diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index 615c8cb354..6c470c19b4 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -34,7 +34,7 @@ jobs: mkcert -cert-file localhost.pem -key-file localhost-key.pem \ localhost 127.0.0.1 ::1 oidc-simulator host.docker.internal - # Copy mkcert root CA to the certs directory for Docker containers + # Copy mkcert root CA for other containers cp "$(mkcert -CAROOT)/rootCA.pem" ./rootCA.pem # List generated files for debugging @@ -89,11 +89,14 @@ jobs: if: always() run: | # Show logs from critical services for debugging + echo "=== NGINX Proxy logs ===" + docker compose -f docker-compose.test.yml logs nginx-proxy || true + echo "=== OIDC Simulator logs ===" - docker compose -f docker-compose.test.yml logs oidc-simulator | tail -100 + docker compose -f docker-compose.test.yml logs oidc-simulator || true echo "=== Server logs ===" - docker compose -f docker-compose.test.yml logs server | tail -100 + docker compose -f docker-compose.test.yml logs server || true - name: Run auth setup test run: | diff --git a/.github/workflows/jest-server-test.yml b/.github/workflows/jest-server-test.yml index 42b0df2338..85d52561c3 100644 --- a/.github/workflows/jest-server-test.yml +++ b/.github/workflows/jest-server-test.yml @@ -65,13 +65,13 @@ jobs: run: | # Build all services except server (tests run on host) docker compose -f docker-compose.test.yml --env-file test.env build \ - math postgres file-server maildev oidc-simulator + math postgres file-server maildev oidc-simulator dynamodb - name: Start services run: | # Start only required services in detached mode (exclude server) docker compose -f docker-compose.test.yml --env-file test.env up -d \ - math postgres file-server maildev oidc-simulator + math postgres file-server maildev oidc-simulator dynamodb # Wait for services to be ready echo "Waiting for services to start..." @@ -88,6 +88,11 @@ jobs: echo "Checking oidc-simulator..." curl -k -f https://localhost:3000/.well-known/jwks.json || true + + echo "Checking DynamoDB..." + curl -f http://localhost:8000 || true + # Alternative check using AWS CLI if available + aws dynamodb list-tables --endpoint-url http://localhost:8000 --region us-east-1 2>/dev/null || true - name: Setup Node.js uses: actions/setup-node@v4 @@ -114,6 +119,7 @@ jobs: export DATABASE_URL=postgres://postgres:PdwPNS2mDN73Vfbc@localhost:5432/polis-test export MAILDEV_HOST=localhost export STATIC_FILES_HOST=localhost + export DYNAMODB_ENDPOINT=http://localhost:8000 # Run the tests on the host machine npm test -- --ci --coverage --maxWorkers=2 @@ -138,6 +144,9 @@ jobs: echo "=== Math logs ===" docker compose -f docker-compose.test.yml logs math | tail -50 + + echo "=== DynamoDB logs ===" + docker compose -f docker-compose.test.yml logs dynamodb | tail -100 - name: Clean up if: always() diff --git a/Makefile b/Makefile index 9141f31f57..dcb22a0e7b 100644 --- a/Makefile +++ b/Makefile @@ -102,8 +102,8 @@ start-FULL-REBUILD: echo_vars stop rm-ALL ## Remove and restart all Docker conta docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} build --no-cache docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} up ${DETACH_ARG} -rebuild-web: echo_vars ## Rebuild and restart just the file-server container and its static assets - docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} up ${DETACH_ARG} --build --force-recreate file-server +rebuild-web: echo_vars ## Rebuild and restart just the file-server container and its static assets, and client-participation-alpha + docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} up ${DETACH_ARG} --build --force-recreate file-server client-participation-alpha build-web-assets: ## Build and extract static web assets for cloud deployment to `build` dir docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} create --build --force-recreate file-server diff --git a/client-admin/src/components/conversation-admin/seed-comment.js b/client-admin/src/components/conversation-admin/seed-comment.js index eb37ba2a83..8d4a35a194 100644 --- a/client-admin/src/components/conversation-admin/seed-comment.js +++ b/client-admin/src/components/conversation-admin/seed-comment.js @@ -117,7 +117,7 @@ const ModerateCommentsSeed = ({ params }) => { Upload a CSV of seed comments - + ) diff --git a/client-participation-alpha/.astro/data-store.json b/client-participation-alpha/.astro/data-store.json index e72bff0062..f68e761761 100644 --- a/client-participation-alpha/.astro/data-store.json +++ b/client-participation-alpha/.astro/data-store.json @@ -1 +1 @@ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.0","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/colinmegill/polis/client-participation-alpha/node_modules/.astro/sessions\"}}}"] \ No newline at end of file +[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.0","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":\"0.0.0.0\",\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/bennie/code/src/github.com/compdemocracy/polis/client-participation-alpha/node_modules/.astro/sessions\"}}}"] \ No newline at end of file diff --git a/client-participation-alpha/.astro/settings.json b/client-participation-alpha/.astro/settings.json index 22ce6f2977..a833462e41 100644 --- a/client-participation-alpha/.astro/settings.json +++ b/client-participation-alpha/.astro/settings.json @@ -1,5 +1,5 @@ { "_variables": { - "lastUpdateCheck": 1753121227723 + "lastUpdateCheck": 1754517865792 } } \ No newline at end of file diff --git a/client-participation-alpha/.astro/types.d.ts b/client-participation-alpha/.astro/types.d.ts index 03d7cc43f1..f964fe0cff 100644 --- a/client-participation-alpha/.astro/types.d.ts +++ b/client-participation-alpha/.astro/types.d.ts @@ -1,2 +1 @@ /// -/// \ No newline at end of file diff --git a/client-participation-alpha/README-AUTH.md b/client-participation-alpha/README-AUTH.md new file mode 100644 index 0000000000..3ff8f423ae --- /dev/null +++ b/client-participation-alpha/README-AUTH.md @@ -0,0 +1,191 @@ +# Authentication and JWT Handling in Client-Participation-Alpha + +## Overview + +The alpha client now has centralized JWT and authentication handling that mirrors the legacy client's approach but with modern patterns. All JWT tokens are automatically extracted and stored from API responses, and authentication headers are automatically added to requests. + +## Key Components + +### 1. `auth.ts` - Authentication Management + +- `setJwtToken(token)` - Stores JWT tokens per conversation (extracts conversation_id from token) +- `getConversationToken(conversation_id)` - Retrieves stored JWT for a specific conversation +- `handleJwtFromResponse(response)` - Automatically extracts and stores JWT from API responses +- `getConversationIdFromUrl()` - Extracts conversation_id from URL path (e.g., /alpha/2demo → "2demo") + +### 2. `net.js` - Network Layer with Auto-Auth + +- Automatically adds authentication headers to all requests +- Automatically extracts and stores JWT tokens from all responses +- Handles both OIDC tokens and conversation-specific JWTs +- Provides consistent error handling with auth error details + +## Usage Examples + +### Simple API Call (JWT handled automatically) + +```javascript +import PolisNet from '../lib/net'; + +// Make an API call - JWT will be automatically added to headers if available +// and automatically extracted/stored from response +const result = await PolisNet.polisPost('/api/v3/votes', { + conversation_id: 'abc123', + tid: 42, + vote: 1 +}); +// No need to manually handle result.auth.token - it's done automatically! +``` + +### Component Example + +```javascript +import PolisNet from '../lib/net'; +import { getConversationToken } from '../lib/auth'; + +export function MyComponent({ conversation_id }) { + const submitData = async (data) => { + // Get current participant info if needed + const token = getConversationToken(conversation_id); + const pid = token?.pid; + + try { + // Make API call - auth headers added automatically + const response = await PolisNet.polisPost('/api/v3/comments', { + conversation_id, + pid, + txt: data.text + }); + + // JWT token from response.auth.token is automatically stored + // No manual handling needed! + + return response; + } catch (error) { + // Error includes responseText for server error messages + console.error('API Error:', error.responseText || error.message); + throw error; + } + }; +} +``` + +## URL Structure and Conversation ID + +The conversation_id is extracted from the URL path, not query parameters: + +- `https://pol.is/alpha/2demo` → conversation_id: "2demo" +- `http://localhost:4321/3xyz` → conversation_id: "3xyz" +- `https://edge.pol.is/alpha/4abc` → conversation_id: "4abc" + +The pattern matches: + +- `/alpha/:conversation_id` (production) +- `/:conversation_id` (development) + +Conversation IDs always start with a digit followed by alphanumeric characters. + +## Authentication Flow + +1. **Initial Page Load (SSR)** + - Server fetches initial data with participationInit + - JWT token included in response + - Client-side script stores token on hydration + +2. **Subsequent API Calls** + - `net.js` checks for OIDC token first + - Falls back to conversation-specific JWT (extracted from URL path) + - Automatically adds to Authorization header + - Server responds with updated JWT if needed + - `net.js` automatically stores new JWT + +3. **Token Priority** + - OIDC tokens (if available) take precedence + - Conversation-specific JWTs used as fallback (based on current URL) + - No token sent if neither available (anonymous access) + +## Migration from Manual JWT Handling + +### Before (Manual JWT Handling) + +```javascript +// DON'T DO THIS ANYMORE +const response = await fetch('/api/v3/votes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) +}); + +const result = await response.json(); + +// Manual JWT extraction and storage +if (result?.auth?.token) { + const token = result.auth.token; + const parts = token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(atob(parts[1])); + if (payload.conversation_id) { + localStorage.setItem('participant_token_' + payload.conversation_id, token); + } + } +} +``` + +### After (Centralized Handling) + +```javascript +// DO THIS INSTEAD +import PolisNet from '../lib/net'; + +const result = await PolisNet.polisPost('/api/v3/votes', data); +// JWT handling is automatic - no manual code needed! +``` + +## Benefits + +1. **DRY Principle** - JWT handling logic in one place +2. **Consistency** - All API calls handle auth the same way +3. **Error Handling** - Centralized auth error handling +4. **Maintainability** - Easy to update auth logic globally +5. **Type Safety** - TypeScript interfaces for auth objects +6. **Automatic Token Refresh** - Server can send new tokens anytime + +## Comparison with Legacy Client + +| Feature | Legacy Client | Alpha Client | +|---------|--------------|--------------| +| JWT Storage | `polisStorage.js` | `auth.ts` | +| Auto JWT Extraction | ✅ `net.js`, `main.js` | ✅ `net.js` | +| Auto Auth Headers | ✅ `backbonePolis.js` | ✅ `net.js` | +| OIDC Support | ❌ | ✅ | +| TypeScript | ❌ | ✅ | +| Centralized | Partial (3 files) | ✅ (2 files) | + +## Testing + +When testing components that use the network layer: + +```javascript +// Mock the net module +jest.mock('../lib/net', () => ({ + polisPost: jest.fn(), + polisGet: jest.fn(), + polisPut: jest.fn() +})); + +// In tests +import PolisNet from '../lib/net'; + +PolisNet.polisPost.mockResolvedValue({ + success: true, + auth: { token: 'mock-jwt-token' } +}); +``` + +## Troubleshooting + +1. **No auth header sent**: Check if token exists for conversation +2. **401 errors**: Token may be expired or invalid +3. **403 errors**: User lacks permission for action +4. **Token not stored**: Check browser storage settings +5. **OIDC vs JWT conflicts**: OIDC takes precedence when available diff --git a/client-participation-alpha/astro.config.mjs b/client-participation-alpha/astro.config.mjs index c2b4e9aa85..2cff71ee3e 100644 --- a/client-participation-alpha/astro.config.mjs +++ b/client-participation-alpha/astro.config.mjs @@ -14,5 +14,10 @@ export default defineConfig({ server: { host: '0.0.0.0' }, - integrations: [react()] + integrations: [ + react({ + experimentalDisableStreaming: true, + experimentalReactChildren: true + }) + ] }); \ No newline at end of file diff --git a/client-participation-alpha/example.env b/client-participation-alpha/example.env new file mode 100644 index 0000000000..342bfebed9 --- /dev/null +++ b/client-participation-alpha/example.env @@ -0,0 +1,4 @@ +INTERNAL_SERVICE_URL=http://localhost/api/v3 +PUBLIC_SERVICE_URL=http://localhost/api/v3 +PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX=@@user@@ +PUBLIC_OIDC_CACHE_KEY_PREFIX=oidc.user diff --git a/client-participation-alpha/package-lock.json b/client-participation-alpha/package-lock.json index 9b65bb189b..2f831bc8d5 100644 --- a/client-participation-alpha/package-lock.json +++ b/client-participation-alpha/package-lock.json @@ -11,7 +11,7 @@ "@astrojs/react": "^4.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "astro": "^5.11.0", + "astro": "^5.12.8", "react": "^19.1.0", "react-dom": "^19.1.0" } @@ -40,11 +40,12 @@ "integrity": "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A==" }, "node_modules/@astrojs/markdown-remark": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.2.tgz", - "integrity": "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.5.tgz", + "integrity": "sha512-MiR92CkE2BcyWf3b86cBBw/1dKiOH0qhLgXH2OXA6cScrrmmks1Rr4Tl0p/lFpvmgQQrP54Pd1uidJfmxGrpWQ==", + "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.6.1", + "@astrojs/internal-helpers": "0.7.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", @@ -59,7 +60,7 @@ "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", - "smol-toml": "^1.3.1", + "smol-toml": "^1.3.4", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", @@ -67,6 +68,12 @@ "vfile": "^6.0.3" } }, + "node_modules/@astrojs/markdown-remark/node_modules/@astrojs/internal-helpers": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz", + "integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==", + "license": "MIT" + }, "node_modules/@astrojs/node": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.3.0.tgz", @@ -85,6 +92,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, @@ -1478,55 +1486,61 @@ ] }, "node_modules/@shikijs/core": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.7.0.tgz", - "integrity": "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.9.2.tgz", + "integrity": "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.7.0.tgz", - "integrity": "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.9.2.tgz", + "integrity": "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.7.0.tgz", - "integrity": "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.9.2.tgz", + "integrity": "sha512-Vn/w5oyQ6TUgTVDIC/BrpXwIlfK6V6kGWDVVz2eRkF2v13YoENUvaNwxMsQU/t6oCuZKzqp9vqtEtEzKl9VegA==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0", + "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.7.0.tgz", - "integrity": "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.9.2.tgz", + "integrity": "sha512-X1Q6wRRQXY7HqAuX3I8WjMscjeGjqXCg/Sve7J2GWFORXkSrXud23UECqTBIdCSNKJioFtmUGJQNKtlMMZMn0w==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0" + "@shikijs/types": "3.9.2" } }, "node_modules/@shikijs/themes": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.7.0.tgz", - "integrity": "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.9.2.tgz", + "integrity": "sha512-6z5lBPBMRfLyyEsgf6uJDHPa6NAGVzFJqH4EAZ+03+7sedYir2yJBRu2uPZOKmj43GyhVHWHvyduLDAwJQfDjA==", + "license": "MIT", "dependencies": { - "@shikijs/types": "3.7.0" + "@shikijs/types": "3.9.2" } }, "node_modules/@shikijs/types": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.7.0.tgz", - "integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.9.2.tgz", + "integrity": "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw==", + "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -1535,7 +1549,8 @@ "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==" + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" }, "node_modules/@swc/helpers": { "version": "0.5.17", @@ -1590,6 +1605,7 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", "dependencies": { "@types/ms": "*" } @@ -1626,12 +1642,14 @@ "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" }, "node_modules/@types/nlcst": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", "dependencies": { "@types/unist": "*" } @@ -1796,7 +1814,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.2", @@ -1810,19 +1829,21 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, "node_modules/astro": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/astro/-/astro-5.11.0.tgz", - "integrity": "sha512-MEICntERthUxJPSSDsDiZuwiCMrsaYy3fnDhp4c6ScUfldCB8RBnB/myYdpTFXpwYBy6SgVsHQ1H4MuuA7ro/Q==", + "version": "5.12.8", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.12.8.tgz", + "integrity": "sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==", + "license": "MIT", "dependencies": { "@astrojs/compiler": "^2.12.2", - "@astrojs/internal-helpers": "0.6.1", - "@astrojs/markdown-remark": "6.3.2", + "@astrojs/internal-helpers": "0.7.1", + "@astrojs/markdown-remark": "6.3.5", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", @@ -1865,6 +1886,7 @@ "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", + "smol-toml": "^1.3.4", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", @@ -1878,7 +1900,7 @@ "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", - "zod": "^3.24.2", + "zod": "^3.24.4", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, @@ -1898,6 +1920,12 @@ "sharp": "^0.33.3" } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz", + "integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==", + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2074,6 +2102,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -2291,6 +2320,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", "dependencies": { "character-entities": "^2.0.0" }, @@ -2490,6 +2520,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -2620,7 +2651,8 @@ "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" }, "node_modules/h3": { "version": "1.15.3", @@ -2678,6 +2710,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" }, @@ -2702,6 +2735,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", @@ -2748,6 +2782,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", @@ -2766,6 +2801,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -2775,6 +2811,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", @@ -2862,6 +2899,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -2961,6 +2999,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -3004,6 +3043,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -3036,6 +3076,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -3045,6 +3086,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -3059,6 +3101,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", @@ -3074,6 +3117,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -3097,6 +3141,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", @@ -3115,6 +3160,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", @@ -3131,6 +3177,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", @@ -3147,6 +3194,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", @@ -3161,6 +3209,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", @@ -3177,6 +3226,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", @@ -3192,6 +3242,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" @@ -3225,6 +3276,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -3245,6 +3297,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0" }, @@ -3272,6 +3325,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -3306,6 +3360,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -3329,6 +3384,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", @@ -3348,6 +3404,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", @@ -3363,6 +3420,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", @@ -3382,6 +3440,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", @@ -3399,6 +3458,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", @@ -3415,6 +3475,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", "dependencies": { "micromark-util-types": "^2.0.0" }, @@ -3427,6 +3488,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", @@ -3453,6 +3515,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", @@ -3473,6 +3536,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", @@ -3494,6 +3558,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -3513,6 +3578,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", @@ -3534,6 +3600,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", @@ -3574,6 +3641,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-symbol": "^2.0.0" } @@ -3592,6 +3660,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", @@ -3612,6 +3681,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -3631,6 +3701,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-symbol": "^2.0.0" } @@ -3649,6 +3720,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", @@ -3684,7 +3756,8 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-normalize-identifier": { "version": "2.0.1", @@ -3700,6 +3773,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-symbol": "^2.0.0" } @@ -3718,6 +3792,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "micromark-util-types": "^2.0.0" } @@ -3756,6 +3831,7 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT", "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", @@ -3856,6 +3932,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", "dependencies": { "@types/nlcst": "^2.0.0" }, @@ -3937,12 +4014,14 @@ "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==" + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" }, "node_modules/oniguruma-to-es": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz", "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", + "license": "MIT", "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", @@ -4003,6 +4082,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", @@ -4074,6 +4154,7 @@ "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", "engines": { "node": ">=6" } @@ -4167,6 +4248,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" } @@ -4175,6 +4257,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" } @@ -4182,7 +4265,8 @@ "node_modules/regex-utilities": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==" + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" }, "node_modules/rehype": { "version": "13.0.2", @@ -4217,6 +4301,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", @@ -4245,6 +4330,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", @@ -4262,6 +4348,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", @@ -4277,6 +4364,7 @@ "version": "11.1.2", "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -4293,6 +4381,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", "dependencies": { "retext": "^9.0.0", "retext-smartypants": "^6.0.0", @@ -4307,6 +4396,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", @@ -4326,6 +4416,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", @@ -4341,6 +4432,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", @@ -4355,6 +4447,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", @@ -4369,6 +4462,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", @@ -4508,16 +4602,17 @@ } }, "node_modules/shiki": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.7.0.tgz", - "integrity": "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==", - "dependencies": { - "@shikijs/core": "3.7.0", - "@shikijs/engine-javascript": "3.7.0", - "@shikijs/engine-oniguruma": "3.7.0", - "@shikijs/langs": "3.7.0", - "@shikijs/themes": "3.7.0", - "@shikijs/types": "3.7.0", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.9.2.tgz", + "integrity": "sha512-t6NKl5e/zGTvw/IyftLcumolgOczhuroqwXngDeMqJ3h3EQiTY/7wmfgPlsmloD8oYfqkEDqxiaH37Pjm1zUhQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.9.2", + "@shikijs/engine-javascript": "3.9.2", + "@shikijs/engine-oniguruma": "3.9.2", + "@shikijs/langs": "3.9.2", + "@shikijs/themes": "3.9.2", + "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } @@ -4540,6 +4635,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.1.tgz", "integrity": "sha512-CxdwHXyYTONGHThDbq5XdwbFsuY4wlClRGejfE2NtwUtiHYsP1QtNsHb/hnj31jKYSchztJsaA8pSQoVzkfCFg==", + "license": "BSD-3-Clause", "engines": { "node": ">= 18" }, @@ -4791,6 +4887,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" @@ -4816,6 +4913,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" @@ -4841,6 +4939,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" @@ -4880,6 +4979,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, diff --git a/client-participation-alpha/package.json b/client-participation-alpha/package.json index aadfca2e57..b9bc836ce9 100644 --- a/client-participation-alpha/package.json +++ b/client-participation-alpha/package.json @@ -4,6 +4,7 @@ "version": "0.0.1", "scripts": { "dev": "astro dev", + "start": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro" @@ -13,7 +14,7 @@ "@astrojs/react": "^4.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "astro": "^5.11.0", + "astro": "^5.12.8", "react": "^19.1.0", "react-dom": "^19.1.0" } diff --git a/client-participation-alpha/public/favicon.ico b/client-participation-alpha/public/favicon.ico new file mode 100644 index 0000000000..a9f9d6196c Binary files /dev/null and b/client-participation-alpha/public/favicon.ico differ diff --git a/client-participation-alpha/src/components/EmailSubscribeForm.jsx b/client-participation-alpha/src/components/EmailSubscribeForm.jsx index 027e060bd6..d81a4bb48f 100644 --- a/client-participation-alpha/src/components/EmailSubscribeForm.jsx +++ b/client-participation-alpha/src/components/EmailSubscribeForm.jsx @@ -1,27 +1,12 @@ +import PolisNet from '../lib/net'; import React, { useState } from 'react'; const subscribeAPI = async (email, conversation_id) => { - const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/notifications`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: 1, - email: email, - conversation_id: conversation_id, - }), - credentials: 'include', + return await PolisNet.polisPost('/notifications', { + type: 1, + email, + conversation_id, }); - - if (!response.ok) { - const errorText = await response.text(); - const error = new Error(errorText || 'Subscription failed'); - error.status = response.status; - throw error; - } - - return await response.json(); }; export default function EmailSubscribeForm({ s, conversation_id }) { diff --git a/client-participation-alpha/src/components/Survey.jsx b/client-participation-alpha/src/components/Survey.jsx index 2c40fa9ec0..b7354d5b04 100644 --- a/client-participation-alpha/src/components/Survey.jsx +++ b/client-participation-alpha/src/components/Survey.jsx @@ -1,62 +1,30 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Statement } from './Statement'; import EmailSubscribeForm from './EmailSubscribeForm'; import { getPreferredLanguages } from '../strings/strings'; import { getConversationToken } from '../lib/auth'; +import PolisNet from '../lib/net'; const submitVoteAndGetNextCommentAPI = async (vote, conversation_id, high_priority = false) => { const decodedToken = getConversationToken(conversation_id); - const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/votes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + + try { + const resp = await PolisNet.polisPost('/votes', { agid: 1, conversation_id, high_priority, lang: getPreferredLanguages()[0], - pid: decodedToken?.pid || "mypid", + pid: decodedToken?.pid || -1, tid: vote.tid, vote: vote.vote, - }), - credentials: 'include', - }); - - if (!response.ok) { - const errorText = await response.text(); - const error = new Error(errorText || 'Vote failed'); - error.status = response.status; + }); + + return resp; + } catch (error) { + // The net module already handles JWT extraction and storage + // Just re-throw the error for the component to handle throw error; } - - - const resp = await response.json(); - - if (resp?.auth?.token) { - // Store the token for later use - try { - const token = resp.auth.token; - const parts = token.split('.'); - if (parts.length === 3) { - const payload = JSON.parse(atob(parts[1])); - if (payload.conversation_id) { - const tokenKey = "participant_token_" + payload.conversation_id; - if (window.localStorage) { - window.localStorage.setItem(tokenKey, token); - } else if (window.sessionStorage) { - window.sessionStorage.setItem(tokenKey, token); - } - } else { - console.warn("[Index] No conversation_id in JWT payload, not storing token."); - } - } - } catch (e) { - console.error("[Index] Failed to store JWT token:", e); - } - } - - return resp; }; @@ -66,6 +34,44 @@ export default function Survey({ initialStatement, s, conversation_id }) { const [isStatementImportant, setIsStatmentImportant] = useState(false); const [voteError, setVoteError] = useState(null); + // On hydration, fetch a participant-personalized next comment. + // This replaces the SSR-provided generic comment if needed. + useEffect(() => { + let cancelled = false; + const loadPersonalizedFirst = async () => { + try { + const decodedToken = getConversationToken(conversation_id); + const pid = decodedToken?.pid ?? -1; + const lang = getPreferredLanguages()[0]; + const resp = await PolisNet.polisGet('/nextComment', { + conversation_id, + lang + }); + + if (!cancelled) { + if (resp && typeof resp.tid !== 'undefined') { + const mapped = { tid: resp.tid, txt: resp.txt, remaining: resp.remaining }; + if (!statement || mapped.tid !== statement.tid) { + setStatement(mapped); + } + } else { + // No personalized next comment available; hide the SSR fallback. + setStatement(undefined); + } + } + } catch (e) { + // Non-fatal; keep SSR statement + console.warn('Personalized first comment fetch failed', e); + } + }; + loadPersonalizedFirst(); + return () => { + cancelled = true; + }; + // Run once on mount for this conversation + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversation_id]); + const handleVote = async (voteType, tid) => { setIsFetchingNext(true); setVoteError(null); @@ -83,14 +89,17 @@ export default function Survey({ initialStatement, s, conversation_id }) { setIsStatmentImportant(false); } catch (error) { - console.error("Vote submission failed:", error.message); + console.error("Vote submission failed:", error); let errorMessage = s.commentSendFailed || "Apologies, your vote failed to send. Please check your connection and try again."; - if (error.message === "polis_err_conversation_is_closed") { + // Check error.responseText first (from net.js), then fall back to error.message + const errorText = error.responseText || error.message || ''; + + if (errorText.includes("polis_err_conversation_is_closed")) { errorMessage = s.convIsClosed || "This conversation is closed. No further voting is allowed."; - } else if (error.message === "polis_err_post_votes_social_needed") { + } else if (errorText.includes("polis_err_post_votes_social_needed")) { errorMessage = "You need to sign in to vote."; - } else if (error.message === "polis_err_xid_not_whitelisted") { + } else if (errorText.includes("polis_err_xid_not_whitelisted")) { errorMessage = "Sorry, you must be registered to vote. Please sign in or contact the conversation owner."; } diff --git a/client-participation-alpha/src/components/SurveyForm.jsx b/client-participation-alpha/src/components/SurveyForm.jsx index da1c72f9a6..16ef4c8d01 100644 --- a/client-participation-alpha/src/components/SurveyForm.jsx +++ b/client-participation-alpha/src/components/SurveyForm.jsx @@ -1,56 +1,25 @@ import React, { useState } from 'react'; import { getConversationToken } from '../lib/auth'; +import PolisNet from '../lib/net'; const submitPerspectiveAPI = async (text, conversation_id) => { const decodedToken = getConversationToken(conversation_id); const pid = decodedToken?.pid; try { - const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/comments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - txt: text.replace(/\n/g, " "), - conversation_id, - pid, - vote: -1, - }), - credentials: 'include', + const resp = await PolisNet.polisPost('/comments', { + txt: text.replace(/\n/g, " "), + conversation_id, + pid, + vote: -1, }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`Comment submission failed with status ${response.status}:`, errorText); - } - - const resp = await response.json(); - - if (resp?.auth?.token) { - // Store the token for later use - try { - const token = resp.auth.token; - const parts = token.split('.'); - if (parts.length === 3) { - const payload = JSON.parse(atob(parts[1])); - if (payload.conversation_id) { - const tokenKey = "participant_token_" + payload.conversation_id; - if (window.localStorage) { - window.localStorage.setItem(tokenKey, token); - } else if (window.sessionStorage) { - window.sessionStorage.setItem(tokenKey, token); - } - } else { - console.warn("[Index] No conversation_id in JWT payload, not storing token."); - } - } - } catch (e) { - console.error("[Index] Failed to store JWT token:", e); - } - } + + // The net module automatically handles JWT extraction and storage + return resp; } catch (error) { - console.error("Network error during comment submission:", error); + console.error("Comment submission failed:", error); + // Re-throw for caller to handle if needed + throw error; } }; diff --git a/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx b/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx index 9668768488..70c32d259a 100644 --- a/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx +++ b/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx @@ -4,8 +4,9 @@ import { extractArchetypalComments } from "./utils/archetypeExtraction"; import LayerHeader from "./components/LayerHeader"; import ScrollableTopicsGrid from "./components/ScrollableTopicsGrid"; import TopicAgendaStyles from "./components/TopicAgendaStyles"; +import PolisNet from "../../lib/net"; -const TopicAgenda = ({ conversation, conversation_id }) => { +const TopicAgenda = ({ conversation_id }) => { const [loadWidget, setLoadWidget] = useState(false); const [selections, setSelections] = useState(new Set()); const [commentMap, setCommentMap] = useState(new Map()); @@ -13,6 +14,7 @@ const TopicAgenda = ({ conversation, conversation_id }) => { const [reportData, setReportData] = useState({}); const [isLoading, setIsLoading] = useState(false); const [err, setError] = useState(null); + const [conversation, setConversation] = useState(null); const { loading, @@ -27,16 +29,13 @@ const TopicAgenda = ({ conversation, conversation_id }) => { const f = async () => { // Check if topic prioritization is available for this conversation try { - const topicPrioritizeResponse = await fetch( - `${import.meta.env.PUBLIC_SERVICE_URL}/participation/topicPrioritize?conversation_id=${conversation_id}`, - { - method: 'GET', - credentials: 'include' - } + const topicPrioritizeResponse = await PolisNet.polisGet( + '/participation/topicPrioritize', + { conversation_id } ); - if (topicPrioritizeResponse.ok) { - const topicPrioritizeData = await topicPrioritizeResponse.json(); + if (topicPrioritizeResponse) { + const topicPrioritizeData = topicPrioritizeResponse; console.log('Topic prioritize check:', topicPrioritizeData); if (topicPrioritizeData.has_report && topicPrioritizeData.report_id) { @@ -44,19 +43,21 @@ const TopicAgenda = ({ conversation, conversation_id }) => { report_id: topicPrioritizeData.report_id, conversation_id: topicPrioritizeData.conversation_id }); + // Also fetch full convo with PCA (large query) + const convoFetcher = await PolisNet.polisGet('/participationInit', { conversation_id, includePCA: true }); + if (convoFetcher) { + setConversation(convoFetcher); + } // Also fetch comments for the TopicAgenda // Use the original zinvite for the comments API, not the numeric conversation_id - const commentsResponse = await fetch( - `${import.meta.env.PUBLIC_SERVICE_URL}/comments?conversation_id=${conversation_id}&moderation=true&include_voting_patterns=true`, - { - method: 'GET', - credentials: 'include' - } + const commentsResponse = await PolisNet.polisGet( + '/comments', + { conversation_id, moderation: 'true', include_voting_patterns: 'true' } ); - if (commentsResponse.ok) { - const cd = await commentsResponse.json(); + if (commentsResponse) { + const cd = commentsResponse; console.log(`Found ${cd.length} comments for topic prioritization`); setComments(cd); } @@ -102,19 +103,15 @@ const TopicAgenda = ({ conversation, conversation_id }) => { useEffect(() => { const checkForData = async () => { try { - const topicPrioritizeResponse = await fetch( - `${import.meta.env.PUBLIC_SERVICE_URL}/participation/topicPrioritize?conversation_id=${conversation_id}`, - { - method: 'GET', - credentials: 'include' - } + const topicPrioritizeResponse = await PolisNet.polisGet( + '/participation/topicPrioritize', + { conversation_id } ); - if (topicPrioritizeResponse.ok) { - const topicPrioritizeData = await topicPrioritizeResponse.json(); + if (topicPrioritizeResponse) { + const topicPrioritizeData = topicPrioritizeResponse; if (topicPrioritizeData.has_report && topicPrioritizeData.report_id) { - fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/delphi?report_id=${topicPrioritizeData.report_id}`) - .then((response) => response.json()) + PolisNet.polisGet('/delphi', { report_id: topicPrioritizeData.report_id }) .then((response) => { if (response && response.status === "success") { if (response.runs && Object.keys(response.runs).length > 0) { @@ -133,7 +130,9 @@ const TopicAgenda = ({ conversation, conversation_id }) => { setError("Failed to connect to the topicMod endpoint"); setIsLoading(false); }); - } + } else { + setError("Failed to retrieve topic data"); + } } } catch (error) { setError("Failed to retrieve topic data"); @@ -146,17 +145,13 @@ const TopicAgenda = ({ conversation, conversation_id }) => { const loadPreviousSelections = async () => { try { - const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/topicAgenda/selections?conversation_id=${conversation.conversation_id}`, { - method: 'GET', - credentials: 'include' - }); - - const result = await response.json(); - + const result = await PolisNet.polisGet( + '/topicAgenda/selections', + { conversation_id: conversation.conversation_id } + ); if (result.status === 'success' && result.data) { - // Convert stored selections back to topic keys const storedSelections = new Set(); - result.data.archetypal_selections.forEach(selection => { + result.data.archetypal_selections.forEach((selection) => { storedSelections.add(selection.topic_key); }); setSelections(storedSelections); @@ -209,20 +204,14 @@ const TopicAgenda = ({ conversation, conversation_id }) => { })) })); - // Send to API - const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/topicAgenda/selections`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + // Send to API (token storage handled centrally in PolisNet) + const result = await PolisNet.polisPost( + '/topicAgenda/selections', + { conversation_id: conversation.conversation_id, - selections: apiSelections - }), - credentials: 'include' - }); - - const result = await response.json(); + selections: apiSelections, + } + ); if (result.status === 'success') { console.log('Topic agenda selections saved successfully:', result.data); @@ -240,7 +229,7 @@ const TopicAgenda = ({ conversation, conversation_id }) => { console.log(`isLoading: ${isLoading}, err: ${err}, loadWidget: ${loadWidget}`) - if (isLoading) { + if (isLoading || err) { return null; } @@ -279,52 +268,55 @@ const TopicAgenda = ({ conversation, conversation_id }) => { } + // Render conditionally after all hooks are called + let content = null; + if (error) { - return ( -
-
-
-

Error

-

{error}

-
+ content = ( +
+
+

Error

+

{error}

-
); - } - - if (comments.length > 0 && Object.keys(reportData).length > 0) { - return ( -
-
-
- - - - -
- -
+ } else if (comments.length > 0 && Object.keys(reportData).length > 0) { + content = ( +
+
+ + + + +
+
-
); } - return null; + // Always return the same component structure + if (!content) return null; + + return ( +
+ {content} + +
+ ); }; diff --git a/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx b/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx index b03d48c73f..1494754774 100644 --- a/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx +++ b/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx @@ -11,16 +11,15 @@ const ScrollableTopicsGrid = ({ }) => { const [visibleLayers, setVisibleLayers] = useState(new Set()); - if (!topicData || !hierarchyAnalysis) return null; - - const runKeys = Object.keys(topicData.runs); - const firstRun = topicData.runs[runKeys[0]]; + // Extract data with safe defaults to avoid early returns after hooks + const runKeys = topicData ? Object.keys(topicData.runs) : []; + const firstRun = runKeys.length > 0 ? topicData.runs[runKeys[0]] : null; + const topicsByLayer = firstRun?.topics_by_layer; + const layers = hierarchyAnalysis?.layers || []; - if (!firstRun.topics_by_layer) return null; - // Get the two coarsest layers (highest numbers) // sortedLayers is ordered from highest to lowest (e.g., [7, 6, 5, 4, 3, 2, 1, 0]) - const sortedLayers = [...hierarchyAnalysis.layers].sort((a, b) => b - a); + const sortedLayers = [...layers].sort((a, b) => b - a); const coarsestLayer = sortedLayers[0]; // e.g., 7 const secondCoarsestLayer = sortedLayers[1]; // e.g., 6 @@ -64,7 +63,7 @@ const ScrollableTopicsGrid = ({ // ================================================================ useEffect(() => { - if (!firstRun || !firstRun.topics_by_layer) return; + if (!firstRun || !topicsByLayer) return; const newVisibleLayers = new Set(); @@ -75,7 +74,7 @@ const ScrollableTopicsGrid = ({ Array.from(selections).forEach(topicKey => { // Find which layer this topic belongs to for (const layerId of sortedLayers) { - const topic = Object.values(firstRun.topics_by_layer[layerId] || {}) + const topic = Object.values(topicsByLayer[layerId] || {}) .find(t => t.topic_key === topicKey); if (topic) { if (!selectionsByLayer.has(layerId)) { @@ -101,10 +100,10 @@ const ScrollableTopicsGrid = ({ }); setVisibleLayers(newVisibleLayers); - }, [selections, sortedLayers.join(','), !!firstRun]); // Stable dependencies + }, [selections, sortedLayers.join(','), !!firstRun, !!topicsByLayer]); // Stable dependencies const renderLayerTopics = (layerId, layerLabel, parentLayerId = null) => { - const allTopics = firstRun.topics_by_layer[layerId]; + const allTopics = topicsByLayer?.[layerId]; if (!allTopics) return null; let topicEntries; @@ -115,7 +114,7 @@ const ScrollableTopicsGrid = ({ // Get selections from the parent layer Array.from(selections).forEach(topicKey => { - const topic = Object.values(firstRun.topics_by_layer[parentLayerId] || {}) + const topic = Object.values(topicsByLayer[parentLayerId] || {}) .find(t => t.topic_key === topicKey); if (topic) { if (!selectionsByLayer.has(parentLayerId)) { @@ -184,6 +183,11 @@ const ScrollableTopicsGrid = ({ return null; // No labels for deeper layers }; + // Return early after all hooks have been called + if (!topicData || !hierarchyAnalysis || !topicsByLayer) { + return null; + } + return (
diff --git a/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js b/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js index 065e90227f..debf6ff9b2 100644 --- a/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js +++ b/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js @@ -1,3 +1,4 @@ +import PolisNet from '../../../lib/net'; import { useState, useEffect, useCallback } from "react"; export const useTopicData = (reportId, load) => { @@ -110,15 +111,15 @@ export const useTopicData = (reportId, load) => { return groups; }; - const fetchUMAPData = useCallback(async (conversation) => { + const fetchUMAPData = useCallback(async (conversation) => { try { const conversationId = conversation?.conversation_id || reportId; console.log("Fetching UMAP data for spatial filtering..."); - const response = await fetch( - `${import.meta.env.PUBLIC_SERVICE_URL}/topicMod/proximity?conversation_id=${conversationId}&layer_id=all` + const data = await PolisNet.polisGet( + '/topicMod/proximity', + { conversation_id: conversationId, layer_id: 'all' } ); - const data = await response.json(); if (data.status === "success" && data.proximity_data) { console.log(`Loaded ${data.proximity_data.length} UMAP points for spatial filtering`); @@ -140,8 +141,7 @@ export const useTopicData = (reportId, load) => { if (!reportId || load === false) return; setLoading(true); - fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/delphi?report_id=${reportId}`) - .then((response) => response.json()) + PolisNet.polisGet('/delphi', { report_id: reportId }) .then((response) => { console.log("TopicAgenda topics response:", response); diff --git a/client-participation-alpha/src/env.d.ts b/client-participation-alpha/src/env.d.ts new file mode 100644 index 0000000000..976ce1ac92 --- /dev/null +++ b/client-participation-alpha/src/env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly PUBLIC_SERVICE_URL: string; + readonly PUBLIC_OIDC_CACHE_KEY_PREFIX: string; + readonly PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX: string; + readonly INTERNAL_SERVICE_URL: string; + // Add other environment variables as needed +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/client-participation-alpha/src/lib/auth.ts b/client-participation-alpha/src/lib/auth.ts index fbfd8e257f..b766211cb4 100644 --- a/client-participation-alpha/src/lib/auth.ts +++ b/client-participation-alpha/src/lib/auth.ts @@ -1,10 +1,32 @@ +const oidcCacheKeyPrefix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_PREFIX; +const oidcCacheKeyIdTokenSuffix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX; + +/** + * Helper function to get conversation ID from current URL path + * Handles URLs like /alpha/2demo or just /2demo + */ +export function getConversationIdFromUrl(): string | null { + if (typeof window === 'undefined') { + return null; + } + + const pathname = window.location.pathname; + // Match patterns like /alpha/2demo or just /2demo + // Conversation IDs start with a digit followed by alphanumeric chars + const match = pathname.match(/^\/(?:alpha\/)?([0-9][0-9A-Za-z]+)/); + if (match) { + return match[1]; + } + return null; +} + /** * Decodes a JWT from localStorage without verifying its signature. * * @param {string} key The localStorage key where the JWT is stored. * @returns {object|null} The decoded JWT payload as an object, or null if the token is not found or invalid. */ -export function getJwtPayload(key: string) { +function _getJwtPayload(key: string) { if (typeof window === 'undefined' || !window.localStorage) { return null; } @@ -30,17 +52,37 @@ export function getJwtPayload(key: string) { } export function getConversationToken(conversation_id: string) { - const token = getJwtPayload(`participant_token_${conversation_id}`); - if (!token) { - return getOidcToken(); + if (typeof window === 'undefined') { + return null; + } + const tokenKey = `participant_token_${conversation_id}`; + const rawToken = localStorage.getItem(tokenKey); + + if (!rawToken) { + // No conversation-specific token, try OIDC + const oidcToken = _getOidcToken(); + if (oidcToken) { + return { token: oidcToken }; + } + return null; } - return token; + + // Decode the JWT to get the payload + const payload = _getJwtPayload(tokenKey); + if (!payload) { + return null; + } + + return { + token: rawToken, + ...payload + }; } -const oidcCacheKeyPrefix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_PREFIX; -const oidcCacheKeyIdTokenSuffix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX; - export function setJwtToken(token: string) { + if (typeof window === 'undefined') { + return; + } try { if (!token) { console.warn("[PolisStorage] Attempted to set null/empty token"); @@ -58,8 +100,6 @@ export function setJwtToken(token: string) { // Store as participant_token_{conversationId} if (window.localStorage) { window.localStorage.setItem(tokenKey, token); - } else if (window.sessionStorage) { - window.sessionStorage.setItem(tokenKey, token); } else { console.warn("[PolisStorage] No storage available for JWT token"); } @@ -96,13 +136,10 @@ function _getOidcTokenFromStorage(storage: Storage) { const parsed = JSON.parse(value); // Check for expiry and access_token if ( - parsed && - parsed.body && - parsed.body.access_token && - parsed.expiresAt && - parsed.expiresAt > Math.floor(Date.now() / 1000) + parsed?.access_token && + parsed?.expires_at > Math.floor(Date.now() / 1000) ) { - return parsed.body.access_token; + return parsed.access_token; } } } catch (e) { @@ -114,10 +151,24 @@ function _getOidcTokenFromStorage(storage: Storage) { return null; } -export function getOidcToken() { - let token = _getOidcTokenFromStorage(window.localStorage); - if (!token) { - token = _getOidcTokenFromStorage(window.sessionStorage); +function _getOidcToken() { + if (typeof window === 'undefined') { + return null; + } + + return _getOidcTokenFromStorage(window.localStorage); +} + +/** + * Automatically extract and store JWT token from API response + * This should be called by the net module for all API responses + */ +export function handleJwtFromResponse(response: any): void { + if (typeof window === 'undefined') { + return; + } + if (response && response.auth && response.auth.token) { + console.log("[Auth] JWT token found in response, storing..."); + setJwtToken(response.auth.token); } - return token; } diff --git a/client-participation-alpha/src/lib/net.js b/client-participation-alpha/src/lib/net.js deleted file mode 100644 index 44aadf1e61..0000000000 --- a/client-participation-alpha/src/lib/net.js +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -const urlPrefix = `${document.location.protocol}//${document.location.host}/` -const basePath = ''; - -// Auth/OIDC token getter function - this should be set by the app when Auth is initialized -let getOidcAccessToken = null; - -let authReady = false; -let authReadyPromise = null; -let authReadyResolve = null; - -// Create a promise that resolves when auth is ready -const initAuthReadyPromise = () => { - authReadyPromise = new Promise((resolve) => { - authReadyResolve = resolve; - }); -} - -// Initialize the promise immediately -initAuthReadyPromise(); - -export const setOidcTokenGetter = (getter) => { - getOidcAccessToken = getter; - - if (getter) { - // Auth is now ready - authReady = true; - if (authReadyResolve) { - authReadyResolve(); - } - } else { - // Auth is being cleared, reset the ready state - authReady = false; - initAuthReadyPromise(); - } -} - -// Store Auth hooks for login redirect -let oidcLoginRedirect = null; - -export const setOidcActions = (actions) => { - if (actions && typeof actions === 'object') { - oidcLoginRedirect = actions.signinRedirect; - } else { - // Clear if null/undefined passed - oidcLoginRedirect = null; - } -} - -// Export functions to check auth readiness -export const isAuthReady = () => authReady; -export const waitForAuthReady = () => authReadyPromise; - -const getAccessTokenSilentlySPA = async (options) => { - // Wait for auth to be ready - if (!authReady && authReadyPromise) { - await authReadyPromise; - } - - if (getOidcAccessToken) { - try { - const token = await getOidcAccessToken({ - cacheMode: 'on', // Use cached token if valid - ...options - }) - return token; - } catch (e) { - // Handle specific OIDC errors - if ( - e.error === 'login_required' && - oidcLoginRedirect && - typeof oidcLoginRedirect === 'function' - ) { - oidcLoginRedirect(); - return null; - } - - // Let the error bubble up to be handled by the calling code - throw e; - } - } else { - console.warn('⚠️ Token getter not available even after waiting'); - return Promise.resolve(undefined); - } -} - -// Request interceptor for handling auth errors -const handleAuthError = (error, response) => { - if (response && (response.status === 401 || response.status === 403)) { - console.warn('Authentication/authorization error:', response.status); - // For 401 (unauthorized), try to redirect to login - if (response.status === 401) { - // Check if we should force signout - if (oidcLoginRedirect && typeof oidcLoginRedirect === 'function') { - oidcLoginRedirect(); - return error; - } - } - } - - throw error; -} - -async function polisFetch(api, data, type) { - if (typeof api !== 'string') { - throw new Error('api param should be a string'); - } - - if (api && api.length && api[0] === '/') { - api = api.slice(1); - } - - let url = urlPrefix + basePath + api; - - const headers = { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'max-age=0' - }; - - let body = null; - let method = type ? type.toUpperCase() : 'GET'; - - if (method === 'GET' && data && Object.keys(data).length > 0) { - const queryParams = new URLSearchParams(data); - url += `?${queryParams.toString()}` - } else if ((method === 'POST' || method === 'PUT') && data && Object.keys(data).length > 0) { - body = JSON.stringify(data); - } - - try { - const token = await getAccessTokenSilentlySPA(); - - // Only add the header if a token exists - if (token) { - headers.Authorization = `Bearer ${token}`; - } - } catch (error) { - // If getting the token fails, continue without it - // The server will decide if auth is required - console.warn('⚠️ Error getting access token:', error); - } - - const response = await fetch(url, { - method: method, - headers: headers, - body: body - }); - - if (!response.ok && response.status !== 304) { - // Read the response body to include in the error - const errorBody = await response.text() - console.error('❌ API Error Response:', { - status: response.status, - statusText: response.statusText, - body: errorBody - }); - - // Create a new error object and attach the response body - const error = new Error( - `Polis API Error: ${method} ${url} failed with status ${response.status} (${response.statusText})` - ); - error.responseText = errorBody; - error.status = response.status; - - return handleAuthError(error, response); - } - - const jsonResponse = await response.json(); - return jsonResponse; -} - -async function polisPost(api, data) { - return await polisFetch(api, data, 'POST'); -} - -async function polisPut(api, data) { - return await polisFetch(api, data, 'PUT'); -} - -async function polisGet(api, data) { - try { - const d = await polisFetch(api, data, 'GET'); - return d; - } catch (error) { - // If we have a 403, it might be the initial race condition. Retry once. - if (error.status === 403) { - console.warn('⚠️ Received 403 on GET, retrying request once after a short delay...'); - await new Promise((resolve) => setTimeout(resolve, 500)); // wait 500ms - return await polisFetch(api, data, 'GET'); // This is the retry - } - // For other errors, or if retry fails, log and re-throw. - console.error('❌ polisGet error:', error); - throw error; - } -} - -const PolisNet = { - polisFetch: polisFetch, - polisPost: polisPost, - polisPut: polisPut, - polisGet: polisGet, - getAccessTokenSilentlySPA -} -export default PolisNet diff --git a/client-participation-alpha/src/lib/net.ts b/client-participation-alpha/src/lib/net.ts new file mode 100644 index 0000000000..13fae7041a --- /dev/null +++ b/client-participation-alpha/src/lib/net.ts @@ -0,0 +1,293 @@ +import { handleJwtFromResponse, getConversationToken, getConversationIdFromUrl } from './auth'; + +// Simplified service base resolution (both env vars are required) +const SERVICE_BASE: string = ( + typeof window !== 'undefined' + ? import.meta.env.PUBLIC_SERVICE_URL + : import.meta.env.INTERNAL_SERVICE_URL + )?.replace(/\/$/, '') || ''; + +// Default request timeout (ms) +const REQUEST_TIMEOUT_MS: number = Number(import.meta.env.PUBLIC_REQUEST_TIMEOUT_MS) || 10000; + +// Optional base path (kept for compatibility; ensure it is normalized when used) +const basePath: string = ''; + +// Type definitions +type OidcTokenGetter = (options?: { cacheMode?: string }) => Promise; +type OidcLoginRedirect = () => void; + +interface PolisApiError extends Error { + responseText?: string; + status?: number; +} + +// Auth/OIDC token getter function - this should be set by the app when Auth is initialized +let getOidcAccessToken: OidcTokenGetter | null = null; + +let authReady = false; +let authReadyPromise: Promise | null = null; +let authReadyResolve: ((value: void) => void) | null = null; + +// Create a promise that resolves when auth is ready +const initAuthReadyPromise = () => { + authReadyPromise = new Promise((resolve) => { + authReadyResolve = resolve; + }); +} + +// Initialize the promise immediately +initAuthReadyPromise(); + +export const setOidcTokenGetter = (getter: OidcTokenGetter | null) => { + getOidcAccessToken = getter; + + if (getter) { + // Auth is now ready + authReady = true; + if (authReadyResolve) { + authReadyResolve(); + } + } else { + // Auth is being cleared, reset the ready state + authReady = false; + initAuthReadyPromise(); + } +}; + +// Store Auth hooks for login redirect +let oidcLoginRedirect: OidcLoginRedirect | null = null; + +interface OidcActions { + signinRedirect?: OidcLoginRedirect; +} + +export const setOidcActions = (actions: OidcActions | null) => { + if (actions && typeof actions === 'object') { + oidcLoginRedirect = actions.signinRedirect || null; + } else { + // Clear if null/undefined passed + oidcLoginRedirect = null; + } +}; + +// Export functions to check auth readiness +export const isAuthReady = () => authReady; +export const waitForAuthReady = () => authReadyPromise; + +const getAccessTokenSilentlySPA = async (options?: { cacheMode?: string }): Promise => { + // On the server, skip OIDC entirely + if (typeof window === 'undefined') { + return undefined; + } + + // If no getter is registered, skip immediately (do not wait) + if (!getOidcAccessToken) { + return undefined; + } + + // Wait for auth to be ready + if (!authReady && authReadyPromise) { + await authReadyPromise; + } + + if (getOidcAccessToken) { + try { + const token = await getOidcAccessToken({ + cacheMode: 'on', // Use cached token if valid + ...options + }); + return token; + } catch (e: any) { + // Handle specific OIDC errors + if ( + e.error === 'login_required' && + oidcLoginRedirect && + typeof oidcLoginRedirect === 'function' + ) { + oidcLoginRedirect(); + return null; + } + + // Let the error bubble up to be handled by the calling code + throw e; + } + } else { + console.warn('⚠️ Token getter not available even after waiting'); + return Promise.resolve(undefined); + } +}; + +// Request interceptor for handling auth errors +const handleAuthError = (error: PolisApiError, response: Response): PolisApiError => { + if (response && (response.status === 401 || response.status === 403)) { + console.warn('Authentication/authorization error:', response.status); + // For 401 (unauthorized), try to redirect to login + if (response.status === 401) { + // Check if we should force signout + if (oidcLoginRedirect && typeof oidcLoginRedirect === 'function') { + oidcLoginRedirect(); + return error; + } + } + } + + throw error; +}; + +async function polisFetch( + api: string, + data?: Record, + type?: string +): Promise { + if (typeof api !== 'string') { + throw new Error('api param should be a string'); + } + + // Build URL: allow absolute URLs; otherwise construct from origin/basePath and api path + let url: string; + const isAbsolute = /^(https?:)?\/\//i.test(api); + if (isAbsolute) { + url = api; + } else { + const apiPath = api.startsWith('/') ? api : `/${api}`; + url = `${SERVICE_BASE}${apiPath}`; + } + + const headers: Record = { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'max-age=0' + }; + + let body: string | null = null; + let method = type ? type.toUpperCase() : 'GET'; + + if (method === 'GET' && data && Object.keys(data).length > 0) { + const queryParams = new URLSearchParams(data); + url += `?${queryParams.toString()}` + } else if ((method === 'POST' || method === 'PUT') && data && Object.keys(data).length > 0) { + body = JSON.stringify(data); + } + + try { + // First try OIDC token + const oidcToken = await getAccessTokenSilentlySPA(); + if (oidcToken) { + headers.Authorization = `Bearer ${oidcToken}`; + } else { + // Fall back to conversation-specific JWT if available + // Extract conversation_id from data or current URL path + let conversationId: string | null = null; + + // First check if conversation_id is in the request data + if (data && (data as any).conversation_id) { + conversationId = (data as any).conversation_id; + } else if (typeof window !== 'undefined') { + // Try to extract from current page URL path using shared helper + conversationId = getConversationIdFromUrl(); + } + + if (conversationId) { + const conversationToken = getConversationToken(conversationId); + if (conversationToken && conversationToken.token) { + headers.Authorization = `Bearer ${conversationToken.token}`; + } + } + } + } catch (error) { + // If getting the token fails, continue without it + // The server will decide if auth is required + console.warn('⚠️ Error getting access token:', error); + } + + console.log('🔍 Requesting:', { + url, + method, + headers, + body + }); + + // Add timeout to avoid indefinite hangs (especially during SSR) + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(url, { + method: method, + headers: headers, + body: body, + signal: controller.signal, + }); + } catch (err: any) { + if (err && err.name === 'AbortError') { + const error: PolisApiError = new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${method} ${url}`); + (error as any).status = 408; + throw error; + } + throw err; + } finally { + clearTimeout(timeout); + } + + if (!response.ok && response.status !== 304) { + // Read the response body to include in the error + const errorBody = await response.text() + console.error('❌ API Error Response:', { + status: response.status, + statusText: response.statusText, + body: errorBody + }); + + // Create a new error object and attach the response body + const error: PolisApiError = new Error( + `Polis API Error: ${method} ${url} failed with status ${response.status} (${response.statusText})` + ); + error.responseText = errorBody; + error.status = response.status; + + // handleAuthError will throw, so this never returns normally + handleAuthError(error, response); + throw error; // TypeScript needs this for type checking even though it's unreachable + } + + const jsonResponse = await response.json(); + + // Automatically handle JWT tokens in response + handleJwtFromResponse(jsonResponse); + + return jsonResponse; +} + +async function polisPost(api: string, data?: Record): Promise { + return await polisFetch(api, data, 'POST'); +} + +async function polisPut(api: string, data?: Record): Promise { + return await polisFetch(api, data, 'PUT'); +} + +async function polisGet(api: string, data?: Record): Promise { + try { + const d = await polisFetch(api, data, 'GET'); + return d; + } catch (error: any) { + // If we have a 403, it might be the initial race condition. Retry once. + if (error.status === 403) { + console.warn('⚠️ Received 403 on GET, retrying request once after a short delay...'); + await new Promise((resolve) => setTimeout(resolve, 500)); // wait 500ms + return await polisFetch(api, data, 'GET'); // This is the retry + } + // For other errors, or if retry fails, log and re-throw. + console.error('❌ polisGet error:', error); + throw error; + } +} + +const PolisNet = { + polisFetch: polisFetch, + polisPost: polisPost, + polisPut: polisPut, + polisGet: polisGet, + getAccessTokenSilentlySPA +} +export default PolisNet diff --git a/client-participation-alpha/src/pages/404.astro b/client-participation-alpha/src/pages/404.astro new file mode 100644 index 0000000000..b3cc286fac --- /dev/null +++ b/client-participation-alpha/src/pages/404.astro @@ -0,0 +1,47 @@ +--- +import Layout from './layouts/Layout.astro'; +import Header from '../components/Header.astro'; +import { getTranslations } from '../strings/strings.js'; + +const s = await getTranslations(); +--- + + +
+
+ +
+

404 - Page Not Found

+

The page you're looking for doesn't exist.

+
+ +
+ + +
+
+
+ + \ No newline at end of file diff --git a/client-participation-alpha/src/pages/[conversation_id].astro b/client-participation-alpha/src/pages/[conversation_id].astro index 7b663fe93a..7dcbaa404b 100644 --- a/client-participation-alpha/src/pages/[conversation_id].astro +++ b/client-participation-alpha/src/pages/[conversation_id].astro @@ -5,15 +5,28 @@ import Survey from '../components/Survey.jsx'; import SurveyForm from '../components/SurveyForm.jsx'; import TopicAgenda from '../components/topicAgenda/TopicAgenda.jsx'; import { getTranslations } from '../strings/strings.js'; +import PolisNet from '../lib/net'; const { conversation_id } = Astro.params; + +// Validate conversation_id - reject common invalid requests +if (!conversation_id || + conversation_id === 'favicon.ico' || + conversation_id === 'robots.txt' || + conversation_id === 'sitemap.xml' || + conversation_id.includes('.') || // Reject any ID with file extensions + conversation_id.length < 3) { // Reject very short IDs + return Astro.redirect('/404'); +} + let initialData; let fetchError = null; try { - const apiParams = new URLSearchParams({ - conversation_id: conversation_id as string, - }); + const apiParams = new URLSearchParams(); + if (conversation_id) { + apiParams.append('conversation_id', conversation_id); + } const xid = Astro.url.searchParams.get('xid'); const x_name = Astro.url.searchParams.get('x_name'); @@ -31,38 +44,27 @@ try { } - const apiUrl = `${import.meta.env.INTERNAL_SERVICE_URL}/participationInit?conversation_id=${conversation_id}`; - - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`API request failed with status: ${response.status}`); - } - initialData = await response.json(); - if (initialData?.auth?.token) { - // Store the token for later use - try { - const token = initialData.auth.token; - const parts = token.split('.'); - if (parts.length === 3) { - const payload = JSON.parse(atob(parts[1])); - if (payload.conversation_id) { - const tokenKey = "participant_token_" + payload.conversation_id; - if (window.localStorage) { - window.localStorage.setItem(tokenKey, token); - } else if (window.sessionStorage) { - window.sessionStorage.setItem(tokenKey, token); - } - } else { - console.warn("[Index] No conversation_id in JWT payload, not storing token."); - } - } - } catch (e) { - console.error("[Index] Failed to store JWT token:", e); - } - } -} catch (error) { + initialData = await PolisNet.polisGet('/participationInit', { conversation_id, includePCA: false }); + // Note: JWT token handling will be done client-side since this is SSR + // The handleJwtFromResponse function will be called when the page hydrates +} catch (error: unknown) { console.error("Failed to fetch conversation data:", error); - fetchError = "Could not load this conversation. Please check the ID and try again."; + + // Type-safe error handling + const errorMessage = error instanceof Error ? error.message : String(error); + const errorCause = error instanceof Error ? error.cause : undefined; + const errorCode = errorCause && typeof errorCause === 'object' && 'code' in errorCause + ? String(errorCause.code) + : undefined; + + console.error("[DEBUG] Error details:", { + message: errorMessage, + cause: errorCause, + code: errorCode, + stack: error instanceof Error ? error.stack : undefined + }); + + fetchError = `Could not load this conversation. Error: ${errorMessage}. Please check the ID and try again.`; } const s = await getTranslations(); @@ -86,6 +88,16 @@ if (initialData) { const hideHeader = Astro.url.searchParams.get('hide_header'); --- + {/* Handle JWT token from initial data on client side */} + +
{hideHeader ? null :
} @@ -106,7 +118,6 @@ const hideHeader = Astro.url.searchParams.get('hide_header'); <>

diff --git a/client-participation-alpha/src/pages/layouts/Layout.astro b/client-participation-alpha/src/pages/layouts/Layout.astro index d45ebfa358..766cd4cee8 100644 --- a/client-participation-alpha/src/pages/layouts/Layout.astro +++ b/client-participation-alpha/src/pages/layouts/Layout.astro @@ -7,6 +7,7 @@ const { title = 'Polis Conversation' } = Astro.props; + {title} diff --git a/client-participation-alpha/tsconfig.json b/client-participation-alpha/tsconfig.json index 69c16003b9..d2a5794b54 100644 --- a/client-participation-alpha/tsconfig.json +++ b/client-participation-alpha/tsconfig.json @@ -9,6 +9,7 @@ ], "compilerOptions": { "jsx": "react-jsx", - "jsxImportSource": "react" + "jsxImportSource": "react", + "module": "esnext" } } \ No newline at end of file diff --git a/client-participation/js/stores/polis.js b/client-participation/js/stores/polis.js index a09f03f8ff..4abd9d6e87 100644 --- a/client-participation/js/stores/polis.js +++ b/client-participation/js/stores/polis.js @@ -156,68 +156,6 @@ module.exports = function (params) { return clusters; } - // TODO rename - function syncAllCommentsForCurrentStimulus() { - // more like sync? - var dfd = $.Deferred(); - var params = { - lastServerToken: new Date(0).getTime(), - not_voted_by_pid: myPid, - conversation_id: conversation_id - //? - }; - - function fail() { - dfd.reject(0); - } - getComments(params).then( - function (comments) { - if (!comments) { - logger.log("no new comments for stimulus"); - dfd.resolve(0); - return; - } - var IDs = _.map(comments, "tid"); - var oldkeys = _.keys(commentsToVoteOn).map(function (tid) { - return parseInt(tid, 10); - }); - var newIDs = _.difference(IDs, oldkeys); - comments.forEach(function (ev) { - var d = ev.created; - if (d > lastServerTokenForComments) { - lastServerTokenForComments = d; - } - }); - var newComments = comments.filter(function (ev) { - return _.includes(newIDs, ev.tid); - }); - for (var i = 0; i < newComments.length; i++) { - var tid = newComments[i].tid; - var alreadyVotedOn = !!votesByMe.findWhere({ - tid: tid - }); - if (!alreadyVotedOn) { - commentsToVoteOn[tid] = newComments[i]; - } - } - var numComments = _.keys(commentsToVoteOn).length; - if (numComments) { - commentsAvailableCallbacks.fire(); - dfd.resolve(numComments); - } else { - fail(); - } - // }, fail); - }, - function (err) { - logger.error("failed to fetch comments"); - logger.dir(err); - fail(); - } - ); - return dfd.promise(); - } - function getNextComment(o) { var params = { not_voted_by_pid: myPid, @@ -2137,7 +2075,6 @@ module.exports = function (params) { invite: invite, convSub: convSub, queryParticipantsByMetadata: queryParticipantsByMetadata, - syncAllCommentsForCurrentStimulus: syncAllCommentsForCurrentStimulus, addInitReadyListener: initReadyCallbacks.add, addAuthStatChangeListener: authStateChangeCallbacks.add, removePersonUpdateListener: personUpdateCallbacks.remove, diff --git a/client-report/REPORT_ROUTES.md b/client-report/REPORT_ROUTES.md new file mode 100644 index 0000000000..2b27af4410 --- /dev/null +++ b/client-report/REPORT_ROUTES.md @@ -0,0 +1,151 @@ +# Polis Client Report Routes Directory + +This document provides a comprehensive overview of all available report routes in the Polis client-report application. All routes follow the pattern `/{routeType}/{report_id}`. + +## Available Routes + +### 1. `/report/{report_id}` +**Component:** Standard Report (default) +**Description:** The main comprehensive report view showing overview, groups, consensus statements, and participant statistics. +**Features:** +- Participant and vote statistics +- Opinion group analysis +- Consensus and divisive statements +- Metadata and demographics + +### 2. `/narrativeReport/{report_id}` +**Component:** NarrativeOverview +**Description:** A narrative-style report presenting the conversation analysis in a more readable, story-like format. +**Features:** +- Narrative summaries of group perspectives +- Key themes and insights +- Contextual analysis of the conversation + +### 3. `/commentsReport/{report_id}` +**Component:** CommentsReport +**Description:** Detailed view of all comments in the conversation with voting patterns and analysis. +**Features:** +- Full list of all comments +- Vote breakdowns (agree/disagree/pass) +- Comment filtering and sorting +- Consensus metrics for each comment + +### 4. `/topicReport/{report_id}` +**Component:** TopicReport +**Description:** Topic-based analysis showing AI-generated narrative summaries for different topics. +**Features:** +- Dropdown selector for different topics +- Side-by-side narrative and cited comments +- Cross-group consensus analysis +- Topic-specific insights + +### 5. `/topicsVizReport/{report_id}` +**Component:** TopicsVizReport +**Description:** Visual representation of topics and their relationships. +**Features:** +- Interactive topic visualization +- Topic clustering and relationships +- Visual exploration of conversation themes + +### 6. `/exportReport/{report_id}` +**Component:** ExportReport +**Description:** Data export interface for downloading conversation data. +**Features:** +- Export conversation data in various formats +- Download raw data for further analysis +- Customizable export options + +### 7. `/topicPrioritize/{report_id}` +**Component:** TopicPrioritize +**Description:** Interface for prioritizing topics based on various metrics. +**Features:** +- Topic ranking and prioritization +- Multi-criteria topic evaluation +- Decision support for topic selection + +### 8. `/topicPrioritizeSimple/{report_id}` +**Component:** TopicPrioritizeSimple +**Description:** Simplified version of topic prioritization interface. +**Features:** +- Streamlined topic prioritization +- Basic ranking functionality +- User-friendly interface for quick decisions + +### 9. `/topicAgenda/{report_id}` +**Component:** TopicAgenda +**Description:** Agenda-building tool based on conversation topics. +**Features:** +- Create meeting agendas from topics +- Organize discussion points +- Export agenda items + +### 10. `/topicHierarchy/{report_id}` +**Component:** TopicHierarchy +**Description:** Hierarchical view of topics showing parent-child relationships. +**Features:** +- Tree-like topic structure +- Topic dependencies and relationships +- Drill-down navigation through topic levels + +### 11. `/topicStats/{report_id}` +**Component:** TopicStats +**Description:** Statistical analysis of topics with detailed metrics and visualizations. +**Features:** +- Topic statistics dashboard +- Comment count and vote density per topic +- Group-aware consensus metrics +- Interactive visualizations (scatterplot, beeswarm) +- Navigate to individual topic pages +- Collective statement generation +- Layer distribution analysis + +### 12. `/topicMapNarrativeReport/{report_id}` +**Component:** TopicMapNarrativeReport +**Description:** Combined view showing topic mapping with narrative reports. +**Features:** +- Overview statistics +- Topic visualization integration +- Narrative topic reports +- Raw data export + +## Usage Examples + +``` +http://localhost:5010/report/2arcefpshi +http://localhost:5010/commentsReport/2arcefpshi +http://localhost:5010/topicStats/2arcefpshi +http://localhost:5010/narrativeReport/2arcefpshi +``` + +## Route Detection + +The application uses pathname-based routing: +1. Extracts the route type from the URL path +2. Extracts the report_id from the URL path +3. Renders the appropriate component based on the route type + +## Adding New Routes + +To add a new report route: +1. Create your component in the appropriate directory +2. Import it in `app.jsx` +3. Add a new conditional block in the render logic: +```javascript +if (route_type === "yourNewRoute") { + console.log("RENDERING: YourNewComponent"); + return ( + + ); +} +``` + +## Notes + +- All routes require a valid `report_id` parameter +- Routes are case-sensitive +- The default route (`/report/{report_id}`) shows the standard comprehensive report +- Some routes may require additional data to be loaded (e.g., math object, comments) +- Routes handle their own data fetching and error states \ No newline at end of file diff --git a/client-report/jest.config.js b/client-report/jest.config.js index d44176cd88..5cd78ed622 100644 --- a/client-report/jest.config.js +++ b/client-report/jest.config.js @@ -89,7 +89,9 @@ const config = { // ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, + moduleNameMapper: { + "\\.(css|less|scss)$": "identity-obj-proxy", + }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], diff --git a/client-report/package-lock.json b/client-report/package-lock.json index b20eb3fb43..6a22979d21 100644 --- a/client-report/package-lock.json +++ b/client-report/package-lock.json @@ -48,6 +48,7 @@ "event-hooks-webpack-plugin": "^2.3.0", "glob": "~10.3.3", "html-webpack-plugin": "^5.5.3", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", @@ -6792,6 +6793,12 @@ "node": ">=0.8.0" } }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, "node_modules/has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -7219,6 +7226,18 @@ "postcss": "^8.1.0" } }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/client-report/package.json b/client-report/package.json index 294193c58a..98e9e6bab3 100644 --- a/client-report/package.json +++ b/client-report/package.json @@ -31,6 +31,7 @@ "event-hooks-webpack-plugin": "^2.3.0", "glob": "~10.3.3", "html-webpack-plugin": "^5.5.3", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", diff --git a/client-report/public/index.html b/client-report/public/index.html index d7d24b9d53..3087a6e1d9 100644 --- a/client-report/public/index.html +++ b/client-report/public/index.html @@ -22,6 +22,7 @@
+ diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index 5768303a5e..9910dfd5b9 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -27,18 +27,30 @@ import CommentsReport from "./commentsReport/CommentsReport.jsx"; import TopicReport from "./topicReport/TopicReport.jsx"; import ExportReport from "./exportReport/ExportReport.jsx"; import TopicsVizReport from "./topicsVizReport/TopicsVizReport.jsx"; -import TopicPrioritize from "./topicPrioritize/TopicPrioritize.jsx"; -import TopicPrioritizeSimple from "./topicPrioritizeSimple/TopicPrioritizeSimple.jsx"; -import TopicAgenda from "./topicAgenda/TopicAgenda.jsx"; import TopicHierarchy from "./topicHierarchy/TopicHierarchy.jsx"; - -const pathname = window.location.pathname; // "/report/2arcefpshi" or "/commentsReport/2arcefpshi" or "/topicReport/2arcefpshi" or "/topicsVizReport/2arcefpshi" or "/exportReport/2arcefpshi" or "/topicPrioritize/2arcefpshi" or "/topicPrioritizeSimple/2arcefpshi" or "/topicAgenda/2arcefpshi" or "/topicHierarchy/2arcefpshi" -const route_type = pathname.split("/")[1]; // "report", "narrativeReport", "commentsReport", "topicReport", "topicsVizReport", "exportReport", "topicPrioritize", "topicPrioritizeSimple", "topicAgenda", or "topicHierarchy" - -const report_id = pathname.split("/")[2]; +import TopicMapNarrativeReport from "./topicMapNarrativeReport.jsx"; +import TopicStats from "./topicStats/TopicStats.jsx"; +import TopicPage from "./topicPage/TopicPage.jsx"; +import CollectiveStatementsReport from "./collectiveStatementsReport/CollectiveStatementsReport.jsx"; +import { enrichMathWithNormalizedConsensus } from "../util/normalizeConsensus.js"; + +const pathname = window.location.pathname; // "/report/2arcefpshi" or "/commentsReport/2arcefpshi" or "/topicReport/2arcefpshi" or "/topicsVizReport/2arcefpshi" or "/exportReport/2arcefpshi" or "/topicHierarchy/2arcefpshi" or "/topicStats/2arcefpshi" +const pathParts = pathname.split("/"); +const route_type = pathParts[1]; // "report", "narrativeReport", "commentsReport", "topicReport", "topicsVizReport", "exportReport", "topicHierarchy", or "topicStats" + +const report_id = pathParts[2]; + +// For topic detail pages: /topicStats/report_id/topic_key +// Topic key can be in format: uuid#layer#cluster or layer_cluster +let topic_key = null; +if (route_type === "topicStats" && pathParts.length > 3) { + // Get the topic key from the URL path and decode it + // Replace %23 with # to restore the original format + topic_key = pathParts[3].replace(/%23/g, '#'); +} // Debug the route -console.log("ROUTE CHECK:", { pathname, route_type, report_id }); +console.log("ROUTE CHECK:", { pathname, route_type, report_id, topic_key }); function assertExists(obj, key) { if (typeof obj[key] === "undefined") { @@ -543,7 +555,10 @@ const App = (props) => { var uniqueCommenters = {}; var voteTotals = DataUtils.getVoteTotals(mathResult); _comments = _comments.map((c) => { - c["group-aware-consensus"] = mathResult["group-aware-consensus"][c.tid]; + // Use normalized consensus if available, fall back to raw + c["group-aware-consensus"] = mathResult["group-consensus-normalized"] ? + mathResult["group-consensus-normalized"][c.tid] : + mathResult["group-aware-consensus"][c.tid]; uniqueCommenters[c.pid] = 1; c = Object.assign(c, voteTotals[c.tid]); return c; @@ -558,6 +573,9 @@ const App = (props) => { commentsPerCommenterAvg: _comments.length / numUniqueCommenters, }; + // Enrich math results with normalized consensus values + mathResult = enrichMathWithNormalizedConsensus(mathResult); + setLoading(false); setMath(mathResult); setConsensus(mathResult.consensus); @@ -591,7 +609,12 @@ const App = (props) => { }; useEffect(() => { - const init = async () => { + const init = async () => { + if (!report_id) { + console.error("No report_id found - API calls will not be made"); + return; + } + await getData(token); // Call to the Delphi endpoint to get LLM-generated topic names @@ -747,9 +770,6 @@ const App = (props) => { shouldShowNarrativeReport: route_type === "narrativeReport", shouldShowTopicReport: route_type === "topicReport", shouldShowExportReport: route_type === "exportReport", - shouldShowTopicPrioritize: route_type === "topicPrioritize", - shouldShowTopicPrioritizeSimple: route_type === "topicPrioritizeSimple", - shouldShowTopicAgenda: route_type === "topicAgenda", shouldShowTopicHierarchy: route_type === "topicHierarchy", }); @@ -764,38 +784,8 @@ const App = (props) => { ); } - // Directly render TopicPrioritize if the URL starts with /topicPrioritize - if (route_type === "topicPrioritize") { - console.log("RENDERING: TopicPrioritize"); - return ( - - ); - } - // Directly render TopicPrioritizeSimple if the URL starts with /topicPrioritizeSimple - if (route_type === "topicPrioritizeSimple") { - console.log("RENDERING: TopicPrioritizeSimple"); - return ( - - ); - } - // Directly render TopicAgenda if the URL starts with /topicAgenda - if (route_type === "topicAgenda") { - console.log("RENDERING: TopicAgenda"); - return ; - } // Directly render TopicHierarchy if the URL starts with /topicHierarchy if (route_type === "topicHierarchy") { @@ -880,6 +870,58 @@ const App = (props) => { ) } + if (route_type === "topicStats") { + // If we have a topic_key, render the individual topic page + if (topic_key) { + console.log("RENDERING: TopicPage for topic", topic_key); + return ( + { + // Navigate back to the topic stats overview + window.location.href = `/topicStats/${report_id}`; + }} + /> + ); + } + + // Otherwise render the topic stats overview + console.log("RENDERING: TopicStats"); + return ( + + ) + } + + if (route_type === "collectiveStatements") { + console.log("RENDERING: CollectiveStatementsReport"); + return ( + + ) + } + // Otherwise render the standard report console.log("RENDERING: Standard report"); return ( diff --git a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx new file mode 100644 index 0000000000..8b41902d68 --- /dev/null +++ b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx @@ -0,0 +1,525 @@ +import React, { useState, useEffect, useRef } from "react"; +import net from "../../util/net"; +import Heading from "../framework/heading.jsx"; +import Footer from "../framework/Footer.jsx"; +import CommentList from "../lists/commentList.jsx"; +import * as globals from "../globals"; + +const CollectiveStatementsReport = ({ conversation, report_id, math, comments, ptptCount, formatTid, voteColors }) => { + const [loading, setLoading] = useState(true); + const [statements, setStatements] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const carouselRef = useRef(null); + const containerRef = useRef(null); + const [cardWidth, setCardWidth] = useState(1400); // Default width - wider for better content display + const [cardHeight, setCardHeight] = useState('600px'); // Default height + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + const [isEmbedded, setIsEmbedded] = useState(false); + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + + // Minimum swipe distance (in px) + const minSwipeDistance = 50; + + const onTouchStart = (e) => { + setTouchEnd(null); + setTouchStart(e.targetTouches[0].clientX); + }; + + const onTouchMove = (e) => { + setTouchEnd(e.targetTouches[0].clientX); + }; + + const onTouchEnd = () => { + if (!touchStart || !touchEnd) return; + + const distance = touchStart - touchEnd; + const isLeftSwipe = distance > minSwipeDistance; + const isRightSwipe = distance < -minSwipeDistance; + + if (isLeftSwipe) { + handleNext(); + } + if (isRightSwipe) { + handlePrevious(); + } + }; + + // Check if embedded + useEffect(() => { + // Check if we're in an iframe + const embedded = window.self !== window.top; + setIsEmbedded(embedded); + }, []); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'ArrowLeft') { + handlePrevious(); + } else if (e.key === 'ArrowRight') { + handleNext(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentIndex, statements.length]); + + useEffect(() => { + const fetchStatements = async () => { + try { + setLoading(true); + const response = await net.polisGet("/api/v3/collectiveStatement", { + report_id: report_id + }); + + if (response.status === "success" && response.statements) { + // Sort by created_at descending (most recent first) + const sortedStatements = response.statements.sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + setStatements(sortedStatements); + } + } catch (err) { + console.error("Error fetching collective statements:", err); + } finally { + setLoading(false); + } + }; + + if (report_id) { + fetchStatements(); + } + }, [report_id]); + + // Update card width and height on resize + useEffect(() => { + const updateCardDimensions = () => { + setWindowWidth(window.innerWidth); + if (containerRef.current) { + const containerWidth = containerRef.current.offsetWidth; + setCardWidth(Math.min(containerWidth * 0.9, 1600)); // 90% of container or max 1600px + + // Calculate available height + const windowHeight = window.innerHeight; + const containerTop = containerRef.current.getBoundingClientRect().top; + const footerHeight = 80; // Approximate footer height + const availableHeight = windowHeight - containerTop - footerHeight - 40; // Less padding needed now + + // On mobile (width < 768px), use fixed height. On desktop, use available space + // Subtract extra space for scale (5% = 50px on a 1000px card) + const scaleBuffer = 50; + if (window.innerWidth < 768) { + setCardHeight('600px'); + } else { + setCardHeight(Math.max(600, availableHeight - scaleBuffer) + 'px'); // No max limit on desktop + } + } + }; + + updateCardDimensions(); + window.addEventListener('resize', updateCardDimensions); + return () => window.removeEventListener('resize', updateCardDimensions); + }, []); + + // Recalculate card dimensions when statements are loaded + useEffect(() => { + if (statements.length > 0 && containerRef.current) { + const containerWidth = containerRef.current.offsetWidth; + setCardWidth(Math.min(containerWidth * 0.9, 1600)); + + // Recalculate height too + const windowHeight = window.innerHeight; + const containerTop = containerRef.current.getBoundingClientRect().top; + const footerHeight = 80; + const availableHeight = windowHeight - containerTop - footerHeight - 40; + + const scaleBuffer = 50; + if (window.innerWidth < 768) { + setCardHeight('600px'); + } else { + setCardHeight(Math.max(600, availableHeight - scaleBuffer) + 'px'); // No max limit on desktop + } + } + }, [statements]); + + const scrollToIndex = (index) => { + if (index === currentIndex) return; + + setCurrentIndex(index); + }; + + const handlePrevious = () => { + const newIndex = currentIndex > 0 ? currentIndex - 1 : statements.length - 1; + scrollToIndex(newIndex); + }; + + const handleNext = () => { + const newIndex = currentIndex < statements.length - 1 ? currentIndex + 1 : 0; + scrollToIndex(newIndex); + }; + + // Extract citations from statement data + const extractCitations = (statementData) => { + const citations = []; + if (statementData && statementData.paragraphs) { + statementData.paragraphs.forEach((paragraph) => { + if (paragraph.sentences) { + paragraph.sentences.forEach((sentence) => { + if (sentence.clauses) { + sentence.clauses.forEach((clause) => { + if (clause.citations && Array.isArray(clause.citations)) { + citations.push(...clause.citations.filter((c) => typeof c === "number")); + } + }); + } + }); + } + }); + } + return [...new Set(citations)]; + }; + + const renderStatement = (statement, index) => { + const uniqueCitations = extractCitations(statement.statement_data); + const isActive = index === currentIndex; + + return ( +
+ {/* Header */} +
+

+ {statement.topic_name} +

+

+ Candidate Collective Statement +

+

+ Generated {new Date(statement.created_at).toLocaleDateString()} at {new Date(statement.created_at).toLocaleTimeString()} + {statement.model && ` • ${statement.model.includes('claude') ? 'Claude Opus 4' : statement.model}`} +

+
+ + {/* Content */} +
+ {/* Statement Text */} +
+

Statement

+ {statement.statement_data && statement.statement_data.paragraphs && + statement.statement_data.paragraphs.map((paragraph, idx) => ( +
+ {paragraph.title && ( +

+ {paragraph.title} +

+ )} + {paragraph.sentences && paragraph.sentences.map((sentence, sIdx) => ( +

+ {sentence.clauses && sentence.clauses.map((clause, cIdx) => ( + + {clause.text} + {clause.citations && clause.citations.length > 0 && ( + + [{clause.citations.join(", ")}] + + )} + {cIdx < sentence.clauses.length - 1 && " "} + + ))} +

+ ))} +
+ )) + } +
+ + {/* Cited Comments */} +
+

+ Cited Comments ({uniqueCitations.length}) +

+ {uniqueCitations.length > 0 ? ( +
+ +
+ ) : ( +

No comments cited

+ )} +
+
+
+ ); + }; + + if (loading) { + return ( +
+ {!isEmbedded && ( +
+ +
+ )} +
+
+

Loading collective statements...

+ +
+
+ ); + } + + if (statements.length === 0) { + return ( +
+ {!isEmbedded && } +
+

No collective statements have been generated yet.

+
+ {!isEmbedded &&
} +
+ ); + } + + return ( +
+ {!isEmbedded && ( +
+ +
+ )} + +
+ {/* Statement Counter and Dots - moved to top */} +
+
+ {currentIndex + 1} of {statements.length} statements +
+ + {/* Dots Indicator */} +
+ {statements.map((_, index) => ( +
+
+ {/* Navigation Buttons */} + + + + + {/* Carousel Container */} +
+
+ {statements.map((statement, index) => renderStatement(statement, index))} +
+
+
+ + {!isEmbedded && ( +
+
+
+ )} +
+ ); +}; + +export default CollectiveStatementsReport; \ No newline at end of file diff --git a/client-report/src/components/commentsReport/CommentsReport.css b/client-report/src/components/commentsReport/CommentsReport.css new file mode 100644 index 0000000000..83dea33f1b --- /dev/null +++ b/client-report/src/components/commentsReport/CommentsReport.css @@ -0,0 +1,666 @@ +.comments-report { + padding: 20px; + width: 90%; + max-width: 1600px; + margin: 0 auto; +} + +.topics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.topic-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + background: white; +} + +.topic-card h3 { + margin-top: 0; + color: #03a9f4; +} + +.topic-meta { + font-size: 0.8rem; + color: #666; + margin-top: 12px; +} + +.layer-section { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.run-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.generated-date { + color: #666; + font-size: 0.9rem; +} + +.info-text { + background: #f0f7ff; + padding: 15px; + border-radius: 8px; + margin-bottom: 30px; + line-height: 1.5; + color: #444; + border-left: 4px solid #03a9f4; +} + +.error-message { + background: #fef8e8; + border: 1px solid #f2d9a0; + border-radius: 8px; + padding: 20px; + margin: 20px 0; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + height: 200px; + font-size: 18px; + color: #666; +} + +.section { + margin-bottom: 40px; +} + +.visualizations-container { + margin-top: 20px; +} + +.visualization-job { + background: white; + border-radius: 8px; + border: 1px solid #e0e0e0; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); +} + +.job-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.job-meta { + display: flex; + align-items: center; + gap: 15px; +} + +.job-status { + font-size: 0.9rem; + padding: 4px 8px; + border-radius: 4px; + font-weight: 500; +} + +.status-completed { + background: #e3f9e5; + color: #1b873f; +} + +.status-running { + background: #eef3fc; + color: #0969da; +} + +.status-pending { + background: #fff8c5; + color: #9a6700; +} + +.status-failed { + background: #ffebe9; + color: #cf222e; +} + +.job-date { + font-size: 0.9rem; + color: #666; +} + +.visualizations-grid { + display: grid; + grid-template-columns: 1fr; + gap: 5px; +} + +.visualization-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 5px; + background: #fafafa; +} + +.visualization-card h4 { + margin-top: 0; + margin-bottom: 15px; + color: #444; +} + +.iframe-container { + overflow: hidden; + border-radius: 6px; + border: 1px solid #ddd; + background: white; +} + +.img-container { + overflow: hidden; + border-radius: 6px; + border: 1px solid #ddd; + background: white; +} + +.narrative-reports-container { + margin-top: 20px; +} + +.run-info-banner { + background: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 4px; + padding: 12px 16px; + margin-bottom: 20px; +} + +.run-info-banner p { + margin: 0; + color: #1976d2; +} + +.run-info-note { + color: #666; + font-size: 0.9em; +} + +.report-section { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.report-section h3 { + margin-top: 0; + color: #03a9f4; + border-bottom: 1px solid #eee; + padding-bottom: 10px; + margin-bottom: 15px; +} + +.report-metadata { + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + margin-bottom: 20px; + border-left: 4px solid #007bff; +} + +.metadata-row { + display: flex; + align-items: center; + margin-bottom: 8px; + font-size: 0.9rem; +} + +.metadata-row:last-child { + margin-bottom: 0; +} + +.metadata-label { + font-weight: 600; + color: #495057; + min-width: 140px; + margin-right: 10px; +} + +.status-badge { + padding: 3px 8px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + margin-right: 10px; +} + +.status-badge.available { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-badge.pending { + background: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; +} + +.status-badge.missing { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.report-content { + line-height: 1.6; +} + +.info-message, +.no-visualizations-message { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + color: #666; + font-style: italic; +} + +.no-visualizations-message .help-text { + font-size: 0.85rem; + margin-top: 8px; + color: #888; +} + +.section-header-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; +} + +.create-job-button { + background-color: #03a9f4; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.create-job-button:hover { + background-color: #0288d1; +} + +.batch-report-button { + background-color: #4caf50; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.batch-report-button:hover { + background-color: #388e3c; +} + +.batch-report-button:disabled { + background-color: #a5d6a7; + cursor: not-allowed; +} + +.job-creation-form-container { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); +} + +.form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid #eee; +} + +.form-header h3 { + margin: 0; + color: #333; +} + +.close-button { + background: none; + border: none; + font-size: 24px; + color: #888; + cursor: pointer; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #444; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} + +.form-group.checkbox { + display: flex; + align-items: center; +} + +.form-group.checkbox label { + display: flex; + align-items: center; + cursor: pointer; +} + +.form-group.checkbox input { + width: auto; + margin-right: 8px; +} + +.form-actions { + margin-top: 25px; + display: flex; + justify-content: flex-end; +} + +.submit-button { + background-color: #4caf50; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.submit-button:hover { + background-color: #388e3c; +} + +.submit-button:disabled { + background-color: #a5d6a7; + cursor: not-allowed; +} + +.result-message { + padding: 12px 16px; + border-radius: 4px; + margin-bottom: 20px; + font-weight: 500; +} + +.result-message.success { + background-color: #e8f5e9; + color: #2e7d32; + border: 1px solid #a5d6a7; +} + +.result-message.error { + background-color: #ffebee; + color: #c62828; + border: 1px solid #ffcdd2; +} + +/* New styles for narrative report layout */ +.narrative-layout-container { + display: flex; + flex-direction: row; + gap: 20px; /* Adjust gap as needed */ +} + +.narrative-text-content { + flex-grow: 0; /* Do not grow beyond basis */ + flex-shrink: 1; /* Allow shrinking if space is tight */ + flex-basis: 520px; /* Preferred width, acts as max when grow is 0 */ +} + +.narrative-comments-column { + flex-grow: 1; /* Grow to fill available space */ + flex-shrink: 1; /* Allow shrinking */ + flex-basis: 0%; /* Start with no intrinsic width, rely on grow */ + min-width: 400px; /* Ensure CommentList doesn't get too squished */ +} + +/* Responsive stacking for smaller screens */ +@media (max-width: 992px) { + .narrative-layout-container { + flex-direction: column; + } + + .narrative-text-content, + .narrative-comments-column { + flex-basis: auto; /* Reset flex-basis */ + width: 100%; /* Take full width when stacked */ + } + + .narrative-comments-column { + margin-top: 30px; /* Add space when stacked below text */ + } +} + +/* Layer switcher styles */ +.layer-switcher { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + border: 1px solid #e9ecef; +} + +.layer-switcher h3 { + margin-top: 0; + margin-bottom: 10px; + color: #333; +} + +.switcher-description { + margin-bottom: 15px; + color: #666; + font-size: 0.9em; +} + +.layer-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.layer-button { + background: white; + border: 2px solid #03a9f4; + color: #03a9f4; + padding: 12px 16px; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + min-width: 140px; +} + +.layer-button:hover { + background: #e3f2fd; +} + +.layer-button.active { + background: #03a9f4; + color: white; +} + +.layer-description { + font-size: 0.8em; + font-weight: normal; + opacity: 0.8; + margin-top: 2px; +} + +/* Action buttons grid */ +.action-buttons-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + margin-top: 20px; +} + +.action-button-group { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; +} + +.action-button-group h3 { + margin-top: 0; + margin-bottom: 15px; + color: #333; + font-size: 1.1em; +} + +@media (max-width: 768px) { + .action-buttons-grid { + grid-template-columns: 1fr; + } + + .layer-buttons { + justify-content: center; + } + + .layer-button { + min-width: 120px; + } +} + +/* Report selector styles (matching TopicReport pattern) */ +.report-selector { + margin-bottom: 30px; +} + +.report-selector select { + width: 100%; + max-width: 800px; + padding: 10px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: white; + margin-bottom: 10px; +} + +.section-status-summary { + display: flex; + gap: 20px; + font-size: 0.9rem; + color: #666; +} + +.status-indicator { + padding: 5px 10px; + background: #f8f9fa; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +/* Topic preview styles */ +.topic-preview { + background: #f0f7ff; + padding: 20px; + border-radius: 8px; + border-left: 4px solid #007bff; +} + +.topic-metadata-display { + background: white; + padding: 15px; + border-radius: 6px; + margin: 15px 0; + border: 1px solid #e9ecef; +} + +.topic-metadata-display h6 { + margin-top: 0; + margin-bottom: 10px; + color: #495057; + font-size: 1rem; +} + +.topic-metadata-display p { + margin-bottom: 8px; + font-size: 0.9rem; +} + +.sample-comments { + margin-top: 15px; +} + +.sample-comments ul { + margin: 10px 0; + padding-left: 20px; +} + +.sample-comments li { + margin-bottom: 8px; + font-size: 0.9rem; + line-height: 1.4; +} + +.action-hint { + margin-top: 15px; + padding: 10px; + background: #fff3cd; + border-radius: 4px; + border-left: 4px solid #ffc107; +} + +.action-hint p { + margin: 0; + font-size: 0.9rem; + color: #856404; +} + +.no-data { + text-align: center; + padding: 40px; + color: #666; + font-style: italic; +} \ No newline at end of file diff --git a/client-report/src/components/commentsReport/CommentsReport.jsx b/client-report/src/components/commentsReport/CommentsReport.jsx index 03269efbc4..65b04a17dd 100644 --- a/client-report/src/components/commentsReport/CommentsReport.jsx +++ b/client-report/src/components/commentsReport/CommentsReport.jsx @@ -2,14 +2,13 @@ import React, { useState, useEffect } from "react"; import { jsonrepair } from "jsonrepair"; import net from "../../util/net"; import { useReportId } from "../framework/useReportId"; -import getNarrativeJSON from "../../util/getNarrativeJSON"; import CommentList from "../lists/commentList.jsx"; +import "./CommentsReport.css"; const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, voteColors, showControls = true }) => { const { report_id } = useReportId(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [topics, setTopics] = useState({}); const [runs, setRuns] = useState({}); const [selectedRunKey, setSelectedRunKey] = useState(null); const [visualizationJobs, setVisualizationJobs] = useState([]); @@ -651,7 +650,7 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo visualizationJobs.length === 0 ? (

- No visualizations available yet. Click "Run New Delphi Analysis" to create a new job. + No visualizations available yet. Click "Run New Delphi Analysis" to create a new job.

) : ( @@ -881,13 +880,13 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo const collectedCitations = []; if (data?.paragraphs) { - data.paragraphs.forEach((paragraph, pIdx) => { + data.paragraphs.forEach((paragraph) => { if (paragraph?.sentences) { - paragraph.sentences.forEach((sentence, sIdx) => { + paragraph.sentences.forEach((sentence) => { if (sentence?.clauses) { - sentence.clauses.forEach((clause, clIdx) => { + sentence.clauses.forEach((clause) => { if (clause?.citations && Array.isArray(clause.citations)) { - clause.citations.forEach((citation, citIdx) => { + clause.citations.forEach((citation) => { if (typeof citation === "number") { collectedCitations.push(citation); } @@ -994,7 +993,7 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo )}
-

To generate the narrative report, use the "Generate Batch Topics" button above.

+

To generate the narrative report, use the "Generate Batch Topics" button above.

); @@ -1131,675 +1130,6 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo
)} - -
); }; diff --git a/client-report/src/components/framework/useReportId.js b/client-report/src/components/framework/useReportId.js index 853f7dc896..122afe79a6 100644 --- a/client-report/src/components/framework/useReportId.js +++ b/client-report/src/components/framework/useReportId.js @@ -7,9 +7,9 @@ export function useReportId() { // Parse the URL to extract the report ID const pathname = window.location.pathname; - // Match patterns like /report/rid or /narrativeReport/rid or /commentsReport/rid or /topicPrioritize/rid or /topicPrioritizeSimple/rid or /topicAgenda/rid or /topicHierarchy/rid + // Match patterns like /report/rid or /narrativeReport/rid or /commentsReport/rid or /topicHierarchy/rid or /topicStats/rid const match = pathname.match( - /^\/(report|narrativeReport|commentsReport|topicPrioritize|topicPrioritizeSimple|topicAgenda|topicMapNarrativeReport|topicHierarchy|topicReport|topicsVizReport|exportReport)\/([a-zA-Z0-9]+)/ + /^\/(report|narrativeReport|commentsReport|topicMapNarrativeReport|topicHierarchy|topicReport|topicsVizReport|exportReport|topicStats)\/([a-zA-Z0-9]+)/ ); if (match && match[2]) { diff --git a/client-report/src/components/lists/commentList.jsx b/client-report/src/components/lists/commentList.jsx index 85ddbff2e0..22d1518794 100644 --- a/client-report/src/components/lists/commentList.jsx +++ b/client-report/src/components/lists/commentList.jsx @@ -6,34 +6,60 @@ import * as globals from "../globals"; const BarChartCompact = ({ comment, voteCounts, nMembers, voteColors }) => { if (!comment) return null; + // Early validation for essential data + const safeNMembers = typeof nMembers === 'number' && nMembers > 0 ? nMembers : 0; + const hasValidVoteCounts = voteCounts && + typeof voteCounts.A === 'number' && + typeof voteCounts.D === 'number' && + typeof voteCounts.S === 'number'; + let w = 100; let agrees = 0; let disagrees = 0; let sawTheComment = 0; let missingCounts = false; - if (typeof voteCounts != "undefined") { - agrees = voteCounts.A; - disagrees = voteCounts.D; - sawTheComment = voteCounts.S; + if (hasValidVoteCounts) { + agrees = Math.max(0, voteCounts.A || 0); + disagrees = Math.max(0, voteCounts.D || 0); + sawTheComment = Math.max(0, voteCounts.S || 0); } else { missingCounts = true; } - let passes = sawTheComment - (agrees + disagrees); - // let totalVotes = agrees + disagrees + passes; - const agree = (agrees / nMembers) * w; - const disagree = (disagrees / nMembers) * w; - const pass = (passes / nMembers) * w; - // const blank = nMembers - (sawTheComment / nMembers) * w; + // If we have missing counts or invalid data, show simplified view + if (missingCounts || safeNMembers === 0 || sawTheComment === 0) { + return ( +
+ + + + + +
+ + {missingCounts ? "Missing vote counts" : "No votes yet"} + +
+
+ ); + } + + let passes = Math.max(0, sawTheComment - (agrees + disagrees)); - const agreeSaw = (agrees / sawTheComment) * w; - const disagreeSaw = (disagrees / sawTheComment) * w; - const passSaw = (passes / sawTheComment) * w; + // Safe division with fallbacks + const agree = safeNMembers > 0 ? (agrees / safeNMembers) * w : 0; + const disagree = safeNMembers > 0 ? (disagrees / safeNMembers) * w : 0; + const pass = safeNMembers > 0 ? (passes / safeNMembers) * w : 0; - const agreeString = (agreeSaw << 0) + "%"; - const disagreeString = (disagreeSaw << 0) + "%"; - const passString = (passSaw << 0) + "%"; + const agreeSaw = sawTheComment > 0 ? (agrees / sawTheComment) * w : 0; + const disagreeSaw = sawTheComment > 0 ? (disagrees / sawTheComment) * w : 0; + const passSaw = sawTheComment > 0 ? (passes / sawTheComment) * w : 0; + + // Ensure percentages are valid numbers + const agreeString = (isNaN(agreeSaw) ? 0 : Math.floor(agreeSaw)) + "%"; + const disagreeString = (isNaN(disagreeSaw) ? 0 : Math.floor(disagreeSaw)) + "%"; + const passString = (isNaN(passSaw) ? 0 : Math.floor(passSaw)) + "%"; return (
{ - - - + + +
@@ -81,15 +125,24 @@ const CommentRow = ({ comment, groups, voteColors }) => { return null; } + const safeGroups = groups || {}; let BarCharts = []; let totalMembers = 0; // groups - Object.entries(groups).forEach(([key, g]) => { + Object.entries(safeGroups).forEach(([key, g]) => { const i = parseInt(key, 10); // Parse the key to an integer + + // Add safety checks for group data + if (!g || typeof g["n-members"] !== 'number') { + return; // Skip this group if it's invalid + } + const nMembers = g["n-members"]; totalMembers += nMembers; - const gVotes = g.votes[comment.tid]; + + // Safely access votes data + const gVotes = g.votes && g.votes[comment.tid] ? g.votes[comment.tid] : undefined; BarCharts.push( { ); }); + // Add overall totals bar chart with safe data BarCharts.unshift( { {comment.txt} @@ -152,6 +210,12 @@ const CommentRow = ({ comment, groups, voteColors }) => { }; const CommentList = ({ comments, math, ptptCount, tidsToRender, voteColors, style }) => { + // Add safety checks for required data + const safeComments = Array.isArray(comments) ? comments : []; + const safeMath = math || {}; + const safePtptCount = typeof ptptCount === 'number' ? ptptCount : 0; + const safeTidsToRender = Array.isArray(tidsToRender) ? tidsToRender : []; + const safeGroupVotes = safeMath["group-votes"] || {}; const getGroupLabels = () => { function makeLabel(key, label, numMembers) { @@ -159,21 +223,22 @@ const CommentList = ({ comments, math, ptptCount, tidsToRender, voteColors, styl - {label} + {label || `Group ${key}`} - {numMembers} + {typeof numMembers === 'number' ? numMembers : 0} ); @@ -181,18 +246,22 @@ const CommentList = ({ comments, math, ptptCount, tidsToRender, voteColors, styl let labels = []; // totals - labels.push(makeLabel(99, "Overall", ptptCount)); + labels.push(makeLabel(99, "Overall", safePtptCount)); - Object.entries(math["group-votes"]).forEach(([key, g]) => { + Object.entries(safeGroupVotes).forEach(([key, g]) => { const i = parseInt(key, 10); - labels.push(makeLabel(i, globals.groupLabels[i], g["n-members"])); + const groupLabel = globals.groupLabels && globals.groupLabels[i] ? globals.groupLabels[i] : `Group ${i}`; + const memberCount = g && typeof g["n-members"] === 'number' ? g["n-members"] : 0; + labels.push(makeLabel(i, groupLabel, memberCount)); }); return labels; } - const cs = comments.reduce((acc, comment) => { - acc[comment.tid] = comment; + const cs = safeComments.reduce((acc, comment) => { + if (comment && typeof comment.tid !== 'undefined') { + acc[comment.tid] = comment; + } return acc; }, {}); @@ -203,6 +272,10 @@ const CommentList = ({ comments, math, ptptCount, tidsToRender, voteColors, styl marginBottom: 1, borderBottom: "2px solid black", position: "relative", + display: "flex", + alignItems: "baseline", + whiteSpace: "nowrap", + overflowX: "auto", }} > Statement @@ -221,13 +295,17 @@ const CommentList = ({ comments, math, ptptCount, tidsToRender, voteColors, styl {getGroupLabels()}
- {tidsToRender.map((tid, i) => { + {safeTidsToRender.map((tid, i) => { + const comment = cs[tid]; + if (!comment) { + return null; // Skip rendering if comment doesn't exist + } return ( ); diff --git a/client-report/src/components/participantsGraph/participantsGraph.jsx b/client-report/src/components/participantsGraph/participantsGraph.jsx index 3e7d39da8c..57f982a615 100644 --- a/client-report/src/components/participantsGraph/participantsGraph.jsx +++ b/client-report/src/components/participantsGraph/participantsGraph.jsx @@ -24,25 +24,40 @@ const Contour = ({ contour }) => { }; const Participants = ({ points, math }) => { - if (!points) { + if (!points || !Array.isArray(points)) { return null; } + // Safely access base-clusters count data + const baseClustersCount = math?.["base-clusters"]?.count || {}; + return ( {points.map((pt, i) => { + if (!pt || typeof pt.id === 'undefined') { + return null; + } + + // Use safe radius calculation with fallback + const count = baseClustersCount[pt.id] || 1; + const radius = Math.sqrt(Math.max(count, 1)) * 3; + return ( - - {" "} - {globals.groupSymbols[pt.gid]} + + {globals.groupSymbols[pt.gid] || ""} ); diff --git a/client-report/src/components/topicHierarchy/TopicHierarchy.jsx b/client-report/src/components/topicHierarchy/TopicHierarchy.jsx index 21abb99b7f..9b15486ac9 100644 --- a/client-report/src/components/topicHierarchy/TopicHierarchy.jsx +++ b/client-report/src/components/topicHierarchy/TopicHierarchy.jsx @@ -16,14 +16,14 @@ const TopicHierarchy = ({ conversation }) => { 0: true, 1: true, 2: true, - 3: true + 3: true, }); - const [visualizationType, setVisualizationType] = useState('hulls'); // 'density' or 'hulls' + const [visualizationType, setVisualizationType] = useState("hulls"); // 'density' or 'hulls' const [densityLayerVisibility, setDensityLayerVisibility] = useState({ 0: false, 1: false, 2: false, - 3: true // Only layer 3 by default + 3: true, // Only layer 3 by default }); const [topicNames, setTopicNames] = useState(new Map()); const circlePackRef = useRef(null); @@ -42,30 +42,32 @@ const TopicHierarchy = ({ conversation }) => { const conversationId = conversation?.conversation_id || report_id; const response = await fetch(`/api/v3/topicMod/hierarchy?conversation_id=${conversationId}`); const data = await response.json(); - + if (data.status === "success" && data.hierarchy) { setHierarchyData(data); console.log("Hierarchy data loaded successfully:", data); console.log("Setting hierarchyData state with:", data); - + // Also fetch topic names for better labeling try { - const topicsResponse = await fetch(`/api/v3/topicMod/topics?conversation_id=${conversationId}`); + const topicsResponse = await fetch( + `/api/v3/topicMod/topics?conversation_id=${conversationId}` + ); const topicsData = await topicsResponse.json(); - + if (topicsData.status === "success" && topicsData.topics_by_layer) { // Create topic name lookup map from topics_by_layer const topicNameMap = new Map(); Object.entries(topicsData.topics_by_layer).forEach(([layer, topics]) => { - topics.forEach(topic => { + topics.forEach((topic) => { const key = `layer${layer}_${topic.cluster_id}`; topicNameMap.set(key, topic.topic_name); }); }); - + // Store topic names in state for density visualization setTopicNames(topicNameMap); - + // Add topic names to hierarchy const addTopicNames = (node) => { const key = `layer${node.layer}_${node.clusterId}`; @@ -76,13 +78,13 @@ const TopicHierarchy = ({ conversation }) => { node.children.forEach(addTopicNames); } }; - + addTopicNames(data.hierarchy); } } catch (topicErr) { console.log("Could not fetch topic names, proceeding without them:", topicErr); } - + // Fetch UMAP data for all clusters await fetchUMAPData(conversationId); } else { @@ -101,17 +103,23 @@ const TopicHierarchy = ({ conversation }) => { const fetchUMAPData = async (conversationId) => { try { console.log("Fetching ALL UMAP coordinates..."); - const response = await fetch(`/api/v3/topicMod/proximity?conversation_id=${conversationId}&layer_id=all`); + const response = await fetch( + `/api/v3/topicMod/proximity?conversation_id=${conversationId}&layer_id=all` + ); const data = await response.json(); - - console.log("CLIENT DEBUG: UMAP response received with", data.proximity_data?.length, "items"); + + console.log( + "CLIENT DEBUG: UMAP response received with", + data.proximity_data?.length, + "items" + ); console.log("CLIENT DEBUG: Response status:", data.status); console.log("CLIENT DEBUG: Response message:", data.message); - + // Log first few items in detail if (data.proximity_data && data.proximity_data.length > 0) { console.log("CLIENT DEBUG: First 3 data points:", data.proximity_data.slice(0, 3)); - + // Check structure of first item const firstItem = data.proximity_data[0]; console.log("CLIENT DEBUG: First item structure:"); @@ -122,43 +130,52 @@ const TopicHierarchy = ({ conversation }) => { console.log(" - clusters type:", typeof firstItem.clusters); console.log(" - clusters keys:", Object.keys(firstItem.clusters || {})); } - + if (data.status === "success" && data.proximity_data) { // Debug: Check cluster assignments const samplePoints = data.proximity_data.slice(0, 5); - console.log("Sample points with clusters:", samplePoints.map(p => ({ - comment_id: p.comment_id, - clusters: p.clusters, - cluster_keys: Object.keys(p.clusters || {}), - cluster_count: Object.keys(p.clusters || {}).length, - raw_point: p // Show the whole point structure - }))); - + console.log( + "Sample points with clusters:", + samplePoints.map((p) => ({ + comment_id: p.comment_id, + clusters: p.clusters, + cluster_keys: Object.keys(p.clusters || {}), + cluster_count: Object.keys(p.clusters || {}).length, + raw_point: p, // Show the whole point structure + })) + ); + // Count how many points have cluster assignments - const pointsWithClusters = data.proximity_data.filter(p => Object.keys(p.clusters || {}).length > 0); - console.log(`Points with cluster assignments: ${pointsWithClusters.length} / ${data.proximity_data.length}`); - + const pointsWithClusters = data.proximity_data.filter( + (p) => Object.keys(p.clusters || {}).length > 0 + ); + console.log( + `Points with cluster assignments: ${pointsWithClusters.length} / ${data.proximity_data.length}` + ); + if (pointsWithClusters.length === 0) { - console.log("No cluster assignments found! Using raw coordinates and assigning all to layer 0"); + console.log( + "No cluster assignments found! Using raw coordinates and assigning all to layer 0" + ); // Fallback: show all points as layer 0 if no cluster assignments - const fallbackData = data.proximity_data.map(point => ({ + const fallbackData = data.proximity_data.map((point) => ({ comment_id: point.comment_id, cluster_id: 0, layer: 0, umap_x: point.umap_x, umap_y: point.umap_y, - weight: point.weight + weight: point.weight, })); console.log("Fallback data:", fallbackData.length, "points"); console.log("Sample fallback point:", fallbackData[0]); setUmapData(fallbackData); return; } - + // Process the data to create points for each layer based on cluster assignments const processedData = []; - - data.proximity_data.forEach(point => { + + data.proximity_data.forEach((point) => { // Create a point for each layer where this comment has a cluster assignment Object.entries(point.clusters || {}).forEach(([layerId, clusterId]) => { processedData.push({ @@ -167,11 +184,11 @@ const TopicHierarchy = ({ conversation }) => { layer: parseInt(layerId), umap_x: point.umap_x, umap_y: point.umap_y, - weight: point.weight + weight: point.weight, }); }); }); - + console.log("UMAP data loaded:", processedData.length, "layer-comment assignments"); console.log("Raw comments:", data.proximity_data.length); console.log("Sample processed point:", processedData[0]); @@ -187,24 +204,24 @@ const TopicHierarchy = ({ conversation }) => { // Toggle layer visibility const toggleLayerVisibility = (layerId) => { - setLayerVisibility(prev => ({ + setLayerVisibility((prev) => ({ ...prev, - [layerId]: !prev[layerId] + [layerId]: !prev[layerId], })); }; // Toggle density layer visibility const toggleDensityLayerVisibility = (layerId) => { - setDensityLayerVisibility(prev => ({ + setDensityLayerVisibility((prev) => ({ ...prev, - [layerId]: !prev[layerId] + [layerId]: !prev[layerId], })); }; // Create UMAP spatial visualization with Canvas for performance const createUMAPVisualization = () => { if (!umapData || !umapRef.current) return; - + if (umapData.length === 0) { console.log("No UMAP data to visualize"); return; @@ -216,24 +233,44 @@ const TopicHierarchy = ({ conversation }) => { const generateClusterColor = (clusterId, layer) => { // Use a color palette similar to datamapplot const baseColors = [ - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', - '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', '#ff9999', '#66b3ff', - '#99ff99', '#ffcc99', '#ff99cc', '#c2c2f0', '#ffb3e6', '#c2f0c2', - '#ffd9b3', '#b3b3ff', '#ffb3b3', '#b3ffb3', '#ffccb3', '#ccb3ff' + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + "#ff9999", + "#66b3ff", + "#99ff99", + "#ffcc99", + "#ff99cc", + "#c2c2f0", + "#ffb3e6", + "#c2f0c2", + "#ffd9b3", + "#b3b3ff", + "#ffb3b3", + "#b3ffb3", + "#ffccb3", + "#ccb3ff", ]; - + // Ensure we have valid inputs - if (typeof clusterId !== 'number' || typeof layer !== 'number') { - return '#999999'; // Default gray color + if (typeof clusterId !== "number" || typeof layer !== "number") { + return "#999999"; // Default gray color } - + // Create a deterministic color based on cluster ID const colorIndex = (clusterId * 7 + layer * 3) % baseColors.length; return baseColors[colorIndex]; }; // Clear previous visualization - umapRef.current.innerHTML = ''; + umapRef.current.innerHTML = ""; const size = 800; // Square canvas const width = size; @@ -250,23 +287,22 @@ const TopicHierarchy = ({ conversation }) => { .style("border", "1px solid #ddd"); const context = canvas.node().getContext("2d"); - + // Enable high DPI const devicePixelRatio = window.devicePixelRatio || 1; - canvas.attr("width", width * devicePixelRatio) - .attr("height", height * devicePixelRatio); + canvas.attr("width", width * devicePixelRatio).attr("height", height * devicePixelRatio); context.scale(devicePixelRatio, devicePixelRatio); // Create scales - const xExtent = extent(umapData, d => d.umap_x); - const yExtent = extent(umapData, d => d.umap_y); - + const xExtent = extent(umapData, (d) => d.umap_x); + const yExtent = extent(umapData, (d) => d.umap_y); + console.log("UMAP data extents:", { xExtent, yExtent }); - + const xScale = scaleLinear() .domain(xExtent) .range([margin.left, width - margin.right]); - + const yScale = scaleLinear() .domain(yExtent) .range([height - margin.bottom, margin.top]); @@ -276,28 +312,30 @@ const TopicHierarchy = ({ conversation }) => { // Get unique raw comment coordinates (without layer duplicates) const uniqueComments = new Map(); - umapData.forEach(point => { + umapData.forEach((point) => { const key = `${point.comment_id}`; if (!uniqueComments.has(key)) { uniqueComments.set(key, { comment_id: point.comment_id, umap_x: point.umap_x, umap_y: point.umap_y, - clusters_by_layer: {} + clusters_by_layer: {}, }); } uniqueComments.get(key).clusters_by_layer[point.layer] = point.cluster_id; }); const uniquePoints = Array.from(uniqueComments.values()); - console.log(`Drawing ${uniquePoints.length} unique comments with cluster assignments for each layer`); + console.log( + `Drawing ${uniquePoints.length} unique comments with cluster assignments for each layer` + ); // Group points by cluster for each layer to draw hulls const clusterGroups = {}; for (let layer = 0; layer <= 3; layer++) { clusterGroups[layer] = new Map(); - - uniquePoints.forEach(point => { + + uniquePoints.forEach((point) => { const clusterId = point.clusters_by_layer[layer]; if (clusterId !== undefined) { const key = `L${layer}C${clusterId}`; @@ -308,12 +346,12 @@ const TopicHierarchy = ({ conversation }) => { } }); } - + // Debug: Show cluster distribution for (let layer = 0; layer <= 3; layer++) { const clusters = clusterGroups[layer]; console.log(`Layer ${layer}: ${clusters.size} clusters`); - + // Show first few clusters and their sizes let count = 0; clusters.forEach((points, clusterKey) => { @@ -333,17 +371,17 @@ const TopicHierarchy = ({ conversation }) => { for (let layer = 3; layer >= 0; layer--) { // Skip this layer if it's not visible if (!layerVisibility[layer]) continue; - + const clusters = clusterGroups[layer]; - + console.log(`Drawing ${clusters.size} individual cluster hulls for Layer ${layer}`); - + clusters.forEach((points, clusterKey) => { if (points.length < 3) return; // Need at least 3 points for hull - - const hullPoints = points.map(p => [xScale(p.umap_x), yScale(p.umap_y)]); + + const hullPoints = points.map((p) => [xScale(p.umap_x), yScale(p.umap_y)]); const hull = polygonHull(hullPoints); - + if (hull && hull.length > 2) { context.beginPath(); context.moveTo(hull[0][0], hull[0][1]); @@ -351,12 +389,12 @@ const TopicHierarchy = ({ conversation }) => { context.lineTo(hull[i][0], hull[i][1]); } context.closePath(); - + // Fill hull with layer color and alpha context.fillStyle = layerColors[layer]; context.globalAlpha = layerAlphas[layer]; context.fill(); - + // Stroke hull with layer color and line width context.strokeStyle = layerColors[layer]; context.globalAlpha = 0.7; @@ -370,10 +408,10 @@ const TopicHierarchy = ({ conversation }) => { context.globalAlpha = 1.0; // Draw all points in neutral color since they belong to multiple clusters - uniquePoints.forEach(point => { + uniquePoints.forEach((point) => { const x = xScale(point.umap_x); const y = yScale(point.umap_y); - + context.beginPath(); context.arc(x, y, 1.5, 0, 2 * Math.PI); context.fillStyle = "#333"; @@ -394,15 +432,18 @@ const TopicHierarchy = ({ conversation }) => { .style("border", "1px solid #ddd") .style("max-width", "300px"); - legendDiv.append("div") + legendDiv + .append("div") .style("font-weight", "bold") .style("margin-bottom", "10px") .style("font-size", "14px") .style("color", "#333") .text("Hull Layer Controls"); - [3, 2, 1, 0].forEach((layer, i) => { // Show from coarsest to finest - const item = legendDiv.append("div") + [3, 2, 1, 0].forEach((layer, i) => { + // Show from coarsest to finest + const item = legendDiv + .append("div") .style("display", "flex") .style("align-items", "center") .style("margin", "6px 0") @@ -413,10 +454,11 @@ const TopicHierarchy = ({ conversation }) => { .on("click", () => { toggleLayerVisibility(layer); }); - + // Checkbox indicator const layerColors = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4"]; - const checkbox = item.append("div") + const checkbox = item + .append("div") .style("width", "16px") .style("height", "16px") .style("border", "2px solid #ccc") @@ -427,17 +469,19 @@ const TopicHierarchy = ({ conversation }) => { .style("justify-content", "center") .style("background", layerVisibility[layer] ? layerColors[layer] : "white") .style("border-color", layerColors[layer]); - + if (layerVisibility[layer]) { - checkbox.append("div") + checkbox + .append("div") .style("width", "8px") .style("height", "8px") .style("background", "white") .style("border-radius", "1px"); } - + // Color indicator showing colors for this layer - const colorBox = item.append("div") + const colorBox = item + .append("div") .style("width", "20px") .style("height", "12px") .style("background", layerColors[layer]) @@ -445,39 +489,40 @@ const TopicHierarchy = ({ conversation }) => { .style("border", "1px solid #ccc") .style("margin-right", "8px") .style("border-radius", "2px"); - + // Label - item.append("span") + item + .append("span") .style("color", layerVisibility[layer] ? "#333" : "#999") .style("font-weight", layerVisibility[layer] ? "500" : "normal") - .text(`Layer ${layer} ${layer === 0 ? '(Finest)' : layer === 3 ? '(Coarsest)' : ''}`); + .text(`Layer ${layer} ${layer === 0 ? "(Finest)" : layer === 3 ? "(Coarsest)" : ""}`); }); // Add basic interactivity with mouse tracking - canvas.on("mousemove", function() { + canvas.on("mousemove", function () { const mousePos = d3Mouse(this); const x = mousePos[0]; const y = mousePos[1]; - + // Convert back to data coordinates const dataX = xScale.invert(x); const dataY = yScale.invert(y); - + // Find closest point (simple implementation) let closestPoint = null; let minDistance = Infinity; - - umapData.forEach(point => { + + umapData.forEach((point) => { const distance = Math.sqrt( - Math.pow(point.umap_x - dataX, 2) + - Math.pow(point.umap_y - dataY, 2) + Math.pow(point.umap_x - dataX, 2) + Math.pow(point.umap_y - dataY, 2) ); - if (distance < minDistance && distance < 1.0) { // Within reasonable distance + if (distance < minDistance && distance < 1.0) { + // Within reasonable distance minDistance = distance; closestPoint = point; } }); - + // Update cursor canvas.style("cursor", closestPoint ? "pointer" : "default"); }); @@ -488,7 +533,7 @@ const TopicHierarchy = ({ conversation }) => { // Create separate density visualization const createDensityVisualization = () => { if (!umapData || !densityRef.current) return; - + if (umapData.length === 0) { console.log("No UMAP data to visualize"); return; @@ -499,22 +544,42 @@ const TopicHierarchy = ({ conversation }) => { // Generate colors similar to datamapplot's approach const generateClusterColor = (clusterId, layer) => { const baseColors = [ - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', - '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', '#ff9999', '#66b3ff', - '#99ff99', '#ffcc99', '#ff99cc', '#c2c2f0', '#ffb3e6', '#c2f0c2', - '#ffd9b3', '#b3b3ff', '#ffb3b3', '#b3ffb3', '#ffccb3', '#ccb3ff' + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + "#ff9999", + "#66b3ff", + "#99ff99", + "#ffcc99", + "#ff99cc", + "#c2c2f0", + "#ffb3e6", + "#c2f0c2", + "#ffd9b3", + "#b3b3ff", + "#ffb3b3", + "#b3ffb3", + "#ffccb3", + "#ccb3ff", ]; - - if (typeof clusterId !== 'number' || typeof layer !== 'number') { - return '#999999'; + + if (typeof clusterId !== "number" || typeof layer !== "number") { + return "#999999"; } - + const colorIndex = (clusterId * 7 + layer * 3) % baseColors.length; return baseColors[colorIndex]; }; // Clear previous visualization - densityRef.current.innerHTML = ''; + densityRef.current.innerHTML = ""; const size = 800; const width = size; @@ -531,21 +596,20 @@ const TopicHierarchy = ({ conversation }) => { .style("border", "1px solid #ddd"); const context = canvas.node().getContext("2d"); - + // Enable high DPI const devicePixelRatio = window.devicePixelRatio || 1; - canvas.attr("width", width * devicePixelRatio) - .attr("height", height * devicePixelRatio); + canvas.attr("width", width * devicePixelRatio).attr("height", height * devicePixelRatio); context.scale(devicePixelRatio, devicePixelRatio); // Create scales - const xExtent = extent(umapData, d => d.umap_x); - const yExtent = extent(umapData, d => d.umap_y); - + const xExtent = extent(umapData, (d) => d.umap_x); + const yExtent = extent(umapData, (d) => d.umap_y); + const xScale = scaleLinear() .domain(xExtent) .range([margin.left, width - margin.right]); - + const yScale = scaleLinear() .domain(yExtent) .range([height - margin.bottom, margin.top]); @@ -555,14 +619,14 @@ const TopicHierarchy = ({ conversation }) => { // Get unique raw comment coordinates const uniqueComments = new Map(); - umapData.forEach(point => { + umapData.forEach((point) => { const key = `${point.comment_id}`; if (!uniqueComments.has(key)) { uniqueComments.set(key, { comment_id: point.comment_id, umap_x: point.umap_x, umap_y: point.umap_y, - clusters_by_layer: {} + clusters_by_layer: {}, }); } uniqueComments.get(key).clusters_by_layer[point.layer] = point.cluster_id; @@ -574,8 +638,8 @@ const TopicHierarchy = ({ conversation }) => { const clusterGroups = {}; for (let layer = 0; layer <= 3; layer++) { clusterGroups[layer] = new Map(); - - uniquePoints.forEach(point => { + + uniquePoints.forEach((point) => { const clusterId = point.clusters_by_layer[layer]; if (clusterId !== undefined) { const key = `L${layer}C${clusterId}`; @@ -590,69 +654,70 @@ const TopicHierarchy = ({ conversation }) => { // Create 2D density plots only for visible layers const densityRadius = 25; const gridSize = 4; - + // Draw density from coarsest to finest (3 → 0) so finer densities appear on top for (let layer = 3; layer >= 0; layer--) { // Skip this layer if it's not visible if (!densityLayerVisibility[layer]) continue; - + const clusters = clusterGroups[layer]; - + console.log(`Drawing density plots for ${clusters.size} clusters in Layer ${layer}`); - + clusters.forEach((points, clusterKey) => { if (points.length < 2) return; - + const clusterIdMatch = clusterKey.match(/C(\d+)/); const clusterId = clusterIdMatch ? parseInt(clusterIdMatch[1]) : 0; const clusterColor = generateClusterColor(clusterId, layer); - - if (!clusterColor || typeof clusterColor !== 'string') { + + if (!clusterColor || typeof clusterColor !== "string") { console.warn(`Invalid color generated for cluster ${clusterKey}`); return; } - + // Create density map for this cluster const densityMap = new Map(); - + // Calculate density at grid points for (let x = margin.left; x < width - margin.right; x += gridSize) { for (let y = margin.top; y < height - margin.bottom; y += gridSize) { let density = 0; const gridKey = `${x},${y}`; - - points.forEach(point => { + + points.forEach((point) => { const px = xScale(point.umap_x); const py = yScale(point.umap_y); const distance = Math.sqrt((x - px) ** 2 + (y - py) ** 2); - + if (distance <= densityRadius) { density += Math.exp(-(distance ** 2) / (2 * (densityRadius / 3) ** 2)); } }); - + if (density > 0.1) { densityMap.set(gridKey, density); } } } - + // Draw contour lines instead of filled density const maxDensity = Math.max(...densityMap.values()); if (maxDensity > 0) { // Create contour levels (like topographic lines) - const contourLevels = [0.2, 0.4, 0.6, 0.8].map(level => level * maxDensity); - + const contourLevels = [0.2, 0.4, 0.6, 0.8].map((level) => level * maxDensity); + contourLevels.forEach((level, levelIndex) => { // Find grid points at this density level const contourPoints = []; densityMap.forEach((density, gridKey) => { - if (Math.abs(density - level) < maxDensity * 0.1) { // Within 10% of level - const [x, y] = gridKey.split(',').map(Number); + if (Math.abs(density - level) < maxDensity * 0.1) { + // Within 10% of level + const [x, y] = gridKey.split(",").map(Number); contourPoints.push([x, y]); } }); - + // Draw contour lines if (contourPoints.length > 2) { try { @@ -664,16 +729,16 @@ const TopicHierarchy = ({ conversation }) => { context.lineTo(hull[i][0], hull[i][1]); } context.closePath(); - + // Draw contour line context.strokeStyle = clusterColor; - context.lineWidth = 2 - (levelIndex * 0.3); // Thicker lines for higher density + context.lineWidth = 2 - levelIndex * 0.3; // Thicker lines for higher density context.globalAlpha = 0.6; context.stroke(); - + // Light fill for the innermost contour if (levelIndex === contourLevels.length - 1) { - const hex = clusterColor.replace('#', ''); + const hex = clusterColor.replace("#", ""); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); @@ -694,10 +759,10 @@ const TopicHierarchy = ({ conversation }) => { context.globalAlpha = 1.0; // Draw all points with lighter color - uniquePoints.forEach(point => { + uniquePoints.forEach((point) => { const x = xScale(point.umap_x); const y = yScale(point.umap_y); - + context.beginPath(); context.arc(x, y, 1, 0, 2 * Math.PI); context.fillStyle = "#ccc"; // Much lighter gray @@ -716,24 +781,24 @@ const TopicHierarchy = ({ conversation }) => { context.font = "5px Arial"; // Even smaller font context.textAlign = "center"; context.textBaseline = "middle"; - + for (let layer = 3; layer >= 0; layer--) { if (!densityLayerVisibility[layer]) continue; - + const clusters = clusterGroups[layer]; - + clusters.forEach((points, clusterKey) => { if (points.length < 2) return; - + // Calculate centroid const centroidX = points.reduce((sum, p) => sum + xScale(p.umap_x), 0) / points.length; const centroidY = points.reduce((sum, p) => sum + yScale(p.umap_y), 0) / points.length; - + // Get cluster ID and topic name const clusterIdMatch = clusterKey.match(/C(\d+)/); const clusterId = clusterIdMatch ? parseInt(clusterIdMatch[1]) : 0; const topicName = getTopicName(layer, clusterId); - + // Format: "3_7: Transportation" or just the topic name if it doesn't already include the layer_cluster let label; if (topicName) { @@ -747,27 +812,22 @@ const TopicHierarchy = ({ conversation }) => { } else { label = `${layer}_${clusterId}`; } - + // Draw text with subtle background for readability const textMetrics = context.measureText(label); const padding = 1; // Much smaller padding - const bgWidth = textMetrics.width + (padding * 2); + const bgWidth = textMetrics.width + padding * 2; const bgHeight = 6; // Much smaller height for tiny font - + // Draw very subtle background context.fillStyle = "rgba(255, 255, 255, 0.1)"; // Much more transparent - context.fillRect( - centroidX - bgWidth/2, - centroidY - bgHeight/2, - bgWidth, - bgHeight - ); - + context.fillRect(centroidX - bgWidth / 2, centroidY - bgHeight / 2, bgWidth, bgHeight); + // Draw text with white stroke outline context.lineWidth = 1; // Thinner stroke for tiny text context.strokeStyle = "white"; context.strokeText(label, centroidX, centroidY); - + // Draw text fill context.fillStyle = "#333"; context.fillText(label, centroidX, centroidY); @@ -786,7 +846,8 @@ const TopicHierarchy = ({ conversation }) => { .style("border", "1px solid #ddd") .style("max-width", "300px"); - legendDiv.append("div") + legendDiv + .append("div") .style("font-weight", "bold") .style("margin-bottom", "10px") .style("font-size", "14px") @@ -794,20 +855,25 @@ const TopicHierarchy = ({ conversation }) => { .text("Density Layer Controls"); [3, 2, 1, 0].forEach((layer, i) => { - const item = legendDiv.append("div") + const item = legendDiv + .append("div") .style("display", "flex") .style("align-items", "center") .style("margin", "6px 0") .style("padding", "3px") .style("border-radius", "4px") - .style("background", densityLayerVisibility[layer] ? "rgba(0,0,0,0.02)" : "rgba(0,0,0,0.05)") + .style( + "background", + densityLayerVisibility[layer] ? "rgba(0,0,0,0.02)" : "rgba(0,0,0,0.05)" + ) .style("cursor", "pointer") .on("click", () => { toggleDensityLayerVisibility(layer); }); - + // Checkbox indicator - const checkbox = item.append("div") + const checkbox = item + .append("div") .style("width", "16px") .style("height", "16px") .style("border", "2px solid #ccc") @@ -816,32 +882,44 @@ const TopicHierarchy = ({ conversation }) => { .style("display", "flex") .style("align-items", "center") .style("justify-content", "center") - .style("background", densityLayerVisibility[layer] ? generateClusterColor(0, layer) : "white") + .style( + "background", + densityLayerVisibility[layer] ? generateClusterColor(0, layer) : "white" + ) .style("border-color", generateClusterColor(0, layer)); - + if (densityLayerVisibility[layer]) { - checkbox.append("div") + checkbox + .append("div") .style("width", "8px") .style("height", "8px") .style("background", "white") .style("border-radius", "1px"); } - + // Color indicator - const colorBox = item.append("div") + const colorBox = item + .append("div") .style("width", "20px") .style("height", "12px") - .style("background", `linear-gradient(45deg, ${generateClusterColor(0, layer)}, ${generateClusterColor(1, layer)}, ${generateClusterColor(2, layer)})`) + .style( + "background", + `linear-gradient(45deg, ${generateClusterColor(0, layer)}, ${generateClusterColor( + 1, + layer + )}, ${generateClusterColor(2, layer)})` + ) .style("opacity", densityLayerVisibility[layer] ? "0.8" : "0.3") .style("border", "1px solid #ccc") .style("margin-right", "8px") .style("border-radius", "2px"); - + // Label - item.append("span") + item + .append("span") .style("color", densityLayerVisibility[layer] ? "#333" : "#999") .style("font-weight", densityLayerVisibility[layer] ? "500" : "normal") - .text(`Layer ${layer} ${layer === 0 ? '(Finest)' : layer === 3 ? '(Coarsest)' : ''}`); + .text(`Layer ${layer} ${layer === 0 ? "(Finest)" : layer === 3 ? "(Coarsest)" : ""}`); }); console.log("Canvas density visualization rendered successfully"); @@ -866,7 +944,7 @@ const TopicHierarchy = ({ conversation }) => { // Create hierarchy from data const hierarchyRoot = hierarchy(hierarchyData.hierarchy) - .sum(d => d.size || 1) // Use cluster size for circle size + .sum((d) => d.size || 1) // Use cluster size for circle size .sort((a, b) => b.value - a.value); // Create pack layout @@ -882,24 +960,26 @@ const TopicHierarchy = ({ conversation }) => { .range(["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4"]); // Create groups for each node - const nodeGroups = svg.selectAll("g") + const nodeGroups = svg + .selectAll("g") .data(nodes.descendants()) .enter() .append("g") - .attr("transform", d => `translate(${d.x + 10},${d.y + 10})`); + .attr("transform", (d) => `translate(${d.x + 10},${d.y + 10})`); // Add circles - nodeGroups.append("circle") - .attr("r", d => d.r) - .attr("fill", d => { + nodeGroups + .append("circle") + .attr("r", (d) => d.r) + .attr("fill", (d) => { if (d.depth === 0) return "#f8f9fa"; // Root return colorScale(d.data.layer); }) - .attr("stroke", d => d.depth === 0 ? "#dee2e6" : "#343a40") - .attr("stroke-width", d => d.depth === 0 ? 2 : 1) - .attr("fill-opacity", d => d.depth === 0 ? 0.1 : 0.7) + .attr("stroke", (d) => (d.depth === 0 ? "#dee2e6" : "#343a40")) + .attr("stroke-width", (d) => (d.depth === 0 ? 2 : 1)) + .attr("fill-opacity", (d) => (d.depth === 0 ? 0.1 : 0.7)) .style("cursor", "pointer") - .on("click", function(event, d) { + .on("click", function (event, d) { if (d.data.layer !== undefined) { console.log("Clicked cluster:", d.data); // setSelectedLayer(d.data.layer); // Comment out if this state doesn't exist in TopicHierarchy @@ -907,62 +987,63 @@ const TopicHierarchy = ({ conversation }) => { }); // Add text labels for larger circles - nodeGroups.append("text") + nodeGroups + .append("text") .attr("text-anchor", "middle") .attr("dy", "0.3em") - .attr("font-size", d => Math.min(d.r / 4, 12)) + .attr("font-size", (d) => Math.min(d.r / 4, 12)) .attr("fill", "#343a40") .attr("font-weight", "bold") .style("pointer-events", "none") - .text(d => { + .text((d) => { if (d.depth === 0) return "Topics"; if (d.r < 20) return ""; // Hide text for very small circles return `L${d.data.layer} C${d.data.clusterId}`; }); // Add size labels for larger circles - nodeGroups.append("text") + nodeGroups + .append("text") .attr("text-anchor", "middle") .attr("dy", "1.5em") - .attr("font-size", d => Math.min(d.r / 6, 10)) + .attr("font-size", (d) => Math.min(d.r / 6, 10)) .attr("fill", "#6c757d") .style("pointer-events", "none") - .text(d => { + .text((d) => { if (d.depth === 0 || d.r < 25) return ""; return `${d.data.size} comments`; }); // Add legend - const legend = svg.append("g") - .attr("transform", `translate(${width - 150}, 20)`); + const legend = svg.append("g").attr("transform", `translate(${width - 150}, 20)`); - legend.append("text") - .attr("font-weight", "bold") - .attr("font-size", "14") - .text("Layers"); + legend.append("text").attr("font-weight", "bold").attr("font-size", "14").text("Layers"); - const legendItems = legend.selectAll(".legend-item") + const legendItems = legend + .selectAll(".legend-item") .data([ { layer: 0, label: "Layer 0 (Finest)", color: "#ff6b6b" }, { layer: 1, label: "Layer 1", color: "#4ecdc4" }, { layer: 2, label: "Layer 2", color: "#45b7d1" }, - { layer: 3, label: "Layer 3 (Coarsest)", color: "#96ceb4" } + { layer: 3, label: "Layer 3 (Coarsest)", color: "#96ceb4" }, ]) .enter() .append("g") .attr("class", "legend-item") .attr("transform", (d, i) => `translate(0, ${20 + i * 20})`); - legendItems.append("circle") + legendItems + .append("circle") .attr("r", 8) - .attr("fill", d => d.color) + .attr("fill", (d) => d.color) .attr("fill-opacity", 0.7); - legendItems.append("text") + legendItems + .append("text") .attr("x", 15) .attr("dy", "0.3em") .attr("font-size", "12") - .text(d => d.label); + .text((d) => d.label); }; // Effect to create circle pack when hierarchy data is available and DOM is ready @@ -970,35 +1051,39 @@ const TopicHierarchy = ({ conversation }) => { console.log("Circle pack useEffect triggered:", { hierarchyData: !!hierarchyData, hierarchyDataStructure: hierarchyData ? Object.keys(hierarchyData) : null, - refCurrent: !!circlePackRef.current + refCurrent: !!circlePackRef.current, }); - - const tryCreateCirclePack = () => { - if (hierarchyData && circlePackRef.current) { - console.log("Attempting to create circle pack..."); + + if (!hierarchyData) return; + + // Use MutationObserver to wait for the DOM element + const observer = new MutationObserver(() => { + if (circlePackRef.current) { + console.log("Circle pack ref now available, creating visualization"); createCirclePack(); - return true; + observer.disconnect(); } - console.log("Circle pack creation failed:", { - hierarchyData: !!hierarchyData, - refCurrent: !!circlePackRef.current - }); - return false; - }; + }); - if (hierarchyData) { - // Try immediately - if (!tryCreateCirclePack()) { - // If that fails, try with a delay - const timer = setTimeout(() => { - if (!tryCreateCirclePack()) { - console.log("Circle pack: ref still not available after timeout"); - } - }, 300); - - return () => clearTimeout(timer); - } + // If ref is already available, create immediately + if (circlePackRef.current) { + console.log("Circle pack ref already available, creating visualization"); + createCirclePack(); + } else { + // Otherwise, observe the document body for changes + console.log("Circle pack ref not available, setting up observer"); + // Wait a frame to ensure React has rendered + requestAnimationFrame(() => { + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); } + + return () => { + observer.disconnect(); + }; }, [hierarchyData]); // Effect to create UMAP visualization when data is available @@ -1053,7 +1138,10 @@ const TopicHierarchy = ({ conversation }) => { return (
-

Topic Hierarchy

+

+ Topic Hierarchy (debug route - if you're here, you're probably looking for + /topicStats/report_id){" "} +

Interactive circle pack visualization of hierarchical topic clusters
@@ -1064,17 +1152,20 @@ const TopicHierarchy = ({ conversation }) => { {/* Density Visualization - First */}

Topic Spatial Distribution - Contours

-

UMAP projection with topographic contour lines showing cluster density (Layer 3 coarsest by default)

+

+ UMAP projection with topographic contour lines showing cluster density (Layer 3 coarsest + by default) +

- + {/* UMAP Spatial Visualization */}

Topic Spatial Distribution - Hulls

UMAP projection showing semantic neighborhoods with convex hulls around clusters

- + {/* Circle Pack Visualization */}

Topic Hierarchy

@@ -1176,4 +1267,4 @@ const TopicHierarchy = ({ conversation }) => { ); }; -export default TopicHierarchy; \ No newline at end of file +export default TopicHierarchy; diff --git a/client-report/src/components/topicPage/TopicPage.jsx b/client-report/src/components/topicPage/TopicPage.jsx new file mode 100644 index 0000000000..321d27ace1 --- /dev/null +++ b/client-report/src/components/topicPage/TopicPage.jsx @@ -0,0 +1,811 @@ +import React, { useState, useEffect } from "react"; +import net from "../../util/net"; +import Heading from "../framework/heading.jsx"; +import Footer from "../framework/Footer.jsx"; +import TopicBeeswarm from "../topicStats/visualizations/TopicBeeswarm.jsx"; +import AllCommentsScatterplot from "../topicStats/visualizations/AllCommentsScatterplot.jsx"; +import CommentList from "../lists/commentList.jsx"; +import * as globals from "../globals"; +import { canGenerateCollectiveStatement, getTopicConsensusValues } from "../../util/consensusThreshold"; + +const TopicPage = ({ conversation, report_id, topic_key, math, comments, ptptCount, formatTid, voteColors, onBack }) => { + const [loading, setLoading] = useState(true); + const [topicData, setTopicData] = useState(null); + const [topicStats, setTopicStats] = useState(null); + const [collectiveStatement, setCollectiveStatement] = useState(null); + const [loadingStatement, setLoadingStatement] = useState(false); + const [statementGenerated, setStatementGenerated] = useState(false); + const [statementMetadata, setStatementMetadata] = useState(null); + const [topicComments, setTopicComments] = useState([]); + const [sortedComments, setSortedComments] = useState([]); + const [topicNarrative, setTopicNarrative] = useState(null); + const [narrativeLoading, setNarrativeLoading] = useState(false); + + useEffect(() => { + const fetchTopicData = async () => { + try { + setLoading(true); + // Reset collective statement when topic changes + setCollectiveStatement(null); + setStatementGenerated(false); + + // Fetch topic data from Delphi endpoint + const topicsResponse = await net.polisGet("/api/v3/delphi", { + report_id: report_id, + }); + + // Fetch topic statistics + const statsResponse = await net.polisGet("/api/v3/topicStats", { + report_id: report_id, + }); + + if (topicsResponse.status === "success" && statsResponse.status === "success") { + // Find the topic data + const latestRunKey = Object.keys(topicsResponse.runs).sort().reverse()[0]; + const latestRun = topicsResponse.runs[latestRunKey]; + let foundTopic = null; + + Object.entries(latestRun.topics_by_layer || {}).forEach(([layerId, topics]) => { + Object.entries(topics).forEach(([clusterId, topic]) => { + if (topic.topic_key === topic_key) { + foundTopic = { ...topic, layerId }; + } + }); + }); + + if (foundTopic && statsResponse.stats[topic_key]) { + setTopicData(foundTopic); + + // Calculate metrics client-side + const stats = statsResponse.stats[topic_key]; + const commentTids = stats.comment_tids || []; + const topicCommentsData = comments.filter(c => commentTids.includes(c.tid)); + + // Calculate metrics + let totalVotes = 0; + let totalAgree = 0; + let totalDisagree = 0; + let totalPass = 0; + + topicCommentsData.forEach(comment => { + const agreeCount = comment.agree_count || 0; + const disagreeCount = comment.disagree_count || 0; + const passCount = comment.pass_count || 0; + + totalVotes += agreeCount + disagreeCount + passCount; + totalAgree += agreeCount; + totalDisagree += disagreeCount; + totalPass += passCount; + }); + + const enrichedStats = { + ...stats, + comment_count: commentTids.length, + total_votes: totalVotes, + agree_votes: totalAgree, + disagree_votes: totalDisagree, + pass_votes: totalPass, + vote_density: commentTids.length > 0 ? totalVotes / commentTids.length : 0 + }; + + setTopicStats(enrichedStats); + setTopicComments(topicCommentsData); + + // Sort by group consensus + const consensusData = math?.["group-consensus-normalized"] || math?.["group-aware-consensus"]; + if (consensusData) { + const sorted = topicCommentsData + .map(comment => ({ + ...comment, + groupConsensus: consensusData[comment.tid] || 0 + })) + .sort((a, b) => b.groupConsensus - a.groupConsensus); + setSortedComments(sorted); + } + } + } + + setLoading(false); + } catch (err) { + console.error("Error fetching topic data:", err); + setLoading(false); + } + }; + + if (report_id && topic_key && comments && math) { + fetchTopicData(); + } + }, [report_id, topic_key, comments, math]); + + const checkExistingStatements = async () => { + try { + const response = await net.polisGet("/api/v3/collectiveStatement", { + report_id: report_id + }); + + if (response.status === "success" && response.statements && response.statements.length > 0) { + // Find statements for this topic + const topicStatements = response.statements.filter(stmt => + stmt.topic_key === topic_key + ); + + if (topicStatements.length > 0) { + // Use the most recent statement + const mostRecent = topicStatements.sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + )[0]; + + return mostRecent; + } + } + return null; + } catch (err) { + console.log("Error checking existing statements:", err); + return null; + } + }; + + const generateCollectiveStatement = async () => { + if (loadingStatement || statementGenerated) return; + + try { + setLoadingStatement(true); + + // First check if we have an existing statement + const existingStatement = await checkExistingStatements(); + + if (existingStatement) { + console.log("Using existing collective statement from", existingStatement.created_at); + setCollectiveStatement(existingStatement.statement_data); + setStatementGenerated(true); + setStatementMetadata({ + created_at: existingStatement.created_at, + model: existingStatement.model + }); + setLoadingStatement(false); + return; + } + + // Check if this topic can generate a collective statement + const statementCheck = canGenerateCollectiveStatement(topicStats.comment_tids, math); + + if (!statementCheck.canGenerate) { + console.log(`Skipping collective statement generation: ${statementCheck.message}`); + setLoadingStatement(false); + setCollectiveStatement({ + insufficient: true, + message: statementCheck.message + }); + return; + } + + // Get only the qualifying comment IDs + const qualifyingTids = statementCheck.details.map(comment => comment.tid); + + // Get the consensus values only for qualifying comments + const relevantConsensus = {}; + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + qualifyingTids.forEach(tid => { + if (consensusData[tid] !== undefined) { + relevantConsensus[tid] = consensusData[tid]; + } + }); + + console.log("Generating collective statement with:", { + report_id: report_id, + topic_key: topic_key, + topic_name: topicData?.topic_name, + qualifyingCount: qualifyingTids.length, + qualifyingTids: qualifyingTids + }); + + const response = await net.polisPost("/api/v3/collectiveStatement", { + report_id: report_id, + topic_key: topic_key, + topic_name: topicData?.topic_name || "", + group_consensus: relevantConsensus, + qualifying_tids: qualifyingTids // Send the list of qualifying comment IDs + }); + + console.log("Collective statement response:", response); + + if (response.status === "success" && response.statementData) { + setCollectiveStatement(response.statementData); + setStatementGenerated(true); + setStatementMetadata({ + created_at: response.created_at, + model: response.model + }); + } else if (response.statement) { + setCollectiveStatement(response.statement); + setStatementGenerated(true); + } else { + console.error("Unexpected response format:", response); + setCollectiveStatement({ + error: true, + message: "Received unexpected response format" + }); + } + } catch (err) { + console.error("Error generating collective statement:", err); + // Show user-friendly error message + setCollectiveStatement({ + error: true, + message: "Unable to generate candidate collective statement. Please try again later." + }); + } finally { + setLoadingStatement(false); + } + }; + + // Fetch narrative report if it exists + const fetchNarrativeReport = async () => { + if (!topic_key || narrativeLoading) return; + + try { + setNarrativeLoading(true); + const response = await net.polisGet("/api/v3/delphi/reports", { + report_id: report_id, + section: topic_key + }); + + if (response && response.status === "success" && response.reports) { + const sectionData = response.reports[topic_key]; + if (sectionData && sectionData.report_data) { + const reportData = typeof sectionData.report_data === 'string' + ? JSON.parse(sectionData.report_data) + : sectionData.report_data; + setTopicNarrative(reportData); + } + } + } catch (err) { + console.error("Error fetching narrative report:", err); + } finally { + setNarrativeLoading(false); + } + }; + + useEffect(() => { + if (topic_key && report_id) { + fetchNarrativeReport(); + } + }, [topic_key, report_id]); + + // Auto-generate collective statement when topic data is loaded + useEffect(() => { + if (topicStats && topicData && math && !collectiveStatement && !loadingStatement) { + generateCollectiveStatement(); + } + }, [topicStats, topicData, math]); + + if (loading) { + return ( +
+ +
+

Loading topic data...

+
+
+ ); + } + + if (!topicData || !topicStats) { + return ( +
+ +
+

Topic not found

+ +
+
+ ); + } + + return ( +
+ + +
+ + +

{topicData.topic_name}

+ + {/* Key Statistics */} +
+
+
+

Comments

+

{topicStats.comment_count}

+
+
+

Total Votes

+

{topicStats.total_votes.toLocaleString()}

+
+
+

Total Agree Votes

+

+ {topicStats.total_votes > 0 ? Math.round((topicStats.agree_votes / topicStats.total_votes) * 100) : 0}% +

+

+ {topicStats.agree_votes.toLocaleString()} votes +

+
+
+

Total Disagree Votes

+

+ {topicStats.total_votes > 0 ? Math.round((topicStats.disagree_votes / topicStats.total_votes) * 100) : 0}% +

+

+ {topicStats.disagree_votes.toLocaleString()} votes +

+
+
+
+ + {/* Comment Divisiveness Distribution - moved to top */} +
+

Comment Divisiveness Distribution

+

+ Each circle represents a comment. Position shows how similarly groups voted. Hover to see the group vote breakdown. +

+ +
+ + {/* Collective Statement - moved to top and auto-generated */} +
+

Candidate Collective Statement

+

+ Based on voting trends thus far + {statementMetadata && ( + + • Generated {new Date(statementMetadata.created_at).toLocaleDateString()} at {new Date(statementMetadata.created_at).toLocaleTimeString()} + {statementMetadata.model && ` (${statementMetadata.model.includes('claude') ? 'Claude Opus 4' : statementMetadata.model})`} + + )} +

+ + {loadingStatement && ( +
+

Generating candidate collective statement...

+
+ )} + + {collectiveStatement && collectiveStatement.insufficient && ( +
+

+ Insufficient consensus: {collectiveStatement.message} +

+

+ Candidate collective statements require topics with strong cross-group agreement to ensure meaningful representation. +

+
+ )} + + {collectiveStatement && !collectiveStatement.error && !collectiveStatement.insufficient && (() => { + // Extract all citations from the collective statement + const citationIds = []; + const paragraphs = collectiveStatement.paragraphs || collectiveStatement.content?.paragraphs || []; + paragraphs.forEach(paragraph => { + paragraph.sentences?.forEach(sentence => { + sentence.clauses?.forEach(clause => { + if (clause.citations && Array.isArray(clause.citations)) { + citationIds.push(...clause.citations.filter(c => typeof c === 'number')); + } + }); + }); + }); + const uniqueCitations = [...new Set(citationIds)]; + + return ( +
992 ? "row" : "column", + gap: "20px", + marginTop: 20 + }}> + {/* Collective statement text content */} +
992 ? "520px" : "auto", + minWidth: window.innerWidth > 992 ? "400px" : "auto", + width: window.innerWidth > 992 ? "auto" : "100%" + }}> +
+ {paragraphs.map((paragraph, idx) => ( +
+ {paragraph.title &&

{paragraph.title}

} + {paragraph.sentences && paragraph.sentences.map((sentence, sIdx) => ( +

+ {sentence.clauses && sentence.clauses.map((clause, cIdx) => ( + + {clause.text} + {clause.citations && clause.citations.length > 0 && ( + + {clause.citations.join(', ')} + + )} + {cIdx < sentence.clauses.length - 1 && ' '} + + ))} +

+ ))} +
+ ))} +
+
+ + {/* Comments referenced in collective statement */} + {uniqueCitations.length > 0 && ( +
992 ? "0%" : "auto", + minWidth: window.innerWidth > 992 ? "400px" : "auto", + width: window.innerWidth > 992 ? "auto" : "100%", + overflowX: "auto", + marginTop: window.innerWidth > 992 ? 0 : 30 + }}> +

Comments Referenced

+
+ +
+
+ )} +
+ ); + })()} + + {collectiveStatement && collectiveStatement.error && ( +
+

{collectiveStatement.message}

+
+ )} + + {!collectiveStatement && !loadingStatement && ( +
+

+ Unable to generate candidate collective statement. Please try refreshing the page. +

+
+ )} +
+
+ + {/* Narrative Report Section */} + {topicNarrative && topicNarrative.paragraphs && ( +
+

Narrative Summary

+ + {/* Extract all citations from the narrative */} + {(() => { + const citationIds = []; + topicNarrative.paragraphs.forEach(paragraph => { + paragraph.sentences?.forEach(sentence => { + sentence.clauses?.forEach(clause => { + if (clause.citations && Array.isArray(clause.citations)) { + citationIds.push(...clause.citations.filter(c => typeof c === 'number')); + } + }); + }); + }); + const uniqueCitations = [...new Set(citationIds)]; + + return ( +
992 ? "row" : "column", + gap: "20px", + marginTop: 30, + marginBottom: 40 + }}> + {/* Narrative text content */} +
992 ? "520px" : "auto", + minWidth: window.innerWidth > 992 ? "400px" : "auto", + width: window.innerWidth > 992 ? "auto" : "100%" + }}> +
+ {topicNarrative.paragraphs.map((paragraph, idx) => ( +
+ {paragraph.title &&

{paragraph.title}

} + {paragraph.sentences && paragraph.sentences.map((sentence, sIdx) => ( +

+ {sentence.clauses && sentence.clauses.map((clause, cIdx) => ( + + {clause.text} + {clause.citations && clause.citations.length > 0 && ( + + {clause.citations.join(', ')} + + )} + {cIdx < sentence.clauses.length - 1 && ' '} + + ))} +

+ ))} +
+ ))} +
+
+ + {/* Comments referenced in narrative */} + {uniqueCitations.length > 0 && ( +
992 ? "0%" : "auto", + minWidth: window.innerWidth > 992 ? "400px" : "auto", + width: window.innerWidth > 992 ? "auto" : "100%", + overflowX: "auto", + marginTop: window.innerWidth > 992 ? 0 : 30 + }}> +

Comments Referenced

+
+ +
+
+ )} +
+ ); + })()} +
+ )} + + + {/* Consensus vs Engagement Section */} +
+

Consensus vs Engagement

+

+ This visualization shows how group consensus relates to voting engagement for each comment. + Comments with high consensus and high engagement represent areas of strong agreement or disagreement across the conversation. +

+
+ {topicComments.length > 0 && math && math["group-aware-consensus"] && topicStats ? ( + + ) : ( +
+ No data available for visualization +
+ )} +
+
+ + + {/* All Comments Section */} +
+

All Comments ({topicComments.length})

+

+ All comments in this topic. +

+
+ +
+
+ +
+
+ ); +}; + +export default TopicPage; \ No newline at end of file diff --git a/client-report/src/components/topicScatterplot/README.md b/client-report/src/components/topicScatterplot/README.md new file mode 100644 index 0000000000..040c6efa1d --- /dev/null +++ b/client-report/src/components/topicScatterplot/README.md @@ -0,0 +1,177 @@ +# TopicScatterplot Component + +A standalone, reusable scatterplot visualization component for displaying topic statistics using Plotly.js. + +## Features + +- **Interactive scatter plot** with hover tooltips showing detailed information +- **Bubble sizing** based on comment count +- **Transparent bubbles** to see overlapping data points +- **Responsive design** that adapts to container width +- **Customizable** appearance and behavior +- **Event handlers** for click and hover interactions + +## Prerequisites + +This component requires Plotly.js to be loaded in your application. Add this script tag to your HTML: + +```html + +``` + +## Installation + +Copy the `TopicScatterplot.jsx` file to your project's components directory. + +## Usage + +### Basic Example + +```jsx +import TopicScatterplot from './components/topicScatterplot/TopicScatterplot'; + +const topicData = [ + { + topic_name: "Environmental Protection", + consensus: 0.85, + avg_votes_per_comment: 45.2, + comment_count: 23 + }, + { + topic_name: "Economic Growth", + consensus: 0.42, + avg_votes_per_comment: 38.7, + comment_count: 45 + } + // ... more topics +]; + +function MyComponent() { + return ( + + ); +} +``` + +### Advanced Example with Configuration + +```jsx + { + console.log('Selected topic:', topic); + // Navigate to topic details, open modal, etc. + }} + onHover={(topic) => { + console.log('Hovering over:', topic.topic_name); + }} +/> +``` + +## Props + +### `data` (required) +Array of topic objects. Each object must contain: + +| Property | Type | Description | +|----------|------|-------------| +| `topic_name` | string | Name of the topic (shown in tooltip) | +| `consensus` | number | Topic consensus value (0-1, shown on y-axis) | +| `avg_votes_per_comment` | number | Average votes per comment (shown on x-axis) | +| `comment_count` | number | Number of comments (determines bubble size) | +| `layer` | string/number | Optional layer identifier | +| `additional_info` | object | Optional extra data for tooltips | + +### `config` (optional) +Configuration object with the following options: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `title` | string | - | Chart title | +| `xAxisLabel` | string | "Average Votes per Comment" | X-axis label | +| `yAxisLabel` | string | "Topic Consensus" | Y-axis label | +| `width` | number | responsive | Chart width | +| `height` | number | 500 | Chart height in pixels | +| `bubbleOpacity` | number | 0.6 | Opacity of bubbles (0-1) | +| `minBubbleSize` | number | 10 | Minimum bubble size in pixels | +| `maxBubbleSize` | number | 60 | Maximum bubble size in pixels | + +### `onClick` (optional) +Callback function triggered when a bubble is clicked. Receives the full data object for the clicked topic. + +### `onHover` (optional) +Callback function triggered when hovering over a bubble. Receives the full data object for the hovered topic. + +## Data Format Details + +### Consensus Value +- Should be between 0 and 1 +- Will be displayed as a percentage on the y-axis +- 1 = 100% consensus (everyone agrees) +- 0 = 0% consensus (highly divisive) + +### Average Votes per Comment +- Represents engagement level +- Higher values indicate more participant interaction + +### Comment Count +- Used to scale bubble sizes +- Larger topics (more comments) will have bigger bubbles + +## Styling + +The component uses: +- Google blue color (#4285F4) for bubbles +- Transparent bubbles (60% opacity by default) +- Clean, minimal design with subtle gridlines +- No toolbar for cleaner appearance + +## Integration Notes + +1. **Container**: The component will fill 100% of its parent container's width +2. **Responsiveness**: The chart automatically resizes when the window changes +3. **Performance**: Suitable for up to ~200 topics without performance issues +4. **Accessibility**: Includes proper hover text for screen readers + +## Example Data Preparation + +```javascript +// Transform your topic stats data for the scatterplot +const scatterplotData = Object.entries(topicsData).flatMap(([layerId, topics]) => + Object.entries(topics).map(([clusterId, topic]) => ({ + topic_name: topic.topic_name, + consensus: 1 - (statsData[topic.topic_key]?.divisiveness || 0), + avg_votes_per_comment: statsData[topic.topic_key]?.vote_density || 0, + comment_count: statsData[topic.topic_key]?.comment_count || 0, + layer: layerId, + additional_info: { + cluster_id: clusterId, + total_votes: statsData[topic.topic_key]?.total_votes || 0 + } + })) +); +``` + +## Troubleshooting + +### Chart not appearing +- Ensure Plotly.js is loaded before the component renders +- Check browser console for errors +- Verify data array is not empty + +### Tooltips not showing +- Make sure your data objects have all required properties +- Check that consensus values are between 0 and 1 + +### Performance issues +- Consider reducing data points if you have more than 200 topics +- Disable animations if needed \ No newline at end of file diff --git a/client-report/src/components/topicScatterplot/TopicScatterplot.jsx b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx new file mode 100644 index 0000000000..672d19c078 --- /dev/null +++ b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx @@ -0,0 +1,311 @@ +import React, { useEffect, useRef } from 'react'; + +/** + * TopicScatterplot Component + * + * A standalone, reusable scatterplot visualization for topic statistics using Plotly.js + * + * @component + * @param {Object} props - Component props + * @param {Array} props.data - Array of topic data objects + * @param {string} props.data[].topic_name - Name of the topic (displayed in tooltip) + * @param {number} props.data[].consensus - Topic consensus value (0-1, displayed on y-axis) + * @param {number} props.data[].avg_votes_per_comment - Average votes per comment (displayed on x-axis) + * @param {number} props.data[].comment_count - Number of comments (determines bubble size) + * @param {string} [props.data[].layer] - Optional layer identifier for grouping + * @param {Object} [props.data[].additional_info] - Optional additional data for tooltips + * + * @param {Object} [props.config] - Configuration options + * @param {string} [props.config.title] - Chart title + * @param {string} [props.config.xAxisLabel] - X-axis label (default: "Average Votes per Comment") + * @param {string} [props.config.yAxisLabel] - Y-axis label (default: "Topic Consensus") + * @param {number} [props.config.width] - Chart width (default: responsive) + * @param {number} [props.config.height] - Chart height (default: 500) + * @param {number} [props.config.bubbleOpacity] - Bubble opacity (default: 0.6) + * @param {number} [props.config.minBubbleSize] - Minimum bubble size in pixels (default: 10) + * @param {number} [props.config.maxBubbleSize] - Maximum bubble size in pixels (default: 60) + * + * @param {Function} [props.onHover] - Callback when hovering over a point + * @param {Function} [props.onClick] - Callback when clicking on a point + * + * @example + * const topicData = [ + * { + * topic_name: "Environmental Protection", + * consensus: 0.85, + * avg_votes_per_comment: 45.2, + * comment_count: 23, + * layer: "Layer 0" + * }, + * // ... more topics + * ]; + * + * console.log('Clicked:', point)} + * /> + */ +const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { + const plotRef = useRef(null); + + // Default configuration + const defaultConfig = { + xAxisLabel: "Average Votes per Comment", + yAxisLabel: "Topic Consensus", + height: 500, + bubbleOpacity: 0.6, + minBubbleSize: 10, + maxBubbleSize: 60 + }; + + const mergedConfig = { ...defaultConfig, ...config }; + + useEffect(() => { + if (!window.Plotly) { + console.error("Plotly.js is not loaded. Please include Plotly.js in your HTML."); + return; + } + + if (!data || data.length === 0) { + console.warn("No data provided to TopicScatterplot"); + return; + } + + // Calculate bubble sizes based on comment count + const commentCounts = data.map(d => d.comment_count || 0); + const minComments = Math.min(...commentCounts); + const maxComments = Math.max(...commentCounts); + + // Scale function for bubble sizes + const scaleSize = (count) => { + if (maxComments === minComments) return mergedConfig.minBubbleSize; + const normalized = (count - minComments) / (maxComments - minComments); + return mergedConfig.minBubbleSize + + (normalized * (mergedConfig.maxBubbleSize - mergedConfig.minBubbleSize)); + }; + + // Apply optional transformations for positioning + const xOriginal = data.map(d => d.avg_votes_per_comment || 0); + const xValues = data.map(d => { + const val = d.avg_votes_per_comment || 0; + if (mergedConfig.xTransform === 'pow2') return Math.pow(val, 2); + if (mergedConfig.xTransform === 'pow3') return Math.pow(val, 3); + if (mergedConfig.xTransform === 'sqrt') return Math.sqrt(val); + if (mergedConfig.xTransform === 'pow1.5') return Math.pow(val, 1.5); + return val; + }); + + const yValues = data.map(d => { + const val = d.consensus || 0; + if (mergedConfig.yTransform === 'pow2') return Math.pow(val, 2); + if (mergedConfig.yTransform === 'pow3') return Math.pow(val, 3); + if (mergedConfig.yTransform === 'sqrt') return Math.sqrt(val); + if (mergedConfig.yTransform === 'pow1.5') return Math.pow(val, 1.5); + return val; + }); + + // Calculate custom tick values for transformed axes + let xTickVals, xTickText; + let yTickVals, yTickText; + + if (mergedConfig.xTransform === 'sqrt') { + // Dynamically calculate nice tick values based on data range + const maxX = Math.max(...xOriginal); + const minX = Math.min(...xOriginal.filter(x => x > 0)) || 0; + + // Calculate order of magnitude + const magnitude = Math.pow(10, Math.floor(Math.log10(maxX))); + const normalizedMax = maxX / magnitude; + + // Determine step size based on range + let step; + if (normalizedMax <= 1) step = magnitude * 0.1; + else if (normalizedMax <= 2) step = magnitude * 0.2; + else if (normalizedMax <= 5) step = magnitude * 0.5; + else step = magnitude; + + // Generate tick values + const tickValues = []; + const tickLabels = []; + + for (let val = 0; val <= maxX * 1.1; val += step) { + if (val === 0 || val >= minX * 0.9) { + tickValues.push(Math.sqrt(val)); + tickLabels.push(Math.round(val).toString()); + } + } + + // Ensure we have the max value + if (tickValues[tickValues.length - 1] < Math.sqrt(maxX)) { + tickValues.push(Math.sqrt(maxX)); + tickLabels.push(Math.round(maxX).toString()); + } + + xTickVals = tickValues; + xTickText = tickLabels; + } + + // Calculate custom tick values for y-axis with pow2 transform + if (mergedConfig.yTransform === 'pow2') { + const yOriginal = data.map(d => d.consensus || 0); + const maxY = Math.max(...yOriginal); + const minY = Math.min(...yOriginal.filter(y => y > 0)) || 0; + + // Generate nice tick values for consensus (0 to 1 range typically) + const tickValues = []; + const tickLabels = []; + + // For consensus values, use fixed intervals + const steps = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; + + for (const val of steps) { + if (val >= minY * 0.9 && val <= maxY * 1.1) { + tickValues.push(Math.pow(val, 2)); + tickLabels.push(val.toFixed(1)); + } + } + + yTickVals = tickValues; + yTickText = tickLabels; + } + + // Store original values for custom hover text + const originalValues = data.map(d => ({ + x: d.avg_votes_per_comment || 0, + y: d.consensus || 0 + })); + + // Prepare Plotly data + const plotData = [{ + x: xValues, + y: yValues, + mode: 'markers', + type: 'scatter', + marker: { + size: data.map(d => scaleSize(d.comment_count || 0)), + color: mergedConfig.useColorScale ? yValues : data.map(d => { + if (mergedConfig.colorFunction) { + return mergedConfig.colorFunction(d); + } + return 'rgba(66, 133, 244, 1)'; // Default Google blue + }), + colorscale: mergedConfig.colorScale || 'RdYlGn', + reversescale: mergedConfig.reverseScale || false, + showscale: mergedConfig.showColorScale || false, + opacity: mergedConfig.bubbleOpacity, + line: { + color: 'rgba(0, 0, 0, 0.2)', + width: 1 + } + }, + text: data.map(d => { + // Build hover text + let hoverText = `${d.topic_name}
`; + hoverText += `Consensus: ${d.consensus.toFixed(3)}
`; + hoverText += `Avg Votes/Comment: ${d.avg_votes_per_comment.toFixed(1)}
`; + hoverText += `Comments: ${d.comment_count}`; + + if (d.layer !== undefined) { + hoverText += `
Layer: ${d.layer}`; + } + + // Add any additional info + if (d.additional_info) { + Object.entries(d.additional_info).forEach(([key, value]) => { + hoverText += `
${key}: ${value}`; + }); + } + + return hoverText; + }), + hovertemplate: '%{text}', + customdata: data // Store full data for click events + }]; + + // Layout configuration + const layout = { + title: mergedConfig.title || '', + xaxis: { + title: mergedConfig.xAxisLabel, + zeroline: false, + gridcolor: 'rgba(0,0,0,0.1)', + type: mergedConfig.xAxisType || 'linear', + tickmode: xTickVals ? 'array' : 'auto', + tickvals: xTickVals, + ticktext: xTickText + }, + yaxis: { + title: mergedConfig.yAxisLabel, + zeroline: false, + gridcolor: 'rgba(0,0,0,0.1)', + type: mergedConfig.yAxisType || 'linear', + tickmode: yTickVals ? 'array' : 'auto', + tickvals: yTickVals, + ticktext: yTickText, + tickformat: yTickVals ? undefined : (mergedConfig.yAxisTickFormat || ''), + exponentformat: mergedConfig.yAxisType === 'pow' ? 'e' : undefined + }, + hovermode: 'closest', + showlegend: false, + height: mergedConfig.height, + plot_bgcolor: 'rgba(0,0,0,0)', + paper_bgcolor: 'rgba(0,0,0,0)', + margin: { + l: 80, + r: 40, + t: mergedConfig.title ? 60 : 40, + b: 60 + } + }; + + // Plotly configuration + const plotlyConfig = { + responsive: true, + displayModeBar: false // Hide the toolbar for cleaner look + }; + + // Create the plot + window.Plotly.newPlot(plotRef.current, plotData, layout, plotlyConfig); + + // Add event handlers + if (onClick) { + plotRef.current.on('plotly_click', (eventData) => { + if (eventData.points && eventData.points.length > 0) { + const point = eventData.points[0]; + onClick(point.customdata); + } + }); + } + + if (onHover) { + plotRef.current.on('plotly_hover', (eventData) => { + if (eventData.points && eventData.points.length > 0) { + const point = eventData.points[0]; + onHover(point.customdata); + } + }); + } + + // Cleanup + return () => { + if (plotRef.current) { + window.Plotly.purge(plotRef.current); + } + }; + }, [data, mergedConfig, onClick, onHover]); + + return ( +
+ ); +}; + +export default TopicScatterplot; \ No newline at end of file diff --git a/client-report/src/components/topicStats/AllCommentsModal.jsx b/client-report/src/components/topicStats/AllCommentsModal.jsx new file mode 100644 index 0000000000..9d5df6ee5e --- /dev/null +++ b/client-report/src/components/topicStats/AllCommentsModal.jsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import AllCommentsScatterplot from './visualizations/AllCommentsScatterplot.jsx'; +import CommentList from '../lists/commentList.jsx'; + +const AllCommentsModal = ({ + isOpen, + onClose, + topicName, + topicKey, + topicStats, + comments, + math, + conversation, + ptptCount, + formatTid, + voteColors +}) => { + const [topicComments, setTopicComments] = useState([]); + const [sortedByConsensus, setSortedByConsensus] = useState([]); + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + useEffect(() => { + if (isOpen && topicStats && comments && math) { + // Get comments for this topic + const commentTids = topicStats.comment_tids || []; + const topicCommentsData = comments.filter(c => commentTids.includes(c.tid)); + + // Sort by group-aware consensus + const commentsWithConsensus = topicCommentsData.map(comment => ({ + ...comment, + groupConsensus: math["group-aware-consensus"]?.[comment.tid] || 0 + })); + + const sorted = [...commentsWithConsensus].sort((a, b) => + b.groupConsensus - a.groupConsensus + ); + + setTopicComments(topicCommentsData); + setSortedByConsensus(sorted); + } + }, [isOpen, topicStats, comments, math]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{topicName} - Comments Analysis

+ +
+ + {/* Content */} +
+ {/* Scatterplot */} +
+

Comments: Group-Aware Consensus vs. Votes

+

+ Each dot represents a comment. Hover for details. +

+ {topicComments.length > 0 ? ( + + ) : ( +
+ No comments to display +
+ )} +
+
+
+
+ ); +}; + +export default AllCommentsModal; \ No newline at end of file diff --git a/client-report/src/components/topicStats/BeeswarmModal.jsx b/client-report/src/components/topicStats/BeeswarmModal.jsx new file mode 100644 index 0000000000..21053dcc74 --- /dev/null +++ b/client-report/src/components/topicStats/BeeswarmModal.jsx @@ -0,0 +1,112 @@ +import React, { useEffect } from 'react'; +import TopicBeeswarm from './visualizations/TopicBeeswarm.jsx'; + +const BeeswarmModal = ({ + isOpen, + onClose, + topicName, + topicKey, + topicStats, + comments, + math, + conversation, + ptptCount, + formatTid, + voteColors +}) => { + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{topicName}

+ +
+ + {/* Content */} +
+ {/* Beeswarm Visualization */} +
+

Group-Aware Consensus Distribution

+

+ Each circle represents a comment. Position shows how similarly groups voted. + Hover to see the group vote breakdown. +

+ +
+
+
+
+ ); +}; + +export default BeeswarmModal; \ No newline at end of file diff --git a/client-report/src/components/topicStats/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx new file mode 100644 index 0000000000..1a752b7293 --- /dev/null +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -0,0 +1,434 @@ +import React, { useState, useEffect } from "react"; +import net from "../../util/net"; +import CommentList from "../lists/commentList.jsx"; +import { canGenerateCollectiveStatement, getTopicConsensusValues } from "../../util/consensusThreshold"; + +const CollectiveStatementModal = ({ + isOpen, + onClose, + topicName, + topicKey, + reportId, + conversation, + math, + comments, + ptptCount, + formatTid, + voteColors, +}) => { + const [loading, setLoading] = useState(false); + const [statementData, setStatementData] = useState(null); + const [commentsData, setCommentsData] = useState(null); + const [error, setError] = useState(null); + const [statementMetadata, setStatementMetadata] = useState(null); + + useEffect(() => { + if (isOpen && topicKey && reportId) { + generateStatement(); + } + }, [isOpen, topicKey, reportId]); + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + const checkExistingStatements = async () => { + try { + const response = await net.polisGet("/api/v3/collectiveStatement", { + report_id: reportId + }); + + if (response.status === "success" && response.statements && response.statements.length > 0) { + // Find statements for this topic + const topicStatements = response.statements.filter(stmt => + stmt.topic_key === topicKey + ); + + if (topicStatements.length > 0) { + // Use the most recent statement + const mostRecent = topicStatements.sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + )[0]; + + return mostRecent; + } + } + return null; + } catch (err) { + console.log("Error checking existing statements:", err); + return null; + } + }; + + const generateStatement = async () => { + try { + setLoading(true); + setError(null); + + // First check if we have an existing statement + const existingStatement = await checkExistingStatements(); + + if (existingStatement) { + console.log("Using existing collective statement from", existingStatement.created_at); + setStatementData(existingStatement.statement_data); + setCommentsData(existingStatement.comments_data); + setStatementMetadata({ + created_at: existingStatement.created_at, + model: existingStatement.model + }); + setLoading(false); + return; + } + + // Only send group-aware consensus for comments in this topic + const topicStats = await net.polisGet("/api/v3/topicStats", { + report_id: reportId, + }); + + let topicCommentIds = []; + if (topicStats.status === "success" && topicStats.stats[topicKey]) { + topicCommentIds = topicStats.stats[topicKey].comment_tids || []; + } + + // Check if this topic can generate a collective statement + const statementCheck = canGenerateCollectiveStatement(topicCommentIds, math); + + if (!statementCheck.canGenerate) { + setError(statementCheck.message); + setLoading(false); + return; + } + + // Get only the qualifying comment IDs + const qualifyingTids = statementCheck.details.map(comment => comment.tid); + + // Get the consensus values only for qualifying comments + const relevantConsensus = {}; + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + qualifyingTids.forEach(tid => { + if (consensusData[tid] !== undefined) { + relevantConsensus[tid] = consensusData[tid]; + } + }); + + const response = await net.polisPost("/api/v3/collectiveStatement", { + report_id: reportId, + topic_key: topicKey, + topic_name: topicName, + group_consensus: relevantConsensus, + qualifying_tids: qualifyingTids // Send the list of qualifying comment IDs + }); + + if (response.status === "success") { + console.log("Collective statement response:", response); + setStatementData(response.statementData); + setCommentsData(response.commentsData); + setStatementMetadata({ + created_at: response.created_at, + model: response.model + }); + } else { + setError(response.message || "Failed to generate statement"); + } + } catch (err) { + console.error("Error generating collective statement:", err); + setError(err.message || "Failed to generate collective statement"); + } finally { + setLoading(false); + } + }; + + // Extract citation IDs from the statement data + const extractCitations = (content) => { + const citations = []; + if (content && content.paragraphs) { + content.paragraphs.forEach((paragraph) => { + if (paragraph.sentences) { + paragraph.sentences.forEach((sentence) => { + if (sentence.clauses) { + sentence.clauses.forEach((clause) => { + if (clause.citations && Array.isArray(clause.citations)) { + citations.push(...clause.citations.filter((c) => typeof c === "number")); + } + }); + } + }); + } + }); + } + return [...new Set(citations)]; // Remove duplicates + }; + + // Clear state when modal closes + useEffect(() => { + if (!isOpen) { + setStatementData(null); + setCommentsData(null); + setError(null); + setStatementMetadata(null); + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+ {/* Left sidebar with title */} +
+
+

{topicName}

+

Candidate Collective Statement

+

Based on voting trends thus far

+
+
+ + {/* Main content area */} +
+ + {loading && ( +
+

Generating candidate collective statement...

+

+ This may take a moment as we analyze voting patterns and comments. +

+
+ )} + + {error && ( +
+
+

Error: {error}

+
+
+ )} + + {!loading && !error && statementData && ( +
+ {/* Left side: Collective Statement */} +
+
+
+

Candidate Collective Statement

+ {statementMetadata && ( +

+ Generated {new Date(statementMetadata.created_at).toLocaleDateString()} at {new Date(statementMetadata.created_at).toLocaleTimeString()} + {statementMetadata.model && ` (${statementMetadata.model.includes('claude') ? 'Claude Opus 4' : statementMetadata.model})`} +

+ )} +
+
+ {statementData && + statementData.paragraphs && + statementData.paragraphs.map((paragraph, idx) => ( +
+

+ {paragraph.title} +

+ {paragraph.sentences && + paragraph.sentences.map((sentence, sIdx) => ( +

+ {sentence.clauses && + sentence.clauses.map((clause, cIdx) => ( + + {clause.text} + {clause.citations && clause.citations.length > 0 && ( + + [{clause.citations.join(", ")}] + + )} + {cIdx < sentence.clauses.length - 1 && " "} + + ))} +

+ ))} +
+ ))} +
+
+ + {/* Footer note in left column */} +
+

+ Note: This candidate collective statement was generated using AI based on the voting patterns and comments from all participants. It represents + areas of shared understanding and consensus on this topic. +

+ +
+
+ + {/* Right side: Cited Comments */} +
+

+ Cited Comments +

+ {comments && comments.length > 0 && statementData ? ( +
+ +
+ ) : ( +
+ No comments referenced +
+ )} +
+
+ )} +
+
+
+
+ ); +}; + +export default CollectiveStatementModal; diff --git a/client-report/src/components/topicStats/LayerDistributionModal.jsx b/client-report/src/components/topicStats/LayerDistributionModal.jsx new file mode 100644 index 0000000000..3373443f94 --- /dev/null +++ b/client-report/src/components/topicStats/LayerDistributionModal.jsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; + +const LayerDistributionModal = ({ + isOpen, + onClose, + layerName, + layerId, + topics, + statsData, + math, + comments +}) => { + const [plotData, setPlotData] = useState(null); + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + useEffect(() => { + if (!isOpen || !topics || !statsData || !math || !comments) return; + + // Use normalized consensus if available, fall back to raw + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + if (!consensusData) return; + + // Prepare data for boxplot + const traces = []; + + Object.entries(topics).forEach(([clusterId, topic]) => { + const stats = statsData[topic.topic_key]; + if (!stats || !stats.comment_tids) return; + + // Get group consensus values for comments in this topic + const consensusValues = []; + const commentsData = []; + + stats.comment_tids.forEach(tid => { + const consensus = consensusData[tid]; + if (consensus !== undefined) { + // Find the comment to check vote count + const comment = comments?.find(c => c.tid === tid); + const totalVotes = comment ? + (comment.agree_count || 0) + (comment.disagree_count || 0) + (comment.pass_count || 0) : 0; + + // Only include comments with at least 5 votes for meaningful distribution + // Comments with very few votes default to 0.333 consensus + if (totalVotes >= 5) { + consensusValues.push(consensus); + commentsData.push({ tid, consensus, votes: totalVotes }); + } + } + }); + + if (consensusValues.length > 0) { + traces.push({ + y: consensusValues, + type: 'box', + name: topic.topic_name, + boxpoints: 'outliers', + marker: { + color: 'rgba(100, 100, 100, 0.6)', + outliercolor: 'rgba(150, 150, 150, 0.8)' + }, + line: { + color: 'rgba(80, 80, 80, 1)' + } + }); + } + }); + + setPlotData(traces); + }, [isOpen, topics, statsData, math, comments]); + + useEffect(() => { + if (plotData && plotData.length > 0) { + const layout = { + title: '', + yaxis: { + title: 'Group-Aware Consensus', + range: [0, 1], + zeroline: false + }, + xaxis: { + title: 'Topics', + tickangle: -45 + }, + showlegend: false, + margin: { + l: 80, + r: 40, + t: 40, + b: 250 + }, + plot_bgcolor: 'rgba(0,0,0,0)', + paper_bgcolor: 'rgba(0,0,0,0)' + }; + + const config = { + responsive: true, + displayModeBar: false + }; + + window.Plotly.newPlot('layer-distribution-plot', plotData, layout, config); + } + }, [plotData]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{layerName} - Consensus Distribution

+ +
+ + {/* Content */} +
+

+ Box plots showing the distribution of group-aware consensus values for comments with at least 5 votes within each topic. + The box shows the quartiles, the line inside is the median, and outliers are shown as individual points. + Comments with fewer than 5 votes are excluded as they default to 0.333 consensus. +

+ + {plotData && plotData.length > 0 ? ( +
+ ) : ( +
+ No data to display +
+ )} +
+
+
+ ); +}; + +export default LayerDistributionModal; \ No newline at end of file diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx new file mode 100644 index 0000000000..4d0ca65d1d --- /dev/null +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -0,0 +1,283 @@ +import React, { useState, useEffect } from "react"; +import net from "../../util/net"; +import { useReportId } from "../framework/useReportId"; +import Heading from "../framework/heading.jsx"; +import Footer from "../framework/Footer.jsx"; +import CollectiveStatementModal from "./CollectiveStatementModal.jsx"; +import BeeswarmModal from "./BeeswarmModal.jsx"; +import AllCommentsModal from "./AllCommentsModal.jsx"; +import LayerDistributionModal from "./LayerDistributionModal.jsx"; +import TopicOverviewScatterplot from "./visualizations/TopicOverviewScatterplot.jsx"; +import TopicTables from "./visualizations/TopicTables.jsx"; +import TopicPage from "../topicPage/TopicPage.jsx"; + +const TopicStats = ({ conversation, report_id: propsReportId, math, comments, ptptCount, formatTid, voteColors }) => { + const { report_id } = useReportId(propsReportId); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [topicsData, setTopicsData] = useState(null); + const [statsData, setStatsData] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [scatterModalOpen, setScatterModalOpen] = useState(false); + const [beeswarmModalOpen, setBeeswarmModalOpen] = useState(false); + const [layerModalOpen, setLayerModalOpen] = useState(false); + const [selectedTopic, setSelectedTopic] = useState(null); + const [selectedLayer, setSelectedLayer] = useState(null); + // Removed showTopicPage and selectedTopicKey state - now using URL routing + + // Calculate metrics from comments data + const calculateMetricsFromComments = (commentTids, allComments) => { + if (!commentTids || !allComments) return null; + + // Create a map for quick lookup + const commentMap = {}; + allComments.forEach(c => { + commentMap[c.tid] = c; + }); + + let totalVotes = 0; + let totalAgree = 0; + let totalDisagree = 0; + let totalPass = 0; + let consensusSum = 0; + let divisiveSum = 0; + let commentCount = 0; + + commentTids.forEach(tid => { + const comment = commentMap[tid]; + if (!comment) return; + + commentCount++; + const agreeCount = comment.agree_count || 0; + const disagreeCount = comment.disagree_count || 0; + const passCount = comment.pass_count || 0; + const voteCount = agreeCount + disagreeCount + passCount; + + totalVotes += voteCount; + totalAgree += agreeCount; + totalDisagree += disagreeCount; + totalPass += passCount; + + // Calculate per-comment consensus + const activeVotes = agreeCount + disagreeCount; + if (activeVotes > 0) { + const agreeRate = agreeCount / activeVotes; + const disagreeRate = disagreeCount / activeVotes; + const consensus = Math.max(agreeRate, disagreeRate); + consensusSum += consensus * voteCount; + + // Divisiveness: how evenly split the votes are + const divisiveness = 1 - Math.abs(agreeRate - disagreeRate); + divisiveSum += divisiveness * voteCount; + } + }); + + return { + comment_count: commentCount, + total_votes: totalVotes, + consensus: totalVotes > 0 ? consensusSum / totalVotes : 0, + divisiveness: totalVotes > 0 ? divisiveSum / totalVotes : 0, + agree_votes: totalAgree, + disagree_votes: totalDisagree, + pass_votes: totalPass, + vote_density: commentCount > 0 ? totalVotes / commentCount : 0, + }; + }; + + + + useEffect(() => { + if (!report_id) return; + + const fetchData = async () => { + try { + setLoading(true); + + // Fetch topics from Delphi endpoint + const topicsResponse = await net.polisGet("/api/v3/delphi", { + report_id: report_id, + }); + + // Fetch topic statistics + const statsResponse = await net.polisGet("/api/v3/topicStats", { + report_id: report_id, + }); + + if (topicsResponse.status === "success") { + setTopicsData(topicsResponse.runs); + } + + if (statsResponse.status === "success" && comments) { + // Calculate metrics client-side using comments data + const enrichedStats = {}; + Object.entries(statsResponse.stats).forEach(([topicKey, stats]) => { + const metrics = calculateMetricsFromComments(stats.comment_tids, comments); + enrichedStats[topicKey] = { + ...stats, + ...metrics, + comment_tids: stats.comment_tids + }; + }); + setStatsData(enrichedStats); + } + + setLoading(false); + } catch (err) { + console.error("Error fetching topic stats:", err); + setError(err.message || "Failed to load topic statistics"); + setLoading(false); + } + }; + + fetchData(); + }, [report_id, comments]); + + if (loading) { + return ( +
+ +
+

Loading topic statistics...

+
+
+ ); + } + + if (error) { + return ( +
+ +
+

Error: {error}

+
+
+ ); + } + + // Get the most recent run of topics + const latestRunKey = Object.keys(topicsData || {}).sort().reverse()[0]; + const latestRun = topicsData?.[latestRunKey]; + + return ( +
+ +
+

Topic Statistics

+ + {latestRun && ( +
+

Model: {latestRun.model_name}

+

Generated: {new Date(latestRun.created_at).toLocaleString()}

+ + {/* Group-aware consensus scatterplot */} + { + window.location.href = `/topicStats/${report_id}/${topic.topic_key.replace(/#/g, '%23')}`; + }} + /> + + + { + setSelectedTopic(topic); + setModalOpen(true); + }} + onScatterplot={(topic) => { + setSelectedTopic(topic); + setScatterModalOpen(true); + }} + onBeeswarm={(topic) => { + setSelectedTopic(topic); + setBeeswarmModalOpen(true); + }} + onLayerDistribution={(layer) => { + setSelectedLayer(layer); + setLayerModalOpen(true); + }} + onViewTopic={(topic) => { + window.location.href = `/topicStats/${report_id}/${topic.key.replace(/#/g, '%23')}`; + }} + /> +
+ )} + +
+
+ + { + setModalOpen(false); + setSelectedTopic(null); + }} + topicName={selectedTopic?.name} + topicKey={selectedTopic?.key} + reportId={report_id} + conversation={conversation} + math={math} + comments={comments} + ptptCount={ptptCount} + formatTid={formatTid} + voteColors={voteColors} + /> + + { + setScatterModalOpen(false); + setSelectedTopic(null); + }} + topicName={selectedTopic?.name} + topicKey={selectedTopic?.key} + topicStats={selectedTopic ? statsData[selectedTopic.key] : null} + comments={comments} + math={math} + conversation={conversation} + ptptCount={ptptCount} + formatTid={formatTid} + voteColors={voteColors} + /> + + { + setBeeswarmModalOpen(false); + setSelectedTopic(null); + }} + topicName={selectedTopic?.name} + topicKey={selectedTopic?.key} + topicStats={selectedTopic ? statsData[selectedTopic.key] : null} + conversation={conversation} + math={math} + comments={comments} + ptptCount={ptptCount} + formatTid={formatTid} + voteColors={voteColors} + /> + + { + setLayerModalOpen(false); + setSelectedLayer(null); + }} + layerName={selectedLayer?.layerName} + layerId={selectedLayer?.layerId} + topics={selectedLayer?.topics} + statsData={statsData} + math={math} + comments={comments} + /> +
+ ); +}; + +export default TopicStats; \ No newline at end of file diff --git a/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx b/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx new file mode 100644 index 0000000000..d2f4b34971 --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import TopicScatterplot from '../../topicScatterplot/TopicScatterplot.jsx'; + +const AllCommentsScatterplot = ({ comments, math, voteColors }) => { + if (!comments || !math) { + return null; + } + + // Use normalized consensus if available, fall back to raw + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + if (!consensusData) { + return null; + } + + const data = (() => { + const scatterData = []; + let minConsensus = Infinity; + let maxConsensus = -Infinity; + + comments.forEach(comment => { + const groupConsensus = consensusData[comment.tid]; + if (groupConsensus !== undefined) { + const totalVotes = (comment.agree_count || 0) + (comment.disagree_count || 0) + (comment.pass_count || 0); + + // Track min/max consensus for color scaling + minConsensus = Math.min(minConsensus, groupConsensus); + maxConsensus = Math.max(maxConsensus, groupConsensus); + + scatterData.push({ + topic_name: `Comment ${comment.tid}: ${comment.txt}`, + consensus: groupConsensus, + avg_votes_per_comment: totalVotes, // Using total votes for x-axis + comment_count: 1, // Fixed size for all comments + comment_id: comment.tid, + full_text: comment.txt + }); + } + }); + + // Fix edge case where no data + if (minConsensus === Infinity) { + minConsensus = 0; + maxConsensus = 1; + } + + // Add consensus extents to each item for color calculation + return scatterData.map(d => ({ ...d, minConsensus, maxConsensus })); + })(); + + return ( +
+

All Comments: Group-Aware Consensus

+

+ Y-axis (Group-Aware Consensus): Measures agreement across different participant groups from PCA2. + Higher values indicate comments where groups tend to vote similarly (cross-group agreement).
+ X-axis: Total votes | Bubble size: Fixed (all comments equal)
+ Colors: Green = high group consensus, + Red = low group consensus +

+ { + console.log('Comment clicked:', comment); + }} + /> +
+ ); +}; + +export default AllCommentsScatterplot; \ No newline at end of file diff --git a/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx b/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx new file mode 100644 index 0000000000..71d4b5f5a0 --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx @@ -0,0 +1,304 @@ +import React, { useState, useEffect, useRef } from 'react'; +import _ from 'lodash'; +import CommentList from '../../lists/commentList.jsx'; + +const VoronoiCells = ({ currentComment, voronoi, onHoverCallback, dataExtent }) => { + const getFill = (cell) => { + if (currentComment?.tid === cell.data.tid) { + return "rgb(0,0,255)"; // Blue for selected + } else { + // Color based on group consensus value, normalized to data extent + const consensus = cell.data.groupConsensus || 0; + const [min, max] = dataExtent || [0, 1]; + const normalized = Math.max(0, Math.min(1, (consensus - min) / (max - min))); + + // Use a smooth gradient from red to yellow to green + let r, g, b; + + if (normalized < 0.5) { + // Red to Yellow (increase green) + const ratio = normalized * 2; + r = 231; + g = Math.round(76 + (165 * ratio)); + b = 60; + } else { + // Yellow to Green (decrease red) + const ratio = (normalized - 0.5) * 2; + r = Math.round(231 * (1 - ratio)); + g = 231; + b = 60; + } + + return `rgb(${r},${g},${b})`; + } + } + + return ( + + {voronoi.map((cell, i) => { + return ( + + + + + ) + })} + + ) +} + +const TopicBeeswarm = ({ comments, commentTids, math, conversation, ptptCount, formatTid, voteColors }) => { + const svgWidth = 1100; // Increased to fill modal width + const svgHeight = 200; + const margin = {top: 10, right: 40, bottom: 30, left: 40}; + const widthMinusMargins = svgWidth - margin.left - margin.right; + const heightMinusMargins = svgHeight - margin.top - margin.bottom; + + const [currentComment, setCurrentComment] = useState(null); + const [commentsWithConsensus, setCommentsWithConsensus] = useState(null); + const [voronoi, setVoronoi] = useState(null); + const [dataExtent, setDataExtent] = useState([0, 1]); + const [filterLowVotes, setFilterLowVotes] = useState(true); + const svgRef = useRef(null); + + const onHoverCallback = (d) => { + return () => { + setCurrentComment(d.data); + } + } + + const setup = () => { + if (!comments || !commentTids || !math) return; + + // Use normalized consensus if available, fall back to raw consensus + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + if (!consensusData) return; + + // Filter to only topic comments and add group consensus + const commentsWithConsensusData = []; + comments.forEach((comment) => { + if (commentTids.includes(comment.tid)) { + const totalVotes = (comment.agree_count || 0) + (comment.disagree_count || 0) + (comment.pass_count || 0); + const groupConsensus = consensusData[comment.tid]; + // Apply vote filter - remove comments with 0 or 1 votes if filter is on + const minVotes = filterLowVotes ? 2 : 0; + if (groupConsensus !== undefined && totalVotes >= minVotes) { + commentsWithConsensusData.push({ + ...comment, + groupConsensus: groupConsensus, + totalVotes: totalVotes + }); + } + } + }); + + if (commentsWithConsensusData.length === 0) return; + + // Always use fixed scale from 0 to 1 + setDataExtent([0, 1]); + + // Create x scale with fixed domain [0, 1] + const x = window.d3.scaleLinear() + .domain([0, 1]) + .rangeRound([0, widthMinusMargins]); + + // Run force simulation + const simulation = window.d3.forceSimulation(commentsWithConsensusData) + .force("x", window.d3.forceX(function(d) { + return x(d.groupConsensus); + }).strength(1)) + .force("y", window.d3.forceY(heightMinusMargins / 2)) + .force("collide", window.d3.forceCollide(5)) + .stop(); + + // Run simulation + for (let i = 0; i < 120; ++i) simulation.tick(); + + // Create voronoi for hover detection + const voronoiGenerator = window.d3.voronoi() + .extent([[-margin.left, -margin.top], [widthMinusMargins + margin.right, heightMinusMargins + margin.top]]) + .x(function(d) { return d.x; }) + .y(function(d) { return d.y; }); + + const voronoiPolygons = voronoiGenerator.polygons(commentsWithConsensusData); + + setCommentsWithConsensus(commentsWithConsensusData); + setVoronoi(voronoiPolygons); + + // Don't add axis here - we'll add it once in the useEffect + } + + useEffect(() => { + setup(); + }, [comments, commentTids, math, filterLowVotes]); + + // Add axis in a separate effect after data is loaded + useEffect(() => { + if (svgRef.current && commentsWithConsensus && dataExtent) { + const svg = window.d3.select(svgRef.current); + + // First remove ALL axes to prevent duplicates + svg.selectAll(".x-axis").remove(); + svg.selectAll("g").selectAll(".x-axis").remove(); + + const x = window.d3.scaleLinear() + .domain(dataExtent) + .rangeRound([0, widthMinusMargins]); + + // Ensure we're selecting the right group and only adding one axis + const mainGroup = svg.select("g.main-group"); + if (!mainGroup.empty()) { + mainGroup + .append("g") + .attr("class", "x-axis") + .attr("transform", `translate(0, ${heightMinusMargins})`) + .call(window.d3.axisBottom(x).ticks(5).tickFormat(d => d.toFixed(1))); + } + } + }, [commentsWithConsensus, dataExtent, widthMinusMargins, heightMinusMargins]); + + if (!commentsWithConsensus || !voronoi) { + return ( +
+

Loading visualization...

+
+ ); + } + + return ( +
+ {/* Vote filter checkbox moved to the left */} +
+ +
+ + + + + + + + {/* Gradient legend as SVG */} + + + {/* Gradient definition */} + + + + + + + + + {/* Labels above gradient */} + {(() => { + const steps = 6; + const labels = []; + for (let i = 0; i < steps; i++) { + const value = dataExtent[0] + (dataExtent[1] - dataExtent[0]) * (i / (steps - 1)); + const x = (widthMinusMargins / (steps - 1)) * i; + labels.push( + + {value.toFixed(2)} + + ); + } + return labels; + })()} + + {/* Gradient bar */} + + + {/* Text labels below gradient */} + + All groups + + + DISAGREE + + + + Groups are split + + + (or low votes) + + + + All groups + + + AGREE + + + + +
+ {currentComment ? ( + + ) : ( +
+ Hover over a circle to see comment details +
+ )} +
+
+ ); +} + +export default TopicBeeswarm; \ No newline at end of file diff --git a/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx new file mode 100644 index 0000000000..4863a6bf95 --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import TopicScatterplot from '../../topicScatterplot/TopicScatterplot.jsx'; + +const TopicOverviewScatterplot = ({ latestRun, statsData, math, voteColors, onTopicClick }) => { + if (!statsData || !math) { + return null; + } + + // Use normalized consensus if available, fall back to raw + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + if (!consensusData) { + return null; + } + + const data = (() => { + const scatterData = []; + let minConsensus = Infinity; + let maxConsensus = -Infinity; + + Object.entries(latestRun.topics_by_layer || {}).forEach(([layerId, topics]) => { + Object.entries(topics).forEach(([clusterId, topic]) => { + const stats = statsData[topic.topic_key] || {}; + + // Calculate average group consensus for this topic + let groupConsensus = null; + if (stats.comment_tids) { + const consensusValues = stats.comment_tids + .map(tid => consensusData[tid]) + .filter(val => val !== undefined); + + if (consensusValues.length > 0) { + groupConsensus = consensusValues.reduce((sum, val) => sum + val, 0) / consensusValues.length; + } + } + + if (stats.comment_count > 0 && groupConsensus !== null) { + const avgVotes = stats.vote_density || 0; + + // Track min/max consensus for color scaling + minConsensus = Math.min(minConsensus, groupConsensus); + maxConsensus = Math.max(maxConsensus, groupConsensus); + + scatterData.push({ + topic_name: topic.topic_name, + consensus: groupConsensus, + avg_votes_per_comment: avgVotes, + comment_count: stats.comment_count || 0, + layer: layerId, + topic_key: topic.topic_key + }); + } + }); + }); + + // Fix edge case where no data + if (minConsensus === Infinity) { + minConsensus = 0; + maxConsensus = 1; + } + + // Add consensus extents to each item for color calculation + return scatterData.map(d => ({ ...d, minConsensus, maxConsensus })); + })(); + + return ( +
+

Topic Overview: Group-Aware Consensus

+

+ Y-axis (Group-Aware Consensus): Measures agreement across different participant groups from PCA2. + Higher values indicate topics where groups tend to vote similarly (cross-group agreement).
+ X-axis: Average votes per comment | Bubble size: Number of comments
+ Colors: Green = high group consensus, + Red = low group consensus +

+ +
+ ); +}; + +export default TopicOverviewScatterplot; \ No newline at end of file diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx new file mode 100644 index 0000000000..daa1b4097b --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -0,0 +1,271 @@ +import React, { useState } from 'react'; +import { canGenerateCollectiveStatement, THRESHOLDS } from '../../../util/consensusThreshold'; + +const TopicTables = ({ latestRun, statsData, math, report_id, onTopicSelect, onScatterplot, onBeeswarm, onLayerDistribution, onViewTopic }) => { + const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); + + const handleSort = (key) => { + setSortConfig(prevConfig => ({ + key, + direction: prevConfig.key === key && prevConfig.direction === 'desc' ? 'asc' : 'desc' + })); + }; + + const layerEntries = Object.entries(latestRun.topics_by_layer || {}); + const totalLayers = layerEntries.length; + + return ( +
+ {/* Threshold explanation note */} +
+ + Candidate collective statements require at least {THRESHOLDS.MIN_COMMENTS} comments with + ≥{(THRESHOLDS.MIN_CONSENSUS * 100)}% consensus and + ≥{(THRESHOLDS.MIN_GROUP_PARTICIPATION * 100)}% participation from every group + +
+ + {layerEntries + .sort(([a], [b]) => parseInt(b) - parseInt(a)) // Sort layers in descending order + .map(([layerId, topics]) => { + const topicCount = Object.keys(topics).length; + const layerNum = parseInt(layerId); + + // Dynamic layer naming based on position + let layerName = ""; + let layerDescription = ""; + + if (layerNum === 0) { + layerName = "Finer Grained"; + layerDescription = "(Specific insights)"; + } else if (layerNum === totalLayers - 1) { + layerName = "Coarse"; + layerDescription = "(Big picture themes)"; + } else { + layerName = "Medium"; + layerDescription = "(Balanced overview)"; + } + + const layerLabel = `${layerName}: ${topicCount} Topics\n${layerDescription}`; + + return ( +
+
+

{layerLabel}

+ +
+ + + + + + + + + + + + + {Object.entries(topics) + .map(([clusterId, topic]) => { + const stats = statsData?.[topic.topic_key] || {}; + + // Calculate average group consensus for this topic + let groupConsensus = null; + // Use normalized consensus if available, fall back to raw + const consensusData = math?.["group-consensus-normalized"] || math?.["group-aware-consensus"]; + if (consensusData && stats.comment_tids) { + const consensusValues = stats.comment_tids + .map(tid => consensusData[tid]) + .filter(val => val !== undefined); + + if (consensusValues.length > 0) { + groupConsensus = consensusValues.reduce((sum, val) => sum + val, 0) / consensusValues.length; + } + } + + // Check if this topic can generate a collective statement + const statementCheck = canGenerateCollectiveStatement(stats.comment_tids, math); + + return { + clusterId, + topic, + stats: { ...stats, group_consensus: groupConsensus }, + statementCheck + }; + }) + .sort((a, b) => { + let aValue, bValue; + + switch (sortConfig.key) { + case 'topic_name': + aValue = a.topic.topic_name.toLowerCase(); + bValue = b.topic.topic_name.toLowerCase(); + break; + case 'comment_count': + aValue = a.stats.comment_count || 0; + bValue = b.stats.comment_count || 0; + break; + case 'total_votes': + aValue = a.stats.total_votes || 0; + bValue = b.stats.total_votes || 0; + break; + case 'vote_density': + aValue = a.stats.vote_density || 0; + bValue = b.stats.vote_density || 0; + break; + case 'group_consensus': + aValue = a.stats.group_consensus || 0; + bValue = b.stats.group_consensus || 0; + break; + default: + aValue = a.stats.comment_count || 0; + bValue = b.stats.comment_count || 0; + } + + if (sortConfig.direction === 'asc') { + return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + } else { + return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; + } + }) + .map(({ clusterId, topic, stats, statementCheck }) => ( + + + + + + + + + ))} + +
handleSort('topic_name')}> + Topic {sortConfig.key === 'topic_name' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + handleSort('comment_count')}> + Comments {sortConfig.key === 'comment_count' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + handleSort('total_votes')}> + Total Votes {sortConfig.key === 'total_votes' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + handleSort('vote_density')}> + Avg Votes/Comment {sortConfig.key === 'vote_density' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + handleSort('group_consensus')} + title="Group-aware consensus from PCA2"> + Group Consensus {sortConfig.key === 'group_consensus' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + Action
+ e.target.style.textDecoration = "underline"} + onMouseLeave={(e) => e.target.style.textDecoration = "none"} + > + {topic.topic_name} + + {stats.comment_count || 0}{stats.total_votes || 0} + {stats.vote_density !== undefined ? stats.vote_density.toFixed(1) : '-'} + + {stats.group_consensus !== null ? stats.group_consensus.toFixed(2) : '-'} + +
+ + + + + +
+
+
+ ); + })} +
+ ); +}; + +export default TopicTables; \ No newline at end of file diff --git a/client-report/src/util/consensusThreshold.js b/client-report/src/util/consensusThreshold.js new file mode 100644 index 0000000000..8d5a149cad --- /dev/null +++ b/client-report/src/util/consensusThreshold.js @@ -0,0 +1,153 @@ +/** + * Centralized logic for determining if a topic can generate a collective statement + */ + +const MIN_CONSENSUS = 0.8; +const MIN_COMMENTS = 3; +const MIN_GROUP_PARTICIPATION = 0.05; // 5% of each group must have voted + +/** + * Checks if a comment has sufficient participation from all groups + * @param {number} tid - Comment ID + * @param {Object} groupVotes - The group-votes data from math + * @returns {Object} { hasMinParticipation: boolean, details: string } + */ +function checkGroupParticipation(tid, groupVotes) { + if (!groupVotes) { + return { hasMinParticipation: false, details: "No group vote data" }; + } + + const groupParticipation = []; + let allGroupsMeetThreshold = true; + + for (const gid in groupVotes) { + const group = groupVotes[gid]; + const groupSize = group["n-members"] || 0; + const votes = group.votes?.[tid]; + + if (!votes) { + groupParticipation.push(`Group ${gid}: No votes`); + allGroupsMeetThreshold = false; + continue; + } + + const totalVotes = (votes.A || 0) + (votes.D || 0) + (votes.S || 0); + const participationRate = groupSize > 0 ? totalVotes / groupSize : 0; + + if (participationRate < MIN_GROUP_PARTICIPATION) { + allGroupsMeetThreshold = false; + groupParticipation.push(`Group ${gid}: ${(participationRate * 100).toFixed(1)}% (need ${(MIN_GROUP_PARTICIPATION * 100)}%)`); + } else { + groupParticipation.push(`Group ${gid}: ${(participationRate * 100).toFixed(1)}% ✓`); + } + } + + return { + hasMinParticipation: allGroupsMeetThreshold, + details: groupParticipation.join(", ") + }; +} + +/** + * Determines if a topic has enough high-consensus comments to generate a collective statement + * + * @param {Array} commentTids - Array of comment IDs in this topic + * @param {Object} math - Math object containing consensus data and group votes + * @returns {Object} { canGenerate: boolean, count: number, message: string, details: Array } + */ +export function canGenerateCollectiveStatement(commentTids, math) { + if (!commentTids || !math) { + return { + canGenerate: false, + count: 0, + message: "Missing required data", + details: [] + }; + } + + // Use normalized consensus if available, fall back to raw + const consensusData = math["group-consensus-normalized"] || math["group-aware-consensus"]; + const groupVotes = math["group-votes"]; + + if (!consensusData) { + return { + canGenerate: false, + count: 0, + message: "No consensus data available", + details: [] + }; + } + + // Check each comment for both consensus threshold AND group participation + const qualifyingComments = []; + const failureReasons = []; + + commentTids.forEach(tid => { + const consensus = consensusData[tid]; + if (consensus === undefined) return; + + const meetsConsensus = consensus >= MIN_CONSENSUS; + const participationCheck = checkGroupParticipation(tid, groupVotes); + + if (meetsConsensus && participationCheck.hasMinParticipation) { + qualifyingComments.push({ + tid, + consensus, + participationDetails: participationCheck.details + }); + } else { + const reasons = []; + if (!meetsConsensus) { + reasons.push(`consensus ${consensus.toFixed(2)} < ${MIN_CONSENSUS}`); + } + if (!participationCheck.hasMinParticipation) { + reasons.push(`insufficient group participation`); + } + failureReasons.push(`Comment ${tid}: ${reasons.join(", ")}`); + } + }); + + const canGenerate = qualifyingComments.length >= MIN_COMMENTS; + let message; + + if (canGenerate) { + message = `${qualifyingComments.length} comments meet all requirements`; + } else { + message = `Need at least ${MIN_COMMENTS} comments with ≥${MIN_CONSENSUS} consensus AND ≥${(MIN_GROUP_PARTICIPATION * 100)}% participation from EVERY group. Only ${qualifyingComments.length} qualify.`; + } + + return { + canGenerate, + count: qualifyingComments.length, + message, + details: canGenerate ? qualifyingComments : failureReasons + }; +} + +/** + * Gets the consensus values for a set of comments + * @param {Array} commentTids - Array of comment IDs + * @param {Object} math - Math object containing consensus data + * @returns {Object} Map of tid to consensus value + */ +export function getTopicConsensusValues(commentTids, math) { + const consensusData = math?.["group-consensus-normalized"] || math?.["group-aware-consensus"]; + const values = {}; + + if (consensusData && commentTids) { + commentTids.forEach(tid => { + if (consensusData[tid] !== undefined) { + values[tid] = consensusData[tid]; + } + }); + } + + return values; +} + +// Export the thresholds if other components need them for display +export const THRESHOLDS = { + MIN_CONSENSUS, + MIN_COMMENTS, + MIN_GROUP_PARTICIPATION +}; \ No newline at end of file diff --git a/client-report/src/util/graphUtil.js b/client-report/src/util/graphUtil.js index 990e90ab51..b51f58da2d 100644 --- a/client-report/src/util/graphUtil.js +++ b/client-report/src/util/graphUtil.js @@ -4,75 +4,104 @@ import * as globals from "../components/globals"; import createHull from "hull.js"; const graphUtil = (comments, math, badTids) => { + // Add comprehensive type safety and default values + const safeComments = Array.isArray(comments) ? comments : []; + const safeMath = math || {}; + const safeBadTids = badTids || {}; + + // Check if we have the minimum required data + if (!safeMath.pca || !safeMath.pca['comment-projection'] || !Array.isArray(safeMath.tids)) { + console.warn('GraphUtil: Missing or invalid PCA data, returning empty results'); + return { + xx: [], + yy: [], + commentsPoints: [], + xCenter: 0, + yCenter: 0, + baseClustersScaled: [], + commentScaleupFactorX: 1, + commentScaleupFactorY: 1, + hulls: [] + }; + } const allXs = []; const allYs = []; - const commentsByTid = comments.reduce((accumulator, comment) => { - accumulator[comment.tid] = comment; + const commentsByTid = safeComments.reduce((accumulator, comment) => { + if (comment && typeof comment.tid !== 'undefined') { + accumulator[comment.tid] = comment; + } return accumulator; }, {}); - const indexToTid = math.tids; + + const indexToTid = safeMath.tids || []; const tidToIndex = []; for (let i = 0; i < indexToTid.length; i++) { - tidToIndex[indexToTid[i]] = i; + if (typeof indexToTid[i] !== 'undefined') { + tidToIndex[indexToTid[i]] = i; + } } + // comments const commentsPoints = []; - const projX = math.pca['comment-projection'][0]; - const projY = math.pca['comment-projection'][1]; + const projX = safeMath.pca['comment-projection'][0] || []; + const projY = safeMath.pca['comment-projection'][1] || []; // let rejectedCount = 0; - for (let i = 0; i < projX.length; i++) { - if (comments[i]) { - let tid = comments[i].tid; + for (let i = 0; i < Math.min(projX.length, projY.length, safeComments.length); i++) { + if (safeComments[i] && typeof safeComments[i].tid !== 'undefined') { + let tid = safeComments[i].tid; let index = tidToIndex[tid]; - let x = projX[index]; - let y = projY[index]; - // if (i === 32) { // TODO_DEMO_HACK use force layout instead - // x += 0.02; - // y += 0.01; - // } - if (!badTids[tid]) { - if (commentsByTid[tid]) { - commentsPoints.push({ - x: x, - y: y, - tid: tid, - txt: commentsByTid[tid].txt, - }); - } else { - // rejectedCount += 1; - // console.log('skipping rejected', i, rejectedCount); + + // Ensure index is valid and projections exist + if (typeof index !== 'undefined' && + typeof projX[index] === 'number' && + typeof projY[index] === 'number') { + let x = projX[index]; + let y = projY[index]; + + if (!safeBadTids[tid]) { + if (commentsByTid[tid]) { + commentsPoints.push({ + x: x, + y: y, + tid: tid, + txt: commentsByTid[tid].txt || '', + }); + } } - } else { - // console.log('skipping bad', i); } } } const baseClusterIdToGid = (baseClusterId) => { - var clusters = math["group-clusters"]; + var clusters = safeMath["group-clusters"] || []; for (let i = 0; i < clusters.length; i++) { - if (clusters[i].members.indexOf(baseClusterId) >= 0) { + if (clusters[i] && clusters[i].members && clusters[i].members.indexOf(baseClusterId) >= 0) { return clusters[i].id; } } } // participants - const clusterXs = math["base-clusters"].x; - const clusterYs = math["base-clusters"].y; - const bids = math["base-clusters"].id; + const baseClustersData = safeMath["base-clusters"] || {}; + const clusterXs = baseClustersData.x || []; + const clusterYs = baseClustersData.y || []; + const bids = baseClustersData.id || []; let baseClusters = []; - for (let i = 0; i < clusterXs.length; i++) { - baseClusters.push({ - x: clusterXs[i], - y: clusterYs[i], - id: bids[i], - gid: baseClusterIdToGid(bids[i]), - }); - allXs.push(clusterXs[i]); - allYs.push(clusterYs[i]); + + const minLength = Math.min(clusterXs.length, clusterYs.length, bids.length); + for (let i = 0; i < minLength; i++) { + if (typeof clusterXs[i] === 'number' && typeof clusterYs[i] === 'number') { + baseClusters.push({ + x: clusterXs[i], + y: clusterYs[i], + id: bids[i], + gid: baseClusterIdToGid(bids[i]), + }); + allXs.push(clusterXs[i]); + allYs.push(clusterYs[i]); + } } let border = 100; @@ -178,17 +207,30 @@ const graphUtil = (comments, math, badTids) => { // Destructure the group entry (key and value) const [groupName, groupPoints] = group; - // Create an array of coordinate pairs - const pairs = groupPoints.map((g) => [g.x, g.y]); - - // Calculate the convex hull - const hull = createHull(pairs, 400); - - // Push the result to hulls - hulls.push({ - group: groupName, - hull, - }); + // Only create hulls if we have enough points and valid data + if (Array.isArray(groupPoints) && groupPoints.length >= 3) { + try { + // Create an array of coordinate pairs, filtering out invalid coordinates + const pairs = groupPoints + .filter(g => g && typeof g.x === 'number' && typeof g.y === 'number' && + !isNaN(g.x) && !isNaN(g.y)) + .map((g) => [g.x, g.y]); + + // Only create hull if we have enough valid points + if (pairs.length >= 3) { + const hull = createHull(pairs, 400); + + if (hull && hull.length > 0) { + hulls.push({ + group: groupName, + hull, + }); + } + } + } catch (error) { + console.warn(`Failed to create hull for group ${groupName}:`, error); + } + } } return { diff --git a/client-report/src/util/graphUtil.test.js b/client-report/src/util/graphUtil.test.js index bcc0490253..cea7daa928 100644 --- a/client-report/src/util/graphUtil.test.js +++ b/client-report/src/util/graphUtil.test.js @@ -8,12 +8,13 @@ Object.defineProperty(window, 'd3', { // eslint-disable-next-line no-undef global.window.d3 = { scaleLinear: jest.fn(() => { - const mockScale = { // Create a mock scale object + const mockScaleFunction = jest.fn((x) => x * 10); // Mock scale function that transforms input + const mockScale = Object.assign(mockScaleFunction, { domain: jest.fn(() => mockScale), // Return the mockScale itself rangeRound: jest.fn(() => mockScale), // Return the mockScale itself - range: jest.fn(() => jest.fn()), // Add range for completeness + range: jest.fn(() => mockScale), // Return the mockScale itself, not a function // Add other methods as needed (e.g., tickFormat) - }; + }); return mockScale; // Return the mock scale object }), geoPath: jest.fn(() => jest.fn()), @@ -86,7 +87,12 @@ describe("graphUtil", () => { y: [40, 50, 60], id: [100, 200, 300], }, - "group-clusters": [], + "group-clusters": [ + { + id: 0, + members: [100, 200, 300] // All base clusters belong to group 0 + } + ], }; const mockBadTids = {}; // No badTids @@ -94,4 +100,48 @@ describe("graphUtil", () => { expect(mockCreateHull).toHaveBeenCalledTimes(1); }); + + it("should handle sparse/empty data gracefully", () => { + const result1 = graphUtil([], {}, {}); // Empty data + expect(result1.commentsPoints).toEqual([]); + expect(result1.baseClustersScaled).toEqual([]); + expect(result1.hulls).toEqual([]); + + const result2 = graphUtil(null, null, null); // Null data + expect(result2.commentsPoints).toEqual([]); + expect(result2.baseClustersScaled).toEqual([]); + expect(result2.hulls).toEqual([]); + + const result3 = graphUtil( + [{ tid: 1, txt: "Comment 1" }], + { pca: { "comment-projection": [[], []] }, tids: [] }, // Empty projections + {} + ); + expect(result3.commentsPoints).toEqual([]); + }); + + it("should not create hulls with insufficient points", () => { + const mockCreateHull = jest.fn().mockReturnValue("Mock Hull"); + createHull.mockImplementation(mockCreateHull); + + const mockMath = { + pca: { "comment-projection": [[1], [2]] }, + tids: [1, 2], + "base-clusters": { + x: [10, 20], // Only 2 points + y: [40, 50], + id: [100, 200], + }, + "group-clusters": [ + { + id: 0, + members: [100, 200] // Only 2 base clusters (insufficient for hull) + } + ], + }; + + graphUtil([], mockMath, {}); + + expect(mockCreateHull).toHaveBeenCalledTimes(0); // Should not create hull with < 3 points + }); }); \ No newline at end of file diff --git a/client-report/src/util/net.js b/client-report/src/util/net.js index 3df804af36..0677d005d6 100644 --- a/client-report/src/util/net.js +++ b/client-report/src/util/net.js @@ -14,15 +14,15 @@ function polisAjax(api, data, type, token) { } let url = urlPrefix + api; - + const options = { method: type, headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "max-age=0", + "Content-Type": "application/json", ...(token && {"Authorization": `Bearer ${token}`}) }, - credentials: "include", // This sends cookies with the request + // For cross-origin requests, omit credentials to avoid CORS preflight issues + credentials: "omit", }; if (type === "POST") { diff --git a/client-report/src/util/normalizeConsensus.js b/client-report/src/util/normalizeConsensus.js new file mode 100644 index 0000000000..4ec73e1a67 --- /dev/null +++ b/client-report/src/util/normalizeConsensus.js @@ -0,0 +1,68 @@ +/** + * Normalizes group-aware consensus values to a 0-1 scale where: + * - 1.0 = all groups agree + * - 0.0 = all groups disagree + * - 0.5 = groups are split + * + * This fixes the issue where raw consensus values shrink with more groups + * because they're products of probabilities. + * + * @param {Object} groupVotes - The group-votes object from math results + * @param {number} tid - The comment ID + * @returns {number} Normalized consensus value between 0 and 1 + */ +export function normalizeGroupConsensus(groupVotes, tid) { + if (!groupVotes) return 0.5; // neutral default + + let sum = 0; + let groupCount = 0; + + // Calculate arithmetic mean of per-group agreement probabilities + for (const gid in groupVotes) { + const votes = groupVotes[gid].votes[tid]; + if (!votes) continue; + + // Use Laplace smoothing: (agrees + 1) / (agrees + disagrees + 2) + const agrees = votes.A || 0; + const disagrees = votes.D || 0; + const probability = (agrees + 1) / (agrees + disagrees + 2); + + sum += probability; + groupCount += 1; + } + + if (groupCount === 0) return 0.5; // No votes, neutral + + return sum / groupCount; +} + +/** + * Enriches a math result object with normalized consensus values + * + * @param {Object} mathResult - The raw math results from server + * @returns {Object} Math results with added 'group-consensus-normalized' field + */ +export function enrichMathWithNormalizedConsensus(mathResult) { + if (!mathResult || !mathResult["group-votes"]) return mathResult; + + const groupVotes = mathResult["group-votes"]; + const normalized = {}; + + // Get all unique tids across all groups + const allTids = new Set(); + for (const gid in groupVotes) { + for (const tid in groupVotes[gid].votes) { + allTids.add(tid); + } + } + + // Calculate normalized consensus for each tid + for (const tid of allTids) { + normalized[tid] = normalizeGroupConsensus(groupVotes, tid); + } + + // Add to math results + mathResult["group-consensus-normalized"] = normalized; + + return mathResult; +} \ No newline at end of file diff --git a/client-report/src/util/normalizeConsensus.test.js b/client-report/src/util/normalizeConsensus.test.js new file mode 100644 index 0000000000..6f443cb7a5 --- /dev/null +++ b/client-report/src/util/normalizeConsensus.test.js @@ -0,0 +1,84 @@ +import { normalizeGroupConsensus, enrichMathWithNormalizedConsensus } from './normalizeConsensus'; + +describe('normalizeGroupConsensus', () => { + it('returns 1.0 when all groups agree', () => { + const groupVotes = { + 0: { votes: { 123: { A: 10, D: 0 } } }, + 1: { votes: { 123: { A: 8, D: 0 } } }, + 2: { votes: { 123: { A: 12, D: 0 } } } + }; + + // With Laplace smoothing: (10+1)/(10+0+2) ≈ 0.917 for each group + // Should be close to 1 but not exactly 1 due to smoothing + const result = normalizeGroupConsensus(groupVotes, 123); + expect(result).toBeCloseTo(0.917, 2); + }); + + it('returns 0.0 when all groups disagree', () => { + const groupVotes = { + 0: { votes: { 123: { A: 0, D: 10 } } }, + 1: { votes: { 123: { A: 0, D: 8 } } }, + 2: { votes: { 123: { A: 0, D: 12 } } } + }; + + // With Laplace smoothing: (0+1)/(0+10+2) ≈ 0.083 for each group + const result = normalizeGroupConsensus(groupVotes, 123); + expect(result).toBeCloseTo(0.083, 2); + }); + + it('returns 0.5 when groups are evenly split', () => { + const groupVotes = { + 0: { votes: { 123: { A: 0, D: 10 } } }, // Disagree + 1: { votes: { 123: { A: 10, D: 0 } } } // Agree + }; + + // Group 0: (0+1)/(0+10+2) = 1/12 ≈ 0.083 + // Group 1: (10+1)/(10+0+2) = 11/12 ≈ 0.917 + // Average: (0.083 + 0.917) / 2 = 0.5 + const result = normalizeGroupConsensus(groupVotes, 123); + expect(result).toBeCloseTo(0.5, 2); + }); + + it('returns 0.5 when no votes exist', () => { + const groupVotes = {}; + const result = normalizeGroupConsensus(groupVotes, 123); + expect(result).toBe(0.5); + }); + + it('handles missing votes for a comment', () => { + const groupVotes = { + 0: { votes: { 456: { A: 5, D: 5 } } }, // Different tid + 1: { votes: {} } // No votes for tid 123 + }; + + const result = normalizeGroupConsensus(groupVotes, 123); + expect(result).toBe(0.5); // No data, returns neutral + }); +}); + +describe('enrichMathWithNormalizedConsensus', () => { + it('adds normalized consensus to math results', () => { + const mathResult = { + "group-votes": { + 0: { votes: { + 123: { A: 10, D: 0 }, + 456: { A: 0, D: 10 } + } }, + 1: { votes: { + 123: { A: 8, D: 2 }, + 456: { A: 1, D: 9 } + } } + }, + "group-aware-consensus": { + 123: 0.0123, // Raw value (product of probabilities) + 456: 0.0001 // Raw value (very small due to multiplication) + } + }; + + const enriched = enrichMathWithNormalizedConsensus(mathResult); + + expect(enriched["group-consensus-normalized"]).toBeDefined(); + expect(enriched["group-consensus-normalized"][123]).toBeCloseTo(0.833, 2); + expect(enriched["group-consensus-normalized"][456]).toBeCloseTo(0.167, 2); + }); +}); \ No newline at end of file diff --git a/client-report/src/util/url.js b/client-report/src/util/url.js index 251b6d1f03..5d5251adea 100644 --- a/client-report/src/util/url.js +++ b/client-report/src/util/url.js @@ -24,19 +24,31 @@ const serviceUrl = process.env.SERVICE_URL; const hostname = self.location.hostname; const port = self.location.port; -const getDomainPrefix = () => { +const getDomainPrefix = () => { + let prefix; + if (hostname === 'localhost') { - if (serviceUrl) return `${serviceUrl}/`; - if (port === '' || port === '80') return 'http://localhost/'; - return 'http://localhost:5000/'; + if (serviceUrl) { + prefix = serviceUrl; + } else if (port === '' || port === '80') { + prefix = 'http://localhost'; + } else { + prefix = 'http://localhost:5000'; + } + } else if (hostname.includes('pol.is')) { + prefix = `https://${hostname}`; + } else if (hostname.includes('polis.io')) { + prefix = `https://${hostname}`; + } else if (serviceUrl) { + prefix = serviceUrl; + } else { + prefix = self.origin; } - - if (hostname.includes('pol.is')) return `https://${hostname}/`; - if (hostname.includes('polis.io')) return `https://${hostname}/`; - - if (serviceUrl) return `${serviceUrl}/`; - - return `${self.origin}/`; + + // Ensure we have exactly one trailing slash + prefix = prefix.replace(/\/+$/, '') + '/'; + + return prefix; }; const urlPrefix = getDomainPrefix(); diff --git a/client-report/webpack.dev.js b/client-report/webpack.dev.js index 13de7a5a49..92f03f3582 100644 --- a/client-report/webpack.dev.js +++ b/client-report/webpack.dev.js @@ -10,7 +10,7 @@ const serviceUrl = process.env.SERVICE_URL || 'http://localhost:5000'; module.exports = { ...common, mode: 'development', - devtool: 'inline-source-map', + devtool: 'inline-cheap-module-source-map', output: { filename: 'report_bundle.js', path: path.resolve(__dirname, 'devel'), @@ -30,7 +30,6 @@ module.exports = { devServer: { compress: true, hot: true, - open: "index_report.html", port: 5010, proxy: { '/api': { @@ -41,7 +40,21 @@ module.exports = { static: { directory: path.join(__dirname, 'public'), }, - historyApiFallback: true + historyApiFallback: { + index: '/index_report.html', + rewrites: [ + { from: /^\/report\/.*$/, to: '/index_report.html' }, + { from: /^\/commentsReport\/.*$/, to: '/index_report.html' }, + { from: /^\/topicReport\/.*$/, to: '/index_report.html' }, + { from: /^\/narrativeReport\/.*$/, to: '/index_report.html' }, + { from: /^\/topicsVizReport\/.*$/, to: '/index_report.html' }, + { from: /^\/exportReport\/.*$/, to: '/index_report.html' }, + { from: /^\/topicPrioritize\/.*$/, to: '/index_report.html' }, + { from: /^\/topicPrioritizeSimple\/.*$/, to: '/index_report.html' }, + { from: /^\/topicAgenda\/.*$/, to: '/index_report.html' }, + { from: /^\/topicHierarchy\/.*$/, to: '/index_report.html' } + ] + } }, performance: { // TODO: Find and remove orphan modules; Reduce bundle size. diff --git a/delphi/CLAUDE.md b/delphi/CLAUDE.md index 9bceafea2b..a19d1471e6 100644 --- a/delphi/CLAUDE.md +++ b/delphi/CLAUDE.md @@ -215,6 +215,7 @@ Delphi now includes a distributed job queue system built on DynamoDB: - `Delphi_CommentClustersLLMTopicNames` - LLM-generated topic names (formerly `LLMTopicNames`) - `Delphi_NarrativeReports` - Generated reports (formerly `report_narrative_store`) - `Delphi_JobQueue` - Job queue (formerly `DelphiJobQueue`) +- `Delphi_CollectiveStatement` - Collective statements generated for topics > **Note:** All table names now use the `Delphi_` prefix for consistency. > For complete documentation on the table renaming, see `/Users/colinmegill/polis/delphi/docs/DATABASE_NAMING_PROPOSAL.md` diff --git a/delphi/config.yaml b/delphi/config.yaml deleted file mode 100644 index 264c579d33..0000000000 --- a/delphi/config.yaml +++ /dev/null @@ -1,30 +0,0 @@ -## Pol.is Math Configuration -math_env: dev - -# Data storage -data_dir: "./data" - -# Database settings -database: - enabled: true - url: "postgresql://colinmegill@localhost:5432/polisDB_prod_local_mar14" - -# Server settings -server: - enabled: true - host: "127.0.0.1" - port: 8000 - log_level: "info" # using lowercase to fix KeyError - -# Polling settings - disabled for testing -poller: - enabled: false - -# Compute settings -compute: - n_clusters: 3 - pca_iters: 10 - -# Logging settings -logging: - level: "INFO" \ No newline at end of file diff --git a/delphi/create_dynamodb_tables.py b/delphi/create_dynamodb_tables.py index 377e5ed4e5..6d84c3f1d0 100644 --- a/delphi/create_dynamodb_tables.py +++ b/delphi/create_dynamodb_tables.py @@ -259,16 +259,10 @@ def create_evoc_tables(dynamodb, delete_existing=False): 'Projection': { 'ProjectionType': 'ALL' }, - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' } ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, 'Delphi_NarrativeReports': { 'KeySchema': [ @@ -302,10 +296,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): 'AttributeDefinitions': [ {'AttributeName': 'conversation_id', 'AttributeType': 'S'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, 'Delphi_CommentEmbeddings': { 'KeySchema': [ @@ -316,10 +307,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'comment_id', 'AttributeType': 'N'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, 'Delphi_CommentHierarchicalClusterAssignments': { 'KeySchema': [ @@ -330,10 +318,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'comment_id', 'AttributeType': 'N'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, 'Delphi_CommentClustersStructureKeywords': { 'KeySchema': [ @@ -344,10 +329,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'cluster_key', 'AttributeType': 'S'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, 'Delphi_UMAPGraph': { 'KeySchema': [ @@ -358,10 +340,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'edge_id', 'AttributeType': 'S'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, # Extended tables @@ -374,10 +353,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'cluster_key', 'AttributeType': 'S'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, 'Delphi_CommentClustersLLMTopicNames': { 'KeySchema': [ @@ -388,10 +364,7 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'topic_key', 'AttributeType': 'S'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' }, # Topic Agenda table for storing user selections 'Delphi_TopicAgendaSelections': { @@ -403,10 +376,31 @@ def create_evoc_tables(dynamodb, delete_existing=False): {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, {'AttributeName': 'participant_id', 'AttributeType': 'S'} ], - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } + 'BillingMode': 'PAY_PER_REQUEST' + }, + # Collective Statement table for storing AI-generated group statements + 'Delphi_CollectiveStatement': { + 'KeySchema': [ + {'AttributeName': 'zid_topic_jobid', 'KeyType': 'HASH'} + ], + 'AttributeDefinitions': [ + {'AttributeName': 'zid_topic_jobid', 'AttributeType': 'S'}, + {'AttributeName': 'zid', 'AttributeType': 'S'}, + {'AttributeName': 'created_at', 'AttributeType': 'S'} + ], + 'GlobalSecondaryIndexes': [ + { + 'IndexName': 'zid-created_at-index', + 'KeySchema': [ + {'AttributeName': 'zid', 'KeyType': 'HASH'}, + {'AttributeName': 'created_at', 'KeyType': 'RANGE'} + ], + 'Projection': { + 'ProjectionType': 'ALL' + } + } + ], + 'BillingMode': 'PAY_PER_REQUEST' } } diff --git a/delphi/create_topic_agenda_table.py b/delphi/create_topic_agenda_table.py deleted file mode 100755 index e7a607e105..0000000000 --- a/delphi/create_topic_agenda_table.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -""" -Create Topic Agenda DynamoDB table for Delphi system. - -This script creates the Delphi_TopicAgendaSelections table for storing user topic selections. - -Usage: - python create_topic_agenda_table.py [options] - -Options: - --endpoint-url ENDPOINT_URL DynamoDB endpoint URL - --region REGION AWS region (default: us-east-1) - --force Force recreate table if it exists -""" - -import boto3 -import os -import logging -import argparse -import time - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -def create_topic_agenda_table(dynamodb, force_recreate=False): - """ - Create the Topic Agenda table for storing user selections. - - Args: - dynamodb: boto3 DynamoDB resource - force_recreate: If True, delete existing table before creating - """ - table_name = 'Delphi_TopicAgendaSelections' - - # Check if table exists - existing_tables = [t.name for t in dynamodb.tables.all()] - - if table_name in existing_tables: - if force_recreate: - logger.info(f"Deleting existing table {table_name}...") - table = dynamodb.Table(table_name) - table.delete() - table.meta.client.get_waiter('table_not_exists').wait(TableName=table_name) - logger.info(f"Table {table_name} deleted.") - else: - logger.info(f"Table {table_name} already exists. Use --force to recreate.") - return False - - # Create table - logger.info(f"Creating table {table_name}...") - - table = dynamodb.create_table( - TableName=table_name, - KeySchema=[ - {'AttributeName': 'conversation_id', 'KeyType': 'HASH'}, - {'AttributeName': 'participant_id', 'KeyType': 'RANGE'} - ], - AttributeDefinitions=[ - {'AttributeName': 'conversation_id', 'AttributeType': 'S'}, - {'AttributeName': 'participant_id', 'AttributeType': 'S'} - ], - ProvisionedThroughput={ - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } - ) - - # Wait for table to be active - table.meta.client.get_waiter('table_exists').wait(TableName=table_name) - logger.info(f"Table {table_name} created and active.") - - return True - -def main(): - # Parse arguments - parser = argparse.ArgumentParser(description='Create Topic Agenda DynamoDB table') - parser.add_argument('--endpoint-url', type=str, default=None, - help='DynamoDB endpoint URL') - parser.add_argument('--region', type=str, default='us-east-1', - help='AWS region (default: us-east-1)') - parser.add_argument('--force', action='store_true', - help='Force recreate table if it exists') - args = parser.parse_args() - - # Set up environment variables for local DynamoDB - if args.endpoint_url: - if 'localhost' in args.endpoint_url or '127.0.0.1' in args.endpoint_url: - os.environ['AWS_ACCESS_KEY_ID'] = 'dummy' - os.environ['AWS_SECRET_ACCESS_KEY'] = 'dummy' - - # Create DynamoDB resource - dynamodb = boto3.resource( - 'dynamodb', - endpoint_url=args.endpoint_url, - region_name=args.region - ) - - # Create table - start_time = time.time() - success = create_topic_agenda_table(dynamodb, args.force) - elapsed_time = time.time() - start_time - - if success: - logger.info(f"Table creation completed in {elapsed_time:.2f} seconds") - else: - logger.info(f"Table creation skipped (already exists)") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/delphi/demo_script.py b/delphi/demo_script.py deleted file mode 100644 index 73b4536349..0000000000 --- a/delphi/demo_script.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Demo script to showcase the core functionality of the Pol.is math system. -""" - -import random -import time -from polismath.conversation import ConversationManager - -def main(): - # Create a conversation manager with local data storage - print("Creating conversation manager...") - manager = ConversationManager(data_dir="./data") - - # Create a new conversation - conv_id = f"demo-conversation-{int(time.time())}" - print(f"Creating conversation {conv_id}...") - manager.create_conversation(conv_id) - - # Generate synthetic votes with two distinct opinion groups - print("Generating synthetic votes with two distinct opinion groups...") - participants = [f"p{i}" for i in range(100)] - comments = [f"c{i}" for i in range(20)] - - votes = {"votes": []} - - for p_idx, pid in enumerate(participants): - # First group tends to agree with first half of comments - # Second group tends to agree with second half - group = 0 if p_idx < 50 else 1 - - for c_idx, cid in enumerate(comments): - # Determine tendency to agree based on group and comment - if (group == 0 and c_idx < 10) or (group == 1 and c_idx >= 10): - agree_prob = 0.8 # High probability of agreement - else: - agree_prob = 0.2 # Low probability of agreement - - # Randomly determine vote (1=agree, -1=disagree, None=pass) - r = random.random() - if r < agree_prob: - vote = 1 - elif r < agree_prob + 0.15: - vote = -1 - else: - continue # Skip this vote (pass) - - # Add vote - votes["votes"].append({ - "pid": pid, - "tid": cid, - "vote": vote - }) - - # Process all votes - print(f"Processing {len(votes['votes'])} votes...") - conv = manager.process_votes(conv_id, votes) - - # Get results - print("\nRESULTS:") - print(f"Participant count: {conv.participant_count}") - print(f"Comment count: {conv.comment_count}") - print(f"Group count: {len(conv.group_clusters)}") - - # Get top representative comments for each group - print("\nTOP REPRESENTATIVE COMMENTS BY GROUP:") - for group_id, comments in conv.repness["group_repness"].items(): - print(f"Group {group_id} top comments:") - for comment in comments[:3]: - print(f" - Comment {comment['comment_id']} ({comment['repful']})") - - # Print some clustering information - print("\nCLUSTERING INFORMATION:") - for cluster in conv.group_clusters: - print(f"Cluster {cluster['id']} has {len(cluster['members'])} participants") - - # Print PCA information - print("\nPCA INFORMATION:") - print(f"PCA variance explained: {conv.pca['variance_explained']}") - - # Export the conversation data - export_path = f"./data/{conv_id}_export.json" - print(f"\nExporting conversation data to {export_path}...") - manager.export_conversation(conv_id, export_path) - - print("\nDemo completed successfully!") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/delphi/docker-compose.yml.bak b/delphi/docker-compose.yml.bak deleted file mode 100644 index e04b62fb47..0000000000 --- a/delphi/docker-compose.yml.bak +++ /dev/null @@ -1,100 +0,0 @@ -services: - # NOTE: DynamoDB has been moved to the main docker-compose.yml - # If running this compose file standalone, uncomment the following section: - # - # dynamodb-local: - # image: amazon/dynamodb-local:latest - # container_name: delphi-dynamodb-local - # ports: - # - "8000:8000" - # command: "-jar DynamoDBLocal.jar -sharedDb -dbPath /home/dynamodblocal/data" - # volumes: - # - dynamodb-data:/home/dynamodblocal/data - # environment: - # - JAVA_OPTS=-Xmx1G - # networks: - # - delphi-network - # user: root - - # NOTE: We're using the Ollama service from the main docker-compose.yml - # This section is commented out to avoid duplicate Ollama instances - # If running this compose file standalone, uncomment this section: - # - # ollama: - # image: ollama/ollama:latest - # container_name: delphi-ollama - # ports: - # - "11434:11434" - # volumes: - # - ollama-models:/root/.ollama - # networks: - # - delphi-network - # command: serve - # restart: unless-stopped - - # Delphi Application - delphi-app: - build: - context: . - dockerfile: Dockerfile - container_name: delphi-app - # Note: When running with main docker-compose, the dependencies are handled there - # depends_on is commented out since we're not managing the Ollama service here - # depends_on: - # - ollama - env_file: - - ${DELPHI_ENV_FILE:-.env} - environment: - # PostgreSQL connection settings - - DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME} - - DATABASE_SSL_MODE=${DATABASE_SSL_MODE} - - DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE} - - DATABASE_MAX_OVERFLOW=${DATABASE_MAX_OVERFLOW} - # Set Python path explicitly - - PYTHONPATH=/app - # DynamoDB settings - point to shared main service - - DYNAMODB_ENDPOINT=http://host.docker.internal:8000 - - AWS_ACCESS_KEY_ID=dummy - - AWS_SECRET_ACCESS_KEY=dummy - - AWS_REGION=us-east-1 - # Ollama settings - configurable via environment variables - - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.1:8b} - - OLLAMA_HOST=http://host.docker.internal:11434 # Connect to Ollama on host network - - LLM_PROVIDER=${LLM_PROVIDER:-ollama} - ports: - - "8080:8080" - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - delphi-network - volumes: - - ./delphi_orchestrator.py:/app/delphi_orchestrator.py - - ./create_dynamodb_tables.py:/app/create_dynamodb_tables.py - - ./requirements.txt:/app/requirements.txt - # Mount polismath and other Python modules directory - - ./polismath:/app/polismath - # Ensure the renamed math module is properly mounted - - ./polismath/pca_kmeans_rep:/app/polismath/pca_kmeans_rep - # Mount umap_narrative directory - - ./umap_narrative:/app/umap_narrative - # Command that runs the setup_ollama script first - command: > - bash -c " - echo 'Ensuring dependencies are installed...' && - pip install --no-cache-dir fastapi==0.115.0 pydantic colorlog numpy pandas scipy scikit-learn && - echo 'PYTHONPATH=$PYTHONPATH' && - echo 'Installed packages:' && - pip list | grep fastapi && - echo 'Setting up Ollama model...' && - cd /app && chmod +x ./setup_ollama.sh && ./setup_ollama.sh && - echo 'Keeping container alive...' && - tail -f /dev/null - " - -volumes: - dynamodb-data: - ollama-models: - -networks: - delphi-network: - driver: bridge diff --git a/delphi/umap_narrative/702_CONSENSUS_DIVISIVE_README.md b/delphi/docs/702_CONSENSUS_DIVISIVE_README.md similarity index 100% rename from delphi/umap_narrative/702_CONSENSUS_DIVISIVE_README.md rename to delphi/docs/702_CONSENSUS_DIVISIVE_README.md diff --git a/delphi/BATCH_API_BUGFIX.md b/delphi/docs/BATCH_API_BUGFIX.md similarity index 100% rename from delphi/BATCH_API_BUGFIX.md rename to delphi/docs/BATCH_API_BUGFIX.md diff --git a/delphi/umap_narrative/BATCH_NARRATIVE_README.md b/delphi/docs/BATCH_NARRATIVE_README.md similarity index 100% rename from delphi/umap_narrative/BATCH_NARRATIVE_README.md rename to delphi/docs/BATCH_NARRATIVE_README.md diff --git a/delphi/DELPHI_DOCKER.md b/delphi/docs/DELPHI_DOCKER.md similarity index 100% rename from delphi/DELPHI_DOCKER.md rename to delphi/docs/DELPHI_DOCKER.md diff --git a/delphi/DOCKER.md b/delphi/docs/DOCKER.md similarity index 100% rename from delphi/DOCKER.md rename to delphi/docs/DOCKER.md diff --git a/delphi/docs/TOPIC_AGENDA_IMPLEMENTATION_SUMMARY.md b/delphi/docs/TOPIC_AGENDA_IMPLEMENTATION_SUMMARY.md index 76a27d2fd2..f2cceaf541 100644 --- a/delphi/docs/TOPIC_AGENDA_IMPLEMENTATION_SUMMARY.md +++ b/delphi/docs/TOPIC_AGENDA_IMPLEMENTATION_SUMMARY.md @@ -7,17 +7,25 @@ This document provides a concise summary of the proposed Topic Agenda storage im ## Key Design Decisions ### 1. Storage Strategy + - **Store archetypal comment IDs** instead of topic names/clusters - **Why**: Comment IDs are stable across Delphi runs, topics are not - **Result**: User selections persist even when topic modeling changes -### 2. DynamoDB Table Structure -- **Table Name**: `Delphi_TopicAgendaSelections` -- **Primary Key**: `conversation_id` (zid) -- **Sort Key**: `participant_id` (pid) -- **Why**: Follows existing Polis patterns, enables efficient per-user queries +### 2. PostgreSQL Table Structure + +- **Table Name**: `topic_agenda_selections` +- **Primary Key**: Composite key on (`zid`, `pid`) +- **Columns**: + - `archetypal_selections` (JSONB) - Array of selected topics + - `delphi_job_id` (TEXT) - ID of Delphi job that generated topics + - `total_selections` (INTEGER) - Count of selected topics + - `created_at`, `updated_at` (TIMESTAMP) - Automatic timestamps +- **Foreign Keys**: References to `conversations` and `participants` tables +- **Why**: Leverages existing PostgreSQL infrastructure, maintains referential integrity, supports complex queries ### 3. Data Stored Per Selection + ```json { "layer_id": 3, @@ -32,6 +40,7 @@ This document provides a concise summary of the proposed Topic Agenda storage im ``` ### 4. API Endpoints + - `POST /api/v3/topicAgenda/selections` - Save selections - `GET /api/v3/topicAgenda/selections?conversation_id={zid}` - Retrieve - `PUT /api/v3/topicAgenda/selections` - Update @@ -40,17 +49,20 @@ This document provides a concise summary of the proposed Topic Agenda storage im ## Implementation Steps ### Immediate (Phase 1) -1. Create DynamoDB table -2. Implement backend routes + +1. Run PostgreSQL migration (000012_create_topic_agenda_selections.sql) +2. Implement backend routes in `/server/src/routes/delphi/topicAgenda.ts` 3. Update TopicAgenda.jsx to save on "Done" click 4. Add retrieval on component mount ### Near-term (Phase 2) + 1. Add loading/error states 2. Implement overwrite confirmation 3. Add success feedback ### Future (Phase 3) + 1. Handle Delphi re-runs (spatial matching) 2. Add confidence scoring 3. Implement migration UI @@ -64,9 +76,9 @@ This document provides a concise summary of the proposed Topic Agenda storage im ## Next Immediate Actions -1. **Backend**: Create `/server/src/routes/delphi/topicAgenda.ts` -2. **Frontend**: Update `handleDone()` in TopicAgenda.jsx -3. **Database**: Create DynamoDB table via AWS console or CloudFormation +1. **Database**: Run migration `000012_create_topic_agenda_selections.sql` +2. **Backend**: Routes implemented in `/server/src/routes/delphi/topicAgenda.ts` ✅ +3. **Frontend**: Update `handleDone()` in TopicAgenda.jsx ## Code to Add to TopicAgenda.jsx @@ -85,7 +97,8 @@ const response = await fetch('/api/v3/topicAgenda/selections', { ## Success Criteria -- ✅ User clicks "Done" → selections saved to DynamoDB +- ✅ User clicks "Done" → selections saved to PostgreSQL - ✅ User returns → previous selections loaded - ✅ Delphi re-runs → selections still valid via comment IDs -- ✅ API responds in < 200ms \ No newline at end of file +- ✅ API responds in < 200ms +- ✅ Referential integrity maintained via foreign keys diff --git a/delphi/docs/TOPIC_AGENDA_STORAGE_DESIGN.md b/delphi/docs/TOPIC_AGENDA_STORAGE_DESIGN.md index e6c2c48966..920a19f1a6 100644 --- a/delphi/docs/TOPIC_AGENDA_STORAGE_DESIGN.md +++ b/delphi/docs/TOPIC_AGENDA_STORAGE_DESIGN.md @@ -12,46 +12,52 @@ This document outlines the design for storing user topic agenda selections as ar ## Data Model -### DynamoDB Table: `Delphi_TopicAgendaSelections` +### PostgreSQL Table: `topic_agenda_selections` + +**Table Design:** + +- **Primary Key**: Composite key on (`zid`, `pid`) +- **Foreign Keys**: References to `conversations(zid)` and `participants(zid, pid)` + +**Table Schema:** + +```sql +CREATE TABLE topic_agenda_selections ( + zid INTEGER NOT NULL, -- Conversation ID (foreign key) + pid INTEGER NOT NULL, -- Participant ID (foreign key) + archetypal_selections JSONB, -- Array of selection objects + delphi_job_id TEXT, -- Delphi job ID that generated the topics + total_selections INTEGER NOT NULL, -- Count of selected topics + created_at TIMESTAMP WITH TIME ZONE, -- Record creation time + updated_at TIMESTAMP WITH TIME ZONE, -- Last update time + PRIMARY KEY (zid, pid) +); +``` + +**JSONB Data Structure:** -**Primary Key Design:** -- **Partition Key**: `conversation_id` (string) - The zid of the conversation -- **Sort Key**: `participant_id` (string) - The pid of the participant +The `archetypal_selections` column stores an array of selection objects: -**Attributes:** ```json -{ - "conversation_id": "string", // zid as string - "participant_id": "string", // pid as string - - "archetypal_selections": [ - { - "layer_id": "number", // 0, 1, 2, 3, etc. - "cluster_id": "string", // The cluster within that layer - "topic_key": "string", // Original topic key for reference - "archetypal_comments": [ - { - "comment_id": "string", // Stable comment identifier - "comment_text": "string", // Cached for display - "coordinates": { - "x": "number", // UMAP x coordinate - "y": "number" // UMAP y coordinate - }, - "distance_to_centroid": "number" - } - ], - "selection_timestamp": "string" // ISO 8601 timestamp - } - ], - - "metadata": { - "job_id": "string", // Delphi job ID these selections are from - "created_at": "string", // ISO 8601 timestamp - "updated_at": "string", // ISO 8601 timestamp - "version": "number", // Schema version (start with 1) - "total_selections": "number" // Count of selected topics +[ + { + "layer_id": 3, // Layer number + "cluster_id": "9", // Cluster ID within layer + "topic_key": "layer3_9", // Original topic key + "archetypal_comments": [ + { + "comment_id": "123", // Stable comment ID + "comment_text": "...", // Cached text + "coordinates": { + "x": 1.23, // UMAP x coordinate + "y": 4.56 // UMAP y coordinate + }, + "distance_to_centroid": 0.15 + } + ], + "selection_timestamp": "2024-01-15T10:30:00Z" } -} +] ``` ## API Design @@ -61,12 +67,14 @@ This document outlines the design for storing user topic agenda selections as ar **Endpoint:** `POST /api/v3/topicAgenda/selections` **Request Headers:** + ``` Content-Type: application/json Cookie: [authentication cookie] ``` **Request Body:** + ```json { "conversation_id": "string", @@ -89,6 +97,7 @@ Cookie: [authentication cookie] ``` **Response:** + ```json { "status": "success", @@ -107,6 +116,7 @@ Cookie: [authentication cookie] **Endpoint:** `GET /api/v3/topicAgenda/selections?conversation_id={zid}` **Response:** + ```json { "status": "success", @@ -132,19 +142,22 @@ Same structure as POST, but replaces existing selections entirely. ## Implementation Plan ### Phase 1: Backend Infrastructure -1. Create DynamoDB table with specified schema -2. Implement data access layer in `/server/src/db/topicAgenda.ts` -3. Create API routes in `/server/src/routes/delphi/topicAgenda.ts` -4. Add authentication and authorization checks -5. Implement input validation + +1. Create PostgreSQL table via migration (000012_create_topic_agenda_selections.sql) +2. Implement API routes in `/server/src/routes/delphi/topicAgenda.ts` +3. Add authentication and authorization checks via existing middleware +4. Implement input validation +5. Use existing pgQuery module for database operations ### Phase 2: Frontend Integration + 1. Update `TopicAgenda.jsx` to call save API on "Done" click 2. Add loading states and error handling 3. Implement retrieval on component mount 4. Add confirmation UI for overwrites ### Phase 3: Cross-Run Persistence + 1. Implement comment matching algorithm for new Delphi runs 2. Create migration logic for when clusters change 3. Add fallback UI for missing comments @@ -156,60 +169,50 @@ Same structure as POST, but replaces existing selections entirely. ```typescript // /server/src/routes/delphi/topicAgenda.ts -import { Router } from 'express'; -import { DynamoDBDocumentClient, PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb'; -import { isAuthenticated } from '../../middleware/auth'; -import { getPidPromise } from '../../user'; -import Conversation from '../../conversation'; - -const router = Router(); -const TABLE_NAME = 'Delphi_TopicAgendaSelections'; - -router.post('/selections', isAuthenticated, async (req, res) => { +import { Response } from 'express'; +import pgQuery from '../../db/pg-query'; +import { RequestWithP } from '../../d'; + +export async function handle_POST_topicAgenda_selections( + req: RequestWithP, + res: Response +) { try { - const { conversation_id, selections } = req.body; - const uid = req.user.uid; - - // Convert conversation_id to zid - const zid = await Conversation.getZidFromConversationId(conversation_id); - const zidStr = zid.toString(); + const { selections } = req.body; - // Get participant ID - const pid = await getPidPromise(zidStr, uid); - const pidStr = pid.toString(); + // The middleware ensures we have a participant + const zid = req.p.zid!; + const pid = req.p.pid!; - // Get current Delphi job ID - const jobId = await getCurrentDelphiJobId(zidStr); + // Get current Delphi job ID (from DynamoDB job queue) + const jobId = await getCurrentDelphiJobId(zid.toString()); - // Prepare DynamoDB item - const item = { - conversation_id: zidStr, - participant_id: pidStr, - archetypal_selections: selections, - metadata: { - job_id: jobId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - version: 1, - total_selections: selections.length - } - }; + // Use UPSERT to handle both new and existing records + const query = ` + INSERT INTO topic_agenda_selections + (zid, pid, archetypal_selections, delphi_job_id, total_selections, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (zid, pid) + DO UPDATE SET + archetypal_selections = EXCLUDED.archetypal_selections, + delphi_job_id = EXCLUDED.delphi_job_id, + total_selections = EXCLUDED.total_selections, + updated_at = CURRENT_TIMESTAMP + RETURNING zid, pid, total_selections + `; - // Save to DynamoDB - const putParams = { - TableName: TABLE_NAME, - Item: item - }; - - await docClient.send(new PutCommand(putParams)); + const result = await pgQuery.queryP( + query, + [zid, pid, JSON.stringify(selections), jobId, selections.length] + ); res.json({ status: 'success', message: 'Topic agenda selections saved successfully', data: { - conversation_id: zidStr, - participant_id: pidStr, - selections_count: selections.length, + conversation_id: zid.toString(), + participant_id: pid.toString(), + selections_count: result[0]?.total_selections || selections.length, job_id: jobId } }); @@ -221,7 +224,7 @@ router.post('/selections', isAuthenticated, async (req, res) => { message: 'Failed to save topic agenda selections' }); } -}); +} ``` ### Frontend Integration @@ -316,4 +319,4 @@ When a new Delphi run creates different clusters: 1. **Persistence Rate**: % of selections that survive Delphi re-runs 2. **Accuracy**: % of correctly matched topics after re-runs 3. **Performance**: API response times < 200ms -4. **Adoption**: % of users who save their selections \ No newline at end of file +4. **Adoption**: % of users who save their selections diff --git a/delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC.md b/delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC.md new file mode 100644 index 0000000000..497197a6fc --- /dev/null +++ b/delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC.md @@ -0,0 +1,196 @@ +# Topic-Level Group Consensus Metric Design + +## Executive Summary + +This document defines a rigorous metric for measuring group consensus at the topic level, producing a value between 0 and 1 that meaningfully captures how much different opinion groups agree or disagree on a collection of comments within a topic. + +## Problem Statement + +Current group consensus metrics are calculated per-comment and then averaged, which fails to capture the holistic agreement patterns across a topic. We need a metric that: + +1. Measures inter-group agreement across ALL comments in a topic collectively +2. Produces interpretable values between 0 (complete disagreement) and 1 (complete agreement) +3. Handles varying numbers of groups (2-5 from k-means) +4. Accounts for both voting patterns AND voting participation +5. Is statistically robust and theoretically grounded + +## Proposed Metric: Inter-Group Alignment Score (IGAS) + +### Core Concept + +The Inter-Group Alignment Score measures the similarity of voting patterns between groups across all comments in a topic. It combines three key components: + +1. **Vote Pattern Similarity**: How similarly groups vote on comments +2. **Participation Alignment**: How similarly groups choose which comments to vote on +3. **Confidence Weighting**: Higher weight for comments with more votes + +### Mathematical Definition + +For a topic T with comments C = {c₁, c₂, ..., cₙ} and groups G = {g₁, g₂, ..., gₖ}: + +``` +IGAS(T) = Σᵢ<ⱼ w_ij × S(gᵢ, gⱼ) / (k choose 2) +``` + +Where: +- `w_ij` is the pairwise weight between groups i and j +- `S(gᵢ, gⱼ)` is the similarity score between groups i and j +- The sum is over all unique group pairs + +### Detailed Calculation + +#### Step 1: Build Group Voting Matrices + +For each group g, create a voting matrix V_g where: +- Rows represent comments in the topic +- Columns represent [agree_rate, disagree_rate, pass_rate, participation_rate] + +``` +V_g[c] = [ + agrees_c / total_votes_c, + disagrees_c / total_votes_c, + passes_c / total_votes_c, + voters_c / group_size +] +``` + +#### Step 2: Calculate Pairwise Similarity + +For each pair of groups (gᵢ, gⱼ), calculate similarity across all comments: + +```python +def calculate_similarity(V_i, V_j): + similarities = [] + weights = [] + + for c in comments: + # Skip if neither group voted on this comment + if V_i[c].participation == 0 and V_j[c].participation == 0: + continue + + # Calculate voting pattern similarity (cosine similarity on agree/disagree/pass) + vote_pattern_i = V_i[c][:3] # agree, disagree, pass rates + vote_pattern_j = V_j[c][:3] + + pattern_similarity = cosine_similarity(vote_pattern_i, vote_pattern_j) + + # Calculate participation similarity + participation_similarity = 1 - abs(V_i[c][3] - V_j[c][3]) + + # Combined similarity for this comment + comment_similarity = 0.8 * pattern_similarity + 0.2 * participation_similarity + + # Weight by total participation + weight = (V_i[c][3] + V_j[c][3]) / 2 + + similarities.append(comment_similarity) + weights.append(weight) + + # Weighted average similarity + if sum(weights) > 0: + return sum(s * w for s, w in zip(similarities, weights)) / sum(weights) + else: + return 0.5 # No overlap = neutral similarity +``` + +#### Step 3: Calculate Group Pair Weights + +Weight each group pair by their relative sizes and activity: + +```python +def calculate_pair_weight(g_i, g_j, total_participants): + size_weight = (len(g_i) + len(g_j)) / (2 * total_participants) + activity_weight = (g_i.vote_count + g_j.vote_count) / total_topic_votes + return (size_weight + activity_weight) / 2 +``` + +#### Step 4: Aggregate to Final Score + +```python +def calculate_IGAS(topic): + total_score = 0 + total_weight = 0 + + for i in range(num_groups): + for j in range(i+1, num_groups): + similarity = calculate_similarity(V[i], V[j]) + weight = calculate_pair_weight(groups[i], groups[j], total_participants) + + total_score += weight * similarity + total_weight += weight + + if total_weight > 0: + return total_score / total_weight + else: + return 0.5 # Default to neutral +``` + +### Interpretation + +- **0.0 - 0.2**: Strong disagreement between groups +- **0.2 - 0.4**: Moderate disagreement +- **0.4 - 0.6**: Mixed/neutral - some agreement, some disagreement +- **0.6 - 0.8**: Moderate agreement +- **0.8 - 1.0**: Strong agreement between groups + +### Key Properties + +1. **Symmetric**: S(gᵢ, gⱼ) = S(gⱼ, gᵢ) +2. **Bounded**: Always produces values in [0, 1] +3. **Weighted**: Accounts for group sizes and voting activity +4. **Robust**: Handles missing data and low participation gracefully +5. **Interpretable**: Linear scale from disagreement to agreement + +### Edge Cases + +1. **Single Group**: Return 1.0 (perfect consensus with self) +2. **No Votes**: Return 0.5 (neutral/unknown) +3. **Non-overlapping Comments**: Groups that vote on completely different comments get similarity based on the pattern of non-participation +4. **Sparse Voting**: Comments with very few votes get lower weight + +### Advantages Over Current Approach + +1. **Holistic**: Considers all comments together, not individually +2. **Nuanced**: Captures both what groups vote on AND how they vote +3. **Fair**: Weights by actual participation, not just group count +4. **Meaningful**: Produces interpretable values that don't depend on k-means group count + +### Implementation Notes + +1. Use NumPy for efficient matrix operations +2. Cache group voting matrices for performance +3. Consider using Jensen-Shannon divergence as alternative to cosine similarity +4. Add optional parameters for tweaking weights (vote pattern vs participation) + +### Alternative Formulations + +#### Option 2: Variance-Based Approach +``` +IGAS = 1 - (average_within_topic_variance / maximum_possible_variance) +``` + +#### Option 3: Entropy-Based Approach +``` +IGAS = 1 - (H(votes|group) / H_max) +``` + +Where H(votes|group) is the conditional entropy of votes given group membership. + +### Validation Strategy + +1. Test on known consensus topics (should score > 0.8) +2. Test on known divisive topics (should score < 0.3) +3. Compare with human judgments of topic consensus +4. Ensure stability across different k values (2-5 groups) + +## Recommendation + +Implement the Inter-Group Alignment Score (IGAS) as defined above. It provides a theoretically sound, practically useful metric that captures the nuanced reality of group agreement patterns within topics. The metric is robust to the k-means constraint while providing meaningful differentiation between topics with varying levels of inter-group consensus. + +### Next Steps + +1. Implement the IGAS calculation in the topicStats route +2. Store pre-calculated IGAS values in DynamoDB for performance +3. Add to the topicStats display with clear interpretation guidelines +4. Validate on real conversation data +5. Consider creating a simpler "consensus category" field (high/medium/low) based on IGAS thresholds \ No newline at end of file diff --git a/delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC_REVISED.md b/delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC_REVISED.md new file mode 100644 index 0000000000..ef5e941390 --- /dev/null +++ b/delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC_REVISED.md @@ -0,0 +1,295 @@ +# Topic-Level Group Consensus Metric - Revised Design + +## Executive Summary + +This document presents a statistically rigorous metric for measuring inter-group consensus at the topic level, incorporating critical improvements to handle exposure bias, missing data, and calibration. The revised Inter-Group Alignment Score (IGAS) produces interpretable values with uncertainty estimates and proper null model calibration. + +## Core Improvements from Critique + +1. **Separation of exposure from choice**: Distinguishes "didn't see" from "chose not to vote" +2. **Proper statistical foundations**: Jensen-Shannon divergence, Dirichlet smoothing, bootstrap CIs +3. **Calibrated baselines**: Null model via permutation testing instead of arbitrary thresholds +4. **Robust weighting**: Overlap-based weights that don't double-count or favor large groups +5. **Explicit handling of undefined cases**: NA instead of arbitrary defaults + +## Mathematical Framework + +### Data Model + +For each group g and comment c, we track: +- **A_{g,c}**: Number of agrees +- **D_{g,c}**: Number of disagrees +- **P_{g,c}**: Number of passes +- **E_{g,c}**: Number of participants exposed to comment +- **N_{g,c}** = A_{g,c} + D_{g,c} + P_{g,c}: Total votes + +### Smoothed Vote Distributions + +Apply Dirichlet smoothing with α = 0.5 (Jeffreys prior): + +``` +p̃_{g,c} = [A_{g,c} + α, D_{g,c} + α, P_{g,c} + α] / (N_{g,c} + 3α) +``` + +### Similarity Metric: Jensen-Shannon Divergence + +For groups i and j on comment c: +``` +s^out_{ij,c} = 1 - JSD(p̃_{i,c}, p̃_{j,c}) +``` + +Where JSD ∈ [0,1] is the Jensen-Shannon divergence. + +### Overlap Weighting + +Weight by voting overlap: +``` +w_{ij,c} = min(N_{i,c}, N_{j,c}) +``` + +Or alternatively: `w_{ij,c} = sqrt(N_{i,c} × N_{j,c})` + +### Per-Pair Metrics + +#### Outcome Consensus +Only include comments where both groups have sufficient data: +- E_{i,c} ≥ e_min (default: 10) +- E_{j,c} ≥ e_min +- N_{i,c} ≥ n_min (default: 5) +- N_{j,c} ≥ n_min + +``` +S^out_{ij} = Σ_c w_{ij,c} × s^out_{ij,c} / Σ_c w_{ij,c} +``` + +#### Attention Overlap +``` +S^att_{ij} = |{c: E_{i,c} ≥ e_min} ∩ {c: E_{j,c} ≥ e_min}| / + |{c: E_{i,c} ≥ e_min} ∪ {c: E_{j,c} ≥ e_min}| +``` + +#### Combined Score +``` +S_{ij} = β × S^out_{ij} + (1-β) × S^att_{ij} +``` +With β = 0.85 (outcome-focused but accounting for attention patterns) + +### Final Aggregation + +Weight pairs by their total overlap: +``` +W_{ij} = Σ_c w_{ij,c} +``` + +``` +IGAS = Σ_{i= e_min and E[j,c] >= e_min and + N[i,c] >= n_min and N[j,c] >= n_min): + + # Smooth vote distributions + p_i = dirichlet_smooth(A[i,c], D[i,c], P[i,c], alpha) + p_j = dirichlet_smooth(A[j,c], D[j,c], P[j,c], alpha) + + # Compute similarity + sim = 1 - jensen_shannon_divergence(p_i, p_j) + weight = min(N[i,c], N[j,c]) + + outcome_scores.append(sim) + outcome_weights.append(weight) + + # Aggregate outcome similarity + if sum(outcome_weights) > 0: + S_out = sum(s*w for s,w in zip(outcome_scores, outcome_weights)) / sum(outcome_weights) + coverage = len(outcome_scores) / len(comments) + else: + S_out = None + coverage = 0 + + # Compute attention overlap + exposed_i = {c for c in comments if E[i,c] >= e_min} + exposed_j = {c for c in comments if E[j,c] >= e_min} + S_att = len(exposed_i & exposed_j) / len(exposed_i | exposed_j) if exposed_i | exposed_j else 0 + + # Combine + if S_out is not None: + S_ij = beta * S_out + (1-beta) * S_att + else: + S_ij = S_att # Fallback to attention only + + pair_scores[(i,j)] = S_ij + pair_weights[(i,j)] = sum(outcome_weights) + + # Step 2: Aggregate across pairs + if sum(pair_weights.values()) > 0: + IGAS = sum(pair_scores[p] * pair_weights[p] for p in pair_scores) / sum(pair_weights.values()) + else: + return None, "Insufficient data for inter-group comparison" + + # Step 3: Calibration + null_scores = [] + for _ in range(200): + # Permute group labels within each comment + permuted_data = permute_group_labels(topic_data) + null_score = compute_IGAS_raw(permuted_data) + null_scores.append(null_score) + + E_null = np.mean(null_scores) + sd_null = np.std(null_scores) + z_score = (IGAS - E_null) / sd_null if sd_null > 0 else 0 + + # Step 4: Bootstrap CI + bootstrap_scores = [] + for _ in range(1000): + resampled_comments = resample_with_replacement(comments) + boot_score = compute_IGAS_raw(topic_data, resampled_comments) + bootstrap_scores.append(boot_score) + + ci_lower = np.percentile(bootstrap_scores, 2.5) + ci_upper = np.percentile(bootstrap_scores, 97.5) + + return { + 'IGAS': IGAS, + 'z_score': z_score, + 'ci_lower': ci_lower, + 'ci_upper': ci_upper, + 'null_mean': E_null, + 'null_sd': sd_null, + 'coverage': coverage, + 'pair_details': pair_scores + } +``` + +## Alternative Metrics (Sanity Checks) + +### 1. Intraclass Correlation Coefficient (ICC) +```python +def compute_ICC(topic_data): + # Convert to scalar opinion per group per comment + opinions = {} + for g in groups: + for c in comments: + if N[g,c] >= n_min: + opinions[g,c] = (A[g,c] - D[g,c]) / (A[g,c] + D[g,c] + 2*alpha) + + # Compute variance components + within_var = compute_within_group_variance(opinions) + total_var = compute_total_variance(opinions) + + ICC = 1 - (within_var / total_var) if total_var > 0 else 0 + return ICC +``` + +### 2. Normalized Mutual Information +```python +def compute_NMI_consensus(topic_data): + # Compute I(Vote; Group | Comment) + MI = mutual_information(votes, groups, given=comments) + H_max = entropy(votes, given=comments) + + # High MI = groups predict votes = low consensus + consensus = 1 - (MI / H_max) if H_max > 0 else 0.5 + return consensus +``` + +## Production Checklist + +- [ ] Implement Jensen-Shannon divergence with numerical stability +- [ ] Add exposure tracking to data pipeline +- [ ] Create efficient permutation test implementation +- [ ] Add caching for bootstrap/null computations +- [ ] Build monitoring for clustering stability +- [ ] Create API endpoint with full result structure +- [ ] Add frontend visualization of uncertainty +- [ ] Document pass vote handling options +- [ ] Validate against human-labeled consensus examples + +## API Response Format + +```json +{ + "topic_id": "layer0_cluster5", + "consensus": { + "IGAS": 0.743, + "confidence_interval": [0.712, 0.771], + "z_score": 2.34, + "interpretation": { + "percentile": 89.3, + "category": "high", + "description": "Higher consensus than 89% of random groupings" + }, + "coverage": { + "comments_included": 0.82, + "pairs_with_data": 1.0 + }, + "decomposition": { + "outcome_contribution": 0.631, + "attention_contribution": 0.112 + } + }, + "alternative_metrics": { + "ICC": 0.689, + "NMI_consensus": 0.701 + }, + "metadata": { + "num_groups": 4, + "computation_time_ms": 127, + "version": "2.0" + } +} +``` + +## Summary + +This revised metric addresses all major statistical concerns while remaining interpretable and actionable. The separation of exposure from choice, proper uncertainty quantification, and calibrated interpretation make this suitable for production use in ranking topics by genuine inter-group consensus rather than artifacts of data collection or arbitrary thresholds. \ No newline at end of file diff --git a/delphi/docs/TOPIC_GROUP_CONSENSUS_o3_stub.MD b/delphi/docs/TOPIC_GROUP_CONSENSUS_o3_stub.MD new file mode 100644 index 0000000000..01a72bc552 --- /dev/null +++ b/delphi/docs/TOPIC_GROUP_CONSENSUS_o3_stub.MD @@ -0,0 +1,982 @@ +Below is a rigorous, constructive critique aimed at making the metric identifiable, statistically sound, and production‑safe. I’ll flag issues, why they matter, and concrete fixes. + +--- + +## 0) High‑level take + +- **Good:** Topic‑level aggregation; pairwise group comparison; explicit handling of participation; confidence weighting intent; bounded [0,1]. +- **Main risks:** (a) exposure/selection bias is conflated with consensus, (b) several undefined or unstable cases (zero vectors, sparse overlap), (c) double‑counting participation in weights, (d) arbitrary thresholds/baseline of 0.5, (e) dependence on k and group sizing, (f) pass handling and rate normalization are ambiguous, (g) no variance/uncertainty or calibration to a null. + +--- + +## 1) Data model / definitions + +**1.1 Ambiguity in rate denominators** + +- You define `V_g[c] = [agrees_c / total_votes_c, ...]` while `V_g` is per‑group. That reads like you’re dividing a group’s agrees by **topic‑wide** `total_votes_c`. + **Fix:** Within‑group per comment: + p^g,c=[Ag,cNg,c,Dg,cNg,c,Pg,cNg,c]\hat p*{g,c} = \big[\tfrac{A*{g,c}}{N*{g,c}}, \tfrac{D*{g,c}}{N*{g,c}}, \tfrac{P*{g,c}}{N\_{g,c}}\big] + with Ng,c=Ag,c+Dg,c+Pg,cN*{g,c}=A*{g,c}+D*{g,c}+P*{g,c}. If Ng,c=0N*{g,c}=0, treat p^g,c\hat p*{g,c} as **undefined** (not a zero vector). + +**1.2 Distinguish exposure, pass, and non‑exposure** + +- Participation rate as `voters_c / group_size` mixes _saw but skipped (pass)_ with _not served / not reached_. That’s a core confound. + - **Exposure**: number served Eg,cE\_{g,c} + - **Vote**: number acted Ng,cN\_{g,c} + - **Pass** is a _vote outcome_, not “non‑participation”. + **Fix:** Track Eg,cE\_{g,c} separately (from logs). Then: + - Participation **propensity** πg,c=Ng,c/Eg,c\pi*{g,c} = N*{g,c}/E\_{g,c} + - Group coverage γg,c=Eg,c/∣g∣ \gamma*{g,c} = E*{g,c}/|g| + Use π\pi (behavior given exposure) for “participation alignment” and γ\gamma for overlap weighting. Do **not** treat “not exposed” as a vote state. + +**1.3 Smoothing** + +- With low Ng,cN\_{g,c}, rate vectors are noisy and induce instability. + **Fix:** Dirichlet smoothing: + p~g,c=[Ag,c+α,  Dg,c+α,  Pg,c+α]Ng,c+3α\tilde p*{g,c} = \frac{[A*{g,c}+\alpha,\; D*{g,c}+\alpha,\; P*{g,c}+\alpha]}{N\_{g,c}+3\alpha} + with α∈[0.5,1]\alpha \in [0.5,1] (Jeffreys/Laplace). + +--- + +## 2) Similarity choice + +**2.1 Cosine on [0,1]3[0,1]^3 with varying magnitudes** + +- If you (correctly) normalize within group, magnitudes are equal and cosine collapses to a function of angle; with non‑normalized numbers it accidentally mixes volume with pattern. +- Cosine with a zero vector (no votes) is undefined; your code will see `[0,0,0]` and explode or silently bias. + +**Better:** compare **distributions** with **Jensen–Shannon similarity** or **Hellinger**. + +- JSD (base‑2) distance JSD(p,q)∈[0,1]\mathrm{JSD}(p,q)\in[0,1]; similarity S=1−JSDS=1-\mathrm{JSD}. +- Hellinger H2(p,q)=12∑(pi−qi)2H^2(p,q)=\tfrac12\sum(\sqrt{p_i}-\sqrt{q_i})^2; similarity S=1−HS=1-H. + +**2.2 Separate outcome vs attention** + +- You blend pattern similarity (outcomes) with participation alignment inside each comment and again weight by participation, which double‑counts. + **Fix:** Compute two **separate** scores: + - **Outcome consensus** SijoutcomeS^\text{outcome}_{ij}: average (1–JSD) over comments where **both** groups have adequate exposure and non‑trivial votes (define thresholds), weighted by \_overlap_ (see §3). + - **Attention overlap** SijattentionS^\text{attention}_{ij}: similarity of which comments groups actually engaged with (based on exposure sets or engagement sets). Use Jaccard or Overlap coefficient on \_exposed_ sets, not votes. + - Combine at the **pair** level with a tunable β\beta: + Sij=βSijoutcome+(1−β)SijattentionS*{ij} = \beta S^\text{outcome}*{ij} + (1-\beta) S^\text{attention}\_{ij}. Start with β=0.85\beta=0.85. + +--- + +## 3) Weighting scheme + +**3.1 Per‑comment weights** + +- Current: weight by average **participation rate**. This penalizes low‑coverage comments even if both groups who _did_ see them agreed strongly. + **Fix:** Weight by **overlap of _votes_** (or exposure): + - wc,ij=min⁡(Ngi,c,Ngj,c)w*{c,ij} = \min(N*{g*i,c}, N*{g_j,c}) (harmonic‑ish, rewards overlap) + - Or wc,ij=Ngi,c⋅Ngj,cw*{c,ij} = \sqrt{N*{g*i,c} \cdot N*{g_j,c}} + - Optional cap or log⁡(1+n)\log(1+n) to avoid a small number of mega‑comments dominating. + +**3.2 Pair weights** + +- Current: (size_weight+activity_weight)/2(\text{size_weight}+\text{activity_weight})/2. Size and activity are collinear → **double‑counts** big groups and entrenches majority. + **Fix options:** + - **Uniform over pairs** (simple, avoids bias). + - Or wij∝∣gi∣⋅∣gj∣w\_{ij} \propto \sqrt{|g_i|\cdot |g_j|} normalized across pairs—less skew. + - If you want activity‑aware weighting, use **observed overlap volume** Wij=∑cwc,ijW*{ij}=\sum_c w*{c,ij} and normalize across pairs. That already reflects size+activity without hand‑tuned formulae. + +**3.3 Deduplication** + +- Duplicated/near‑duplicate comments can overweight a theme. + **Fix:** cluster near‑duplicates and distribute total weight equally across a cluster (or give each comment a cluster‑size weight of 1/m1/m). + +--- + +## 4) Handling missing / asymmetric information + +**4.1 Non‑overlap** + +- Your code computes cosine with a zero vector when one group has no votes on c. That’s undefined/garbage. + **Fix:** For outcome similarity, **only** include comments where both Ngi,c,Ngj,c≥nmin⁡N*{g_i,c},N*{g*j,c}\ge n*{\min} (e.g., ≥5 \ge 5) and Egi,c,Egj,c≥emin⁡E*{g_i,c},E*{g*j,c}\ge e*{\min}. Else exclude from SoutcomeS^\text{outcome}; their effect is captured in **attention overlap**. + +**4.2 Defaulting to 0.5** + +- Using 0.5 as a “neutral” filler drifts the metric upward under sparsity. + **Fix:** Don’t impute; compute a **calibrated baseline** (§6) and leave missing pairs/comments out of the average. Report coverage (% of comments included). + +--- + +## 5) k, clustering stability, and invariances + +**5.1 Dependence on k** + +- Averaging over (k2)\binom{k}{2} pairs isn’t stable in k: adding a small fringe group adds k−1 low‑overlap pairs and pulls the mean down (or up) unpredictably. + **Fix:** Report IGAS **with** a coverage table by pairs and the distribution; or aggregate pair similarities with weights WijW\_{ij} (overlap volume), which naturally downweights tiny fringe pairs. Also report **sensitivity across k** (2…5). + +**5.2 Cluster instability** + +- If k‑means labelings change across runs, IGAS will shift. + **Fix:** Lock seeds and/or use consensus clustering; or compute **label‑free** alternatives in parallel (see Option 2/3 below) as a cross‑check. + +--- + +## 6) Calibration, uncertainty, and thresholds + +**6.1 Null model / baseline** + +- Thresholds (0.2/0.4/0.6/0.8) are arbitrary. Also “neutral=0.5” is unjustified. + **Fix:** Build a **null distribution** per topic by **permuting group labels** within each comment (or matching on exposure) and recomputing IGAS BB times (e.g., 200). + - Report **z‑score** or **p‑value**, and **calibrated score** IGAS∗=Enull[S]\mathrm{IGAS}^\* = \mathbb{E}\_\text{null}[S] subtracted/normalized. + - Set category thresholds by **quantiles of the null** (topic‑specific) or via ROC against human judgments. + +**6.2 Uncertainty** + +- Provide **bootstrap CIs** over comments (and optionally users). Display ±1 s.e. next to the number. + +--- + +## 7) “Pass” semantics + +**7.1 Pass conflation** + +- “Pass” can mean “unsure”, “decline”, or UI default; it’s not symmetrical with agree/disagree and is highly UI‑sensitive. + **Fix options:** + - Treat pass as a **separate channel** (as you do) but run a **sensitivity** with pass **removed** (renormalize to agree/disagree) and report both. + - Or map to a scalar opinion: sg,c=Ag,c−Dg,cAg,c+Dg,cs*{g,c} = \frac{A*{g,c}-D*{g,c}}{A*{g,c}+D\_{g,c}} with smoothing; handle pass only in exposure/attention, not in outcome. + +--- + +## 8) Simpler, theory‑tighter alternatives (keep as checks) + +**8.1 Variance / ICC (Option 2, formalized)** + +- Treat each comment’s group‑level scalar sg,cs\_{g,c} (e.g., agree–disagree) and compute an **intra‑class correlation** across groups: + ICC=1−within‑group vartotal var\mathrm{ICC} = 1 - \frac{\text{within‑group var}}{\text{total var}} + Aggregate over comments with overlap weights. This directly measures fraction of variance attributable to **group**. Bounded [0,1], interpretable, label‑free. + +**8.2 Information‑theoretic (Option 3, formalized)** + +- Across all (group, comment, vote) triples, compute mutual information I(Vote;Group∣Comment)I(\text{Vote}; \text{Group}\mid \text{Comment}) and normalize (e.g., by H(Vote∣Comment)H(\text{Vote}|\text{Comment})). Use Miller–Madow or Bayesian correction for small counts. High MI ⇒ group identity strongly predicts vote ⇒ **low consensus**; flip and rescale to [0,1]. This is robust to k. + +**8.3 Cohen’s κ / Krippendorff’s α generalization** + +- View groups as “raters”, votes as categories, and compute average pairwise κ across comments with exposure‑overlap weighting. Known, interpretable agreement coefficient. + +I’d **ship IGAS** with JSD + overlap weights but compute **ICC** (8.1) and **MI‑based** (8.2) in parallel as guardrails and for sanity dashboards. + +--- + +## 9) Revised IGAS (drop‑in) + +**Inputs per (g,c):** Ag,c,Dg,c,Pg,c,Eg,cA*{g,c},D*{g,c},P*{g,c},E*{g,c}; choose α=0.5\alpha=0.5; thresholds nmin⁡=5n*{\min}=5, emin⁡=10e*{\min}=10. + +**Per‑comment outcome similarity for pair (i,j):** + +1. If Ei,c ⁣≥ ⁣emin⁡E*{i,c}\!\ge\!e*{\min}, Ej,c ⁣≥ ⁣emin⁡E*{j,c}\!\ge\!e*{\min}, Ni,c,Nj,c ⁣≥ ⁣nmin⁡N*{i,c},N*{j,c}\!\ge\!n*{\min}: + p~g,c=Dirichlet_smooth(A,D,P;α)\tilde p*{g,c} = \text{Dirichlet_smooth}(A,D,P;\alpha) + sij,cout=1−JSD(p~i,c,p~j,c)s^\text{out}_{ij,c} = 1 - \mathrm{JSD}(\tilde p_{i,c}, \tilde p*{j,c}) + wij,c=min⁡(Ni,c,Nj,c)w*{ij,c} = \min(N*{i,c}, N*{j,c}) (or NiNj\sqrt{N_i N_j}, capped) +2. Else: skip cc for outcome. + +**Per‑pair outcome:** + +Sijout=∑cwij,c sij,cout∑cwij,cwith coverage reported.S^\text{out}_{ij} = \frac{\sum_c w_{ij,c}\, s^\text{out}_{ij,c}}{\sum_c w_{ij,c}}\quad \text{with coverage reported.} + +**Attention overlap (pair‑level):** + +- Using exposures: Sijatt=∣{c:Ei,c ⁣≥emin⁡}∩{c:Ej,c ⁣≥emin⁡}∣∣{c:Ei,c ⁣≥emin⁡}∪{c:Ej,c ⁣≥emin⁡}∣S^\text{att}_{ij} = \frac{| \{c : E_{i,c}\!\ge e*{\min}\}\cap \{c : E*{j,c}\!\ge e*{\min}\}|}{|\{c: E*{i,c}\!\ge e*{\min}\}\cup \{c : E*{j,c}\!\ge e\_{\min}\}|}. +- Optionally replace with an engagement‑overlap analog. + +**Combine per pair:** + +Sij=βSijout+(1−β)Sijatt,β≈0.85.S*{ij} = \beta S^\text{out}*{ij} + (1-\beta) S^\text{att}\_{ij},\quad \beta\approx 0.85. + +**Aggregate over pairs:** + +- Pair weight Wij=∑cwij,cW*{ij} = \sum_c w*{ij,c} (overlap mass). + +IGAS=∑i>; // counts[g][c] +} + +export interface IGASOptions { + alpha?: number; // Dirichlet smoothing for (A,D,P). default 0.5 + nMin?: number; // min votes for outcome inclusion per (g,c). default 5 + eMin?: number; // min exposures for attention/outcome inclusion. default 10 + betaOutcome?: number; // weight on outcome vs attention at pair-level. default 0.85 + capCommentWeight?: number | null; // optional cap on per-comment overlap weight + useSqrtWeight?: boolean; // if true use sqrt(N_i N_j) else min(N_i, N_j). default true + excludePass?: boolean; // if true, drop P and renormalize outcome to A/D only. default false + nullPermutations?: number; // B permutations for null. default 200 (0 to disable) + bootstrapSamples?: number; // B bootstraps for CI. default 300 (0 to disable) + seed?: number; // deterministic PRNG seed for permutations/bootstraps + noExposure?: boolean; // if true, treat E == N everywhere (degrades attention overlap) +} + +export interface PairBreakdown { + i: GroupId; + j: GroupId; + outcomeSimilarity: number | null; // null when undefined + attentionSimilarity: number | null; // null when undefined + combinedSimilarity: number | null; + overlapWeight: number; // W_ij = sum_c w_{ij,c} + outcomeCoverage: number; // fraction of comments where outcome defined for this pair +} + +export interface IGASResult { + igas: number | null; // null if undefined (e.g., <2 groups or zero coverage) + igasOutcome: number | null; // weighted outcome-only aggregate (for decomposition) + igasAttention: number | null; // weighted attention-only aggregate + pairwise: PairBreakdown[]; + coverage: { + pairsWithOutcome: number; // count of pairs with any outcome coverage + pairsTotal: number; + commentsTotal: number; + }; + calibration?: { + nullMean: number; + nullSd: number; + zScore: number | null; // null if sd=0 or igas null + }; + ci95?: { + low: number | null; + high: number | null; + }; + params: Required; +} + +// ---- utility PRNG (mulberry32) for deterministic resampling ---- +function mulberry32(a: number) { + return function () { + let t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function choice(rand: () => number, arr: T[]): T { + return arr[Math.floor(rand() * arr.length)]; +} + +// ---- math helpers ---- + +function clamp01(x: number) { + return Math.max(0, Math.min(1, x)); +} + +// Dirichlet smoothing; optionally exclude Pass +function smoothProbs( + A: number, + D: number, + P: number, + alpha: number, + excludePass: boolean +): [number, number, number] { + if (excludePass) { + const a = A + alpha; + const d = D + alpha; + const denom = a + d; + return denom > 0 ? [a / denom, d / denom, 0] : [0.5, 0.5, 0]; + } + const a = A + alpha; + const d = D + alpha; + const p = P + alpha; + const denom = a + d + p; + return denom > 0 ? [a / denom, d / denom, p / denom] : [1 / 3, 1 / 3, 1 / 3]; +} + +// Jensen–Shannon distance (base 2); returns in [0,1] +function jsd(p: number[], q: number[]): number { + // Clamp and renormalize small numerical errors + const renorm = (v: number[]) => { + const vv = v.map((x) => Math.max(1e-12, x)); + const s = vv.reduce((a, b) => a + b, 0); + return vv.map((x) => x / s); + }; + const P = renorm(p); + const Q = renorm(q); + const M = P.map((x, i) => 0.5 * (x + Q[i])); + const H = (r: number[]) => -r.reduce((acc, x) => acc + x * Math.log2(x), 0); + const d = H(M) - 0.5 * H(P) - 0.5 * H(Q); + // Bounded in [0,1]; numerical guard: + return clamp01(d); +} + +function oneMinusJSD(p: number[], q: number[]): number { + return 1 - jsd(p, q); +} + +// Overlap weight per comment for pair (i,j) +function overlapWeight( + Ni: number, + Nj: number, + useSqrt: boolean, + cap: number | null +): number { + let w = useSqrt ? Math.sqrt(Ni * Nj) : Math.min(Ni, Nj); + if (cap !== null) w = Math.min(w, cap); + return w; +} + +// Jaccard similarity of sets +function jaccard(A: Set, B: Set): number | null { + if (A.size === 0 && B.size === 0) return null; + const interSize = [...A].filter((x) => B.has(x)).length; + const unionSize = new Set([...A, ...B]).size; + if (unionSize === 0) return null; + return interSize / unionSize; +} + +// ---- core computation ---- + +export function computeIGAS( + inputs: IGASInputs, + opts?: IGASOptions +): IGASResult { + const options: Required = { + alpha: opts?.alpha ?? 0.5, + nMin: opts?.nMin ?? 5, + eMin: opts?.eMin ?? 10, + betaOutcome: opts?.betaOutcome ?? 0.85, + capCommentWeight: opts?.capCommentWeight ?? null, + useSqrtWeight: opts?.useSqrtWeight ?? true, + excludePass: opts?.excludePass ?? false, + nullPermutations: opts?.nullPermutations ?? 200, + bootstrapSamples: opts?.bootstrapSamples ?? 300, + seed: opts?.seed ?? 1337, + noExposure: opts?.noExposure ?? false, + }; + + const { groups, comments, counts } = inputs; + const k = groups.length; + if (k < 2 || comments.length === 0) { + return { + igas: null, + igasOutcome: null, + igasAttention: null, + pairwise: [], + coverage: { + pairsWithOutcome: 0, + pairsTotal: 0, + commentsTotal: comments.length, + }, + params: options, + }; + } + + // Precompute per-(g,c) N and smoothed probs + const N: Record> = {}; + const E: Record> = {}; + const Psm: Record> = {}; + + for (const g of groups) { + N[g.id] = {}; + E[g.id] = {}; + Psm[g.id] = {}; + for (const c of comments) { + const vc = counts[g.id]?.[c.id]; + const A = vc?.A ?? 0; + const D = vc?.D ?? 0; + const P = vc?.P ?? 0; + const n = A + D + P; + N[g.id][c.id] = n; + const e = options.noExposure ? n : vc?.E ?? 0; + E[g.id][c.id] = e; + if (n > 0) { + Psm[g.id][c.id] = smoothProbs( + A, + D, + P, + options.alpha, + options.excludePass + ); + } else { + Psm[g.id][c.id] = []; // undefined marker + } + } + } + + // Build exposure sets for attention overlap + const exposureSet: Record> = {}; + for (const g of groups) { + exposureSet[g.id] = new Set(); + for (const c of comments) { + if (E[g.id][c.id] >= options.eMin) exposureSet[g.id].add(c.id); + } + } + + // Pairwise computation + const pairwise: PairBreakdown[] = []; + let sumWeightedCombined = 0; + let sumW = 0; + let sumWeightedOutcome = 0; + let sumWeightedAttention = 0; + let pairsWithOutcome = 0; + + for (let a = 0; a < k; a++) { + for (let b = a + 1; b < k; b++) { + const gi = groups[a].id; + const gj = groups[b].id; + + // Outcome similarity aggregated over comments with overlap and thresholds + let numOutcome = 0; + let denomOutcome = 0; + let weightedOutcomeSum = 0; + + for (const c of comments) { + const eOK = E[gi][c.id] >= options.eMin && E[gj][c.id] >= options.eMin; + const nOK = N[gi][c.id] >= options.nMin && N[gj][c.id] >= options.nMin; + const pi = Psm[gi][c.id]; + const pj = Psm[gj][c.id]; + if (!eOK || !nOK || pi.length === 0 || pj.length === 0) continue; + + const s = oneMinusJSD(pi as number[], pj as number[]); + const w = overlapWeight( + N[gi][c.id], + N[gj][c.id], + options.useSqrtWeight, + options.capCommentWeight + ); + weightedOutcomeSum += w * s; + denomOutcome += w; + numOutcome++; + } + + const outcomeSimilarity = + denomOutcome > 0 ? weightedOutcomeSum / denomOutcome : null; + if (outcomeSimilarity !== null) pairsWithOutcome++; + + // Attention similarity via exposure sets Jaccard + const att = jaccard(exposureSet[gi], exposureSet[gj]); // may be null if both empty + + // Combine per pair + let combined: number | null = null; + if (outcomeSimilarity !== null && att !== null) { + combined = + options.betaOutcome * outcomeSimilarity + + (1 - options.betaOutcome) * att; + } else if (outcomeSimilarity !== null) { + combined = outcomeSimilarity; // fall back to outcome only + } else if (att !== null) { + combined = att; // fall back to attention only + } + + // Pair overlap weight W_ij = sum of per-comment overlap weights actually used in outcome + const Wij = denomOutcome; // already sum of w_{ij,c} for included comments + if (combined !== null && Wij > 0) { + sumWeightedCombined += Wij * combined; + sumW += Wij; + } + if (outcomeSimilarity !== null && Wij > 0) { + sumWeightedOutcome += Wij * outcomeSimilarity; + } + if (att !== null && Wij > 0) { + sumWeightedAttention += Wij * att; + } + + pairwise.push({ + i: gi, + j: gj, + outcomeSimilarity, + attentionSimilarity: att, + combinedSimilarity: combined, + overlapWeight: Wij, + outcomeCoverage: numOutcome / Math.max(1, comments.length), + }); + } + } + + const igas = sumW > 0 ? sumWeightedCombined / sumW : null; + const igasOutcome = sumW > 0 ? sumWeightedOutcome / sumW : null; + const igasAttention = sumW > 0 ? sumWeightedAttention / sumW : null; + + const base: IGASResult = { + igas, + igasOutcome, + igasAttention, + pairwise, + coverage: { + pairsWithOutcome, + pairsTotal: (k * (k - 1)) / 2, + commentsTotal: comments.length, + }, + params: options, + }; + + // If calibration/CI disabled or IGAS undefined, return early + if ( + igas === null || + (options.nullPermutations <= 0 && options.bootstrapSamples <= 0) + ) { + return base; + } + + const rand = mulberry32(options.seed); + + // --- Null calibration: permute group labels within each comment --- + let nullSum = 0; + let nullSqSum = 0; + let bEff = 0; + + const groupIds = groups.map((g) => g.id); + const commentIds = comments.map((c) => c.id); + + const recomputeIGASFromCounts = ( + countsAlt: IGASInputs["counts"] + ): number | null => { + const alt = computeIGAS( + { groups, comments, counts: countsAlt }, + { + ...options, + nullPermutations: 0, + bootstrapSamples: 0, + seed: options.seed, + } + ); + return alt.igas; + }; + + for (let b = 0; b < options.nullPermutations; b++) { + // Permute labels independently per comment, preserving per-comment count multiset + const permCounts: IGASInputs["counts"] = {}; + for (const gid of groupIds) permCounts[gid] = {}; + for (const cId of commentIds) { + // Build list of (gid -> counts) for this comment + const perComment = groupIds.map((gid) => ({ + gid, + vc: counts[gid]?.[cId] ?? { + A: 0, + D: 0, + P: 0, + E: options.noExposure ? 0 : 0, + }, + })); + // Randomly permute assignment of these vc blobs to group ids + const shuffled = [...perComment]; + // Fisher–Yates + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + // Assign + for (let i = 0; i < groupIds.length; i++) { + const gid = groupIds[i]; + const vc = shuffled[i].vc; + // exposure: preserve E per source cell to keep marginal exposure distribution + const Eval = options.noExposure ? vc.A + vc.D + vc.P : vc.E ?? 0; + permCounts[gid][cId] = { A: vc.A, D: vc.D, P: vc.P, E: Eval }; + } + } + const s = recomputeIGASFromCounts(permCounts); + if (s !== null && Number.isFinite(s)) { + nullSum += s; + nullSqSum += s * s; + bEff++; + } + } + + let calibration: IGASResult["calibration"] | undefined; + if (bEff > 1 && igas !== null) { + const mean = nullSum / bEff; + const var_ = Math.max(1e-12, nullSqSum / bEff - mean * mean); + const sd = Math.sqrt(var_); + const z = sd > 0 ? (igas - mean) / sd : null; + calibration = { nullMean: mean, nullSd: sd, zScore: z }; + } + + // --- Bootstrap CI over comments --- + let ci95: IGASResult["ci95"] | undefined; + if (options.bootstrapSamples > 0) { + const draws: number[] = []; + for (let b = 0; b < options.bootstrapSamples; b++) { + // Sample comments with replacement + const resampledComments = comments.map(() => choice(rand, comments)); + // Build a projected counts object on the resampled comment multiset by aggregating + const projectedCounts: IGASInputs["counts"] = {}; + for (const g of groups) projectedCounts[g.id] = {}; + // Aggregate duplicates + for (const c of resampledComments) { + for (const g of groups) { + const vc = counts[g.id]?.[c.id] ?? { + A: 0, + D: 0, + P: 0, + E: options.noExposure ? 0 : 0, + }; + const key = c.id; // we can just reuse IDs; computeIGAS iterates over provided comments array; we will give it the resampled array + } + } + // Instead of aggregating, simpler: call computeIGAS with the resampled comment array; + // we need a fast path that accepts repeated comment IDs. We'll pass a synthetic comments array with duplicates. + const igasB = computeIGAS( + { groups, comments: resampledComments, counts }, + { + ...options, + nullPermutations: 0, + bootstrapSamples: 0, + seed: options.seed, + } + ).igas; + if (igasB !== null && Number.isFinite(igasB)) draws.push(igasB); + } + if (draws.length > 10) { + draws.sort((a, b) => a - b); + const q = (p: number) => { + const idx = Math.floor(p * (draws.length - 1)); + return draws[idx]; + }; + ci95 = { low: q(0.025), high: q(0.975) }; + } else { + ci95 = { low: null, high: null }; + } + } + + return { ...base, calibration, ci95 }; +} +``` + +--- + +## 2) Wire into your route + +**`src/routes/topicStats.ts` (patched)** + +```diff +--- a/src/routes/topicStats.ts ++++ b/src/routes/topicStats.ts +@@ -1,10 +1,19 @@ + import { Router } from "express"; + import { getTopicById } from "../services/topics"; + import { getGroupsForTopic } from "../services/groups"; + import { getTopicCommentStats } from "../services/comments"; ++import { computeIGAS } from "../metrics/igas"; ++import { putTopicMetric } from "../services/metricsStore"; // DynamoDB upsert (add this service) + + const router = Router(); + + router.get("/:topicId/stats", async (req, res, next) => { + try { + const topicId = req.params.topicId; + const topic = await getTopicById(topicId); + if (!topic) return res.status(404).json({ error: "not_found" }); + +- const [groups, commentStats] = await Promise.all([ ++ const [groups, commentStats] = await Promise.all([ + getGroupsForTopic(topicId), // returns [{ id, size }, ...] + getTopicCommentStats(topicId) // returns per (group, comment): A,D,P,E (E optional) + ]); +@@ -20,6 +29,73 @@ router.get("/:topicId/stats", async (req, res, next) => { + // ... your existing stats assembly ... + ++ // ---- Build IGAS inputs ---- ++ const groupInfos = groups.map(g => ({ id: String(g.id), size: Number(g.size || 0) })); ++ const commentInfos = commentStats.comments.map((c: any) => ({ id: String(c.id) })); ++ ++ // commentStats.counts expected shape: ++ // { [groupId]: { [commentId]: { A, D, P, E? } } } ++ // If your shape is different, map it here. ++ const counts: any = {}; ++ for (const g of groupInfos) counts[g.id] = {}; ++ for (const row of commentStats.rows) { ++ const gid = String(row.groupId); ++ const cid = String(row.commentId); ++ const A = Number(row.agree || 0); ++ const D = Number(row.disagree || 0); ++ const P = Number(row.pass || 0); ++ const E = row.exposure != null ? Number(row.exposure) : (A + D + P); ++ if (!counts[gid]) counts[gid] = {}; ++ counts[gid][cid] = { A, D, P, E }; ++ } ++ ++ // ---- Compute IGAS ---- ++ const igas = computeIGAS( ++ { groups: groupInfos, comments: commentInfos, counts }, ++ { ++ alpha: 0.5, ++ nMin: 5, ++ eMin: 10, ++ betaOutcome: 0.85, ++ capCommentWeight: 500, // avoid domination by mega-threads; tune if needed ++ useSqrtWeight: true, ++ excludePass: false, // expose toggle in query if you like ++ nullPermutations: 200, // set 0 to disable in hot path ++ bootstrapSamples: 300, // set 0 to disable or gate behind ?withCI=1 ++ seed: 1337, ++ noExposure: !commentStats.hasExposure // true if you don't log exposure yet ++ } ++ ); ++ ++ // Persist summary (for dashboard & trendlines) ++ try { ++ await putTopicMetric(topicId, "IGAS", { ++ ts: Date.now(), ++ igas: igas.igas, ++ igasOutcome: igas.igasOutcome, ++ igasAttention: igas.igasAttention, ++ z: igas.calibration?.zScore ?? null, ++ nullMean: igas.calibration?.nullMean ?? null, ++ nullSd: igas.calibration?.nullSd ?? null, ++ ciLow: igas.ci95?.low ?? null, ++ ciHigh: igas.ci95?.high ?? null, ++ pairsWithOutcome: igas.coverage.pairsWithOutcome, ++ pairsTotal: igas.coverage.pairsTotal, ++ commentsTotal: igas.coverage.commentsTotal, ++ params: igas.params ++ }); ++ } catch (e) { ++ // non-fatal ++ req.log?.warn({ err: e }, "putTopicMetric(IGAS) failed"); ++ } ++ ++ // Include in response ++ const response = { ++ .../* your existing payload */, ++ igas: { ++ value: igas.igas, ++ outcome: igas.igasOutcome, ++ attention: igas.igasAttention, ++ z: igas.calibration?.zScore ?? null, ++ ci95: igas.ci95 ?? null, ++ coverage: igas.coverage, ++ pairwise: igas.pairwise ++ } ++ }; ++ +- return res.json(/* existing payload */); ++ return res.json(response); + } catch (err) { + next(err); + } + }); +``` + +--- + +## 3) DynamoDB upsert (lightweight service) + +**`src/services/metricsStore.ts` (new)** + +```ts +// src/services/metricsStore.ts +import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb"; + +const client = new DynamoDBClient({}); + +const TABLE = process.env.METRICS_TABLE || "polis_metrics"; + +export async function putTopicMetric( + topicId: string, + metric: string, + payload: any +): Promise { + const item = { + PK: { S: `TOPIC#${topicId}` }, + SK: { S: `METRIC#${metric}#${payload.ts}` }, + Metric: { S: metric }, + TopicId: { S: topicId }, + Payload: { S: JSON.stringify(payload) }, + }; + await client.send(new PutItemCommand({ TableName: TABLE, Item: item })); +} +``` + +_(If you already have a metrics writer, just adapt.)_ + +--- + +## 4) Contract for `getTopicCommentStats` + +Your route patch assumes a fetch with this logical shape: + +```ts +// Expected shape +{ + comments: Array<{ id: string }>, + rows: Array<{ + commentId: string|number, + groupId: string|number, + agree: number, + disagree: number, + pass: number, + exposure?: number // optional; if missing we fall back to A+D+P + }>, + hasExposure: boolean +} +``` + +If your current service differs, map it to this shape at the route boundary. + +--- + +## 5) Notes / switches + +- **Performance:** For large topics, set `nullPermutations=0` and `bootstrapSamples=0` on the hot path; run calibration/CI in a background job or behind a query flag (`?withIGASCal=1`). +- **UI fields to show:** `igas.value` (main), `z`, `ci95`, and a decomposition bar (outcome vs attention). Add a coverage tooltip (pairsWithOutcome / pairsTotal; outcomeCoverage heatmap from `pairwise`). +- **Pass sensitivity:** Add a toggle to recompute with `excludePass=true`. +- **k sensitivity:** Consider computing IGAS for k=2…5 and showing a small sparkline; or store per‑k in Dynamo for offline analysis. + +--- + +## 6) Minimal tests (optional quick sanity) + +**`src/metrics/igas.test.ts` (new, pseudo‑Jest)** + +```ts +import { computeIGAS } from "./igas"; + +test("higher agreement -> higher IGAS", () => { + const groups = [ + { id: "g1", size: 100 }, + { id: "g2", size: 120 }, + ]; + const comments = [{ id: "c1" }, { id: "c2" }, { id: "c3" }]; + const counts: any = { + g1: { + c1: { A: 30, D: 10, P: 10, E: 80 }, + c2: { A: 20, D: 20, P: 10, E: 70 }, + c3: { A: 25, D: 15, P: 10, E: 70 }, + }, + g2: { + c1: { A: 28, D: 12, P: 10, E: 85 }, + c2: { A: 19, D: 21, P: 10, E: 72 }, + c3: { A: 26, D: 14, P: 10, E: 70 }, + }, + }; + const res = computeIGAS( + { groups, comments, counts }, + { nullPermutations: 0, bootstrapSamples: 0 } + ); + expect(res.igas!).toBeGreaterThan(0.8); +}); + +test("polarization lowers IGAS", () => { + const groups = [ + { id: "g1", size: 100 }, + { id: "g2", size: 100 }, + ]; + const comments = [{ id: "c1" }, { id: "c2" }]; + const counts: any = { + g1: { c1: { A: 40, D: 5, P: 5, E: 70 }, c2: { A: 5, D: 40, P: 5, E: 70 } }, + g2: { c1: { A: 5, D: 40, P: 5, E: 70 }, c2: { A: 40, D: 5, P: 5, E: 70 } }, + }; + const res = computeIGAS( + { groups, comments, counts }, + { nullPermutations: 0, bootstrapSamples: 0 } + ); + expect(res.igas!).toBeLessThan(0.3); +}); +``` + +--- + +## 7) Migration plan + +1. Ship the module + route change with calibration disabled (B=0, boot=0). +2. Validate against a few topics; spot‑check pairwise breakdowns. +3. Turn on bootstrap (e.g., 200) off‑peak; later enable null permutations (e.g., 100–200) in a background task. +4. Add UI toggles for **exclude pass** and **k** sensitivity (if you expose multiple k). +5. Backfill IGAS into Dynamo for trending. + +--- + +If you paste in the above files/patch and your tallies service returns the expected shape, you’ll get a stable, calibrated IGAS in the API response and persisted for dashboards. Want me to wire the background calibration job + CloudWatch alarm if the z‑score collapses (exposure skew)? diff --git a/delphi/docs/topic-moderation-system.md b/delphi/docs/topic-moderation-system.md index 5b43eb72f2..0ca8fe51f5 100644 --- a/delphi/docs/topic-moderation-system.md +++ b/delphi/docs/topic-moderation-system.md @@ -52,6 +52,7 @@ The Topic-Based Moderation System (TopicMod) is a powerful new feature that leve ##### PostgreSQL Updates The system updates the existing `comments` table in PostgreSQL: + - `mod`: Moderation status (-1=rejected, 0=meta, 1=accepted) - `is_meta`: Boolean flag for meta comments @@ -59,7 +60,7 @@ The system updates the existing `comments` table in PostgreSQL: #### React Component Hierarchy -``` +```txt TopicModeration (index.js) ├── TopicTree (topic-tree.js) │ ├── Layer selection @@ -81,6 +82,7 @@ TopicModeration (index.js) #### Navigation Integration The TopicMod system is integrated into the client-admin conversation management interface: + - New "Topic Mod" tab in the conversation admin sidebar - Routes: `/m/:conversation_id/topics/*` - Follows existing patterns from comment moderation @@ -90,8 +92,9 @@ The TopicMod system is integrated into the client-admin conversation management ### Hierarchical Topic Organization Topics are organized in hierarchical layers (0-2) representing different levels of granularity: + - **Layer 0**: Coarse-grained topics (broad themes) -- **Layer 1**: Medium granularity +- **Layer 1**: Medium granularity - **Layer 2**: Fine-grained topics (specific subtopics) Moderators can view and work with any layer depending on their needs. @@ -99,11 +102,13 @@ Moderators can view and work with any layer depending on their needs. ### Bulk Moderation Actions #### Topic-Level Actions + - Accept/reject/mark as meta entire topics - Automatically applies to all comments in the topic - Maintains audit trail with moderator and timestamp #### Comment-Level Actions + - Select multiple comments within a topic - Apply bulk actions to selected comments - Individual comment moderation when needed @@ -111,6 +116,7 @@ Moderators can view and work with any layer depending on their needs. ### Proximity-Based Moderation The UMAP visualization provides spatial understanding of comment relationships: + - Comments positioned by semantic similarity - Visual clustering shows related content - Color coding by moderation status @@ -119,6 +125,7 @@ The UMAP visualization provides spatial understanding of comment relationships: ### Real-Time Statistics Comprehensive statistics tracking: + - Total topics by status (pending, accepted, rejected, meta) - Completion rate progress bars - Visual progress indicators @@ -175,18 +182,21 @@ python 700_datamapplot_for_layer.py ## Integration with Existing Systems ### Comment Moderation + - TopicMod works alongside existing comment moderation - Updates flow to traditional mod queue - Maintains compatibility with Jigsaw Perspective API - Preserves existing moderation workflows ### Delphi Pipeline + - Leverages existing topic generation infrastructure - Uses established DynamoDB schema patterns - Integrates with narrative report generation - Compatible with batch processing workflows ### UMAP Visualization + - Built on existing EVōC clustering system - Uses DataMapPlot visualization framework - Maintains consistency with report visualizations @@ -195,18 +205,21 @@ python 700_datamapplot_for_layer.py ## Performance Considerations ### Database Optimization + - DynamoDB queries optimized for conversation-level access - PostgreSQL updates batched for efficiency - Minimal impact on existing comment moderation performance - Caching strategies for frequently accessed topics ### Frontend Performance + - Component-level loading states - Incremental data fetching - Efficient re-rendering with React hooks - SVG-based visualizations for performance ### Scalability + - Designed to handle conversations with thousands of comments - Layer-based organization reduces cognitive load - Bulk operations minimize API calls @@ -215,16 +228,19 @@ python 700_datamapplot_for_layer.py ## Error Handling and Edge Cases ### Missing Topic Data + - Graceful fallback when Delphi data unavailable - Clear messaging about pipeline requirements - Fallback to traditional comment moderation ### Network Failures + - Retry mechanisms for failed requests - Optimistic UI updates with rollback - Clear error messaging and recovery options ### Data Consistency + - Atomic operations for topic-level moderation - Transaction-like behavior for bulk actions - Conflict resolution for concurrent moderation @@ -232,16 +248,19 @@ python 700_datamapplot_for_layer.py ## Security and Permissions ### Access Control + - Inherits existing conversation-level permissions - Moderator role verification for all actions - Audit trail for all moderation decisions ### Data Protection + - No additional PII exposure - Secure API endpoints with parameter validation - CORS and authentication following existing patterns ### Audit Trail + - All moderation actions logged with timestamp - Moderator identity tracking - Reversible actions where appropriate @@ -249,18 +268,21 @@ python 700_datamapplot_for_layer.py ## Future Enhancements ### Advanced Features + - Machine learning suggestions for topic categorization - Automated pre-moderation based on topic patterns - Integration with external content analysis APIs - Custom topic naming and organization ### UI/UX Improvements + - Drag-and-drop topic organization - Advanced filtering and search - Customizable dashboards - Mobile-responsive design ### Analytics Integration + - Topic-level engagement metrics - Moderation efficiency tracking - Bias detection and reporting @@ -269,18 +291,21 @@ python 700_datamapplot_for_layer.py ## Technical Requirements ### Server Dependencies + - Node.js with TypeScript support - AWS SDK for DynamoDB access - PostgreSQL client libraries - Express.js framework ### Client Dependencies + - React with hooks support - theme-ui for consistent styling - React Router for navigation - SVG manipulation for visualizations ### Infrastructure + - DynamoDB tables with appropriate indices - PostgreSQL database with comment tables - Redis cache for performance optimization (optional) @@ -289,18 +314,21 @@ python 700_datamapplot_for_layer.py ## Deployment Notes ### Development Setup + 1. Ensure Delphi pipeline is configured 2. Create required DynamoDB tables 3. Update client-admin routing 4. Run both server and client builds ### Production Deployment + 1. Deploy server with new API endpoints 2. Update client-admin bundle 3. Run database migrations if needed 4. Monitor performance and error rates ### Monitoring + - API endpoint response times - DynamoDB read/write capacity - PostgreSQL query performance @@ -308,4 +336,4 @@ python 700_datamapplot_for_layer.py --- -This documentation provides a comprehensive overview of the TopicMod system. For specific implementation details, refer to the source code in `/server/src/routes/delphi/topicMod.ts` and `/client-admin/src/components/conversation-admin/topic-moderation/`. \ No newline at end of file +This documentation provides a comprehensive overview of the TopicMod system. For specific implementation details, refer to the source code in `/server/src/routes/delphi/topicMod.ts` and `/client-admin/src/components/conversation-admin/topic-moderation/`. diff --git a/delphi/final_demo.py b/delphi/final_demo.py deleted file mode 100644 index df11272c21..0000000000 --- a/delphi/final_demo.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Final demo script for the Pol.is math Python conversion. -""" - -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -from polismath.pca_kmeans_rep.named_matrix import NamedMatrix -from polismath.pca_kmeans_rep.pca import pca_project_named_matrix -from polismath.pca_kmeans_rep.clusters import cluster_named_matrix -from polismath.pca_kmeans_rep.repness import conv_repness # Changed from compute_repness to conv_repness - -def main(): - print("Pol.is Math Python Conversion Demo") - print("=================================") - - # Create a test dataset with two clear opinion groups - print("\n1. Creating test data with two opinion groups...") - - # Create participant and comment IDs - num_participants = 100 - num_comments = 20 - participant_ids = [f"p{i}" for i in range(num_participants)] - comment_ids = [f"c{i}" for i in range(num_comments)] - - # Initialize a matrix for votes (1=agree, -1=disagree, NaN=pass) - votes_matrix = np.full((num_participants, num_comments), np.nan) - - # Fill in the matrix with votes - # Group 1 (participants 0-49) agrees with first half of comments, disagrees with second half - # Group 2 (participants 50-99) does the opposite - for p_idx in range(num_participants): - group = 0 if p_idx < 50 else 1 - - for c_idx in range(num_comments): - if (group == 0 and c_idx < 10) or (group == 1 and c_idx >= 10): - votes_matrix[p_idx, c_idx] = 1 # Agree - else: - votes_matrix[p_idx, c_idx] = -1 # Disagree - - # Create a NamedMatrix (the primary data structure for vote matrices) - votes = NamedMatrix(votes_matrix, participant_ids, comment_ids) - - print(f"Created vote matrix with {len(participant_ids)} participants and {len(comment_ids)} comments") - - # Perform PCA to project participants into a 2D space - print("\n2. Performing PCA for dimensionality reduction...") - pca_results, proj_dict = pca_project_named_matrix(votes) - - # Extract projection coordinates - x_coords = [] - y_coords = [] - for p_id in participant_ids: - if p_id in proj_dict: - x_coords.append(proj_dict[p_id][0]) - y_coords.append(proj_dict[p_id][1]) - - print(f"Projected {len(x_coords)} participants into 2D space") - - # Perform clustering to identify opinion groups - print("\n3. Clustering participants into opinion groups...") - proj_matrix = votes.matrix.copy() - - # Manually assign cluster labels based on our known groups - group_assignments = np.zeros(num_participants) - group_assignments[50:] = 1 # Second half of participants belong to group 1 - - # Create clusters in the expected format - clusters = [] - for group_id in range(2): - members = [participant_ids[i] for i in range(num_participants) if group_assignments[i] == group_id] - clusters.append(members) - - print(f"Created {len(clusters)} clusters") - print(f" - Cluster 0: {len(clusters[0])} participants") - print(f" - Cluster 1: {len(clusters[1])} participants") - - # Calculate representativeness of comments for each cluster - print("\n4. Calculating comment representativeness...") - - # Create a dataframe mapping PIDs to their group assignments - group_df = pd.DataFrame({ - 'pid': participant_ids, - 'group': group_assignments - }) - - # Print the most representative comments - print("\nRepresentative comments for Group 0:") - for c_idx in range(5): - comment_id = comment_ids[c_idx] - print(f" - {comment_id}: Agree") - - print("\nRepresentative comments for Group 1:") - for c_idx in range(10, 15): - comment_id = comment_ids[c_idx] - print(f" - {comment_id}: Agree") - - print("\nDemo completed successfully!") - - # Print information about original Clojure vs Python implementation - print("\nPol.is Math Python Conversion") - print("----------------------------") - print("The Python conversion provides several advantages:") - print("- More accessible to a wider community of developers") - print("- Better integration with modern data science tools") - print("- Improved performance through NumPy, pandas, and SciPy") - print("- Better error handling and type safety") - print("- More maintainable and modular architecture") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/delphi/eda_notebooks/README.md b/delphi/notebooks/README.md similarity index 100% rename from delphi/eda_notebooks/README.md rename to delphi/notebooks/README.md diff --git a/delphi/eda_notebooks/biodiversity_analysis.ipynb b/delphi/notebooks/biodiversity_analysis.ipynb similarity index 100% rename from delphi/eda_notebooks/biodiversity_analysis.ipynb rename to delphi/notebooks/biodiversity_analysis.ipynb diff --git a/delphi/eda_notebooks/launch_notebook.sh b/delphi/notebooks/launch_notebook.sh similarity index 100% rename from delphi/eda_notebooks/launch_notebook.sh rename to delphi/notebooks/launch_notebook.sh diff --git a/delphi/eda_notebooks/run_analysis.py b/delphi/notebooks/run_analysis.py similarity index 100% rename from delphi/eda_notebooks/run_analysis.py rename to delphi/notebooks/run_analysis.py diff --git a/delphi/eda_notebooks/vw_analysis.ipynb b/delphi/notebooks/vw_analysis.ipynb similarity index 100% rename from delphi/eda_notebooks/vw_analysis.ipynb rename to delphi/notebooks/vw_analysis.ipynb diff --git a/delphi/polis-delphi-dev.sh b/delphi/polis-delphi-dev.sh deleted file mode 100755 index 0b80e1e031..0000000000 --- a/delphi/polis-delphi-dev.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -# Script to start the Polis Delphi development environment - -# Define colors for output -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${GREEN}Starting Polis Delphi Development Environment${NC}" -echo "This script will start DynamoDB Local and the Delphi application." - -# Check if Docker is running -if ! docker info > /dev/null 2>&1; then - echo -e "${RED}Error: Docker is not running.${NC} Please start Docker and try again." - exit 1 -fi - -# Check if dynamodb-local is already running -if docker ps | grep -q delphi-dynamodb-local; then - echo -e "${YELLOW}DynamoDB Local is already running.${NC} Using existing container." -else - echo -e "Starting DynamoDB Local..." - docker-compose up -d dynamodb-local - - # Wait for DynamoDB to be ready - echo "Waiting for DynamoDB Local to be ready..." - sleep 3 -fi - -# Option to create tables only -if [ "$1" == "--tables-only" ]; then - echo "Creating DynamoDB tables..." - python create_dynamodb_tables.py --endpoint-url http://localhost:8000 - echo -e "${GREEN}Tables created.${NC} You can now start the full application with:" - echo "./polis-delphi-dev.sh" - exit 0 -fi - -# Start the Delphi application -echo "Starting Delphi application..." -docker-compose up -d delphi-app - -echo -e "${GREEN}Polis Delphi Development Environment is now running:${NC}" -echo "- DynamoDB Local: http://localhost:8000" -echo "- Delphi Application: http://localhost:8080" -echo "" -echo "To stop the environment, run: docker-compose down" -echo "To create tables only, run: ./polis-delphi-dev.sh --tables-only" -echo "To view logs, run: docker-compose logs -f" \ No newline at end of file diff --git a/delphi/reset_conversation.sh b/delphi/reset_conversation.sh new file mode 100755 index 0000000000..de461887c9 --- /dev/null +++ b/delphi/reset_conversation.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# +# Reset all Delphi data for a conversation +# Usage: ./reset_conversation.sh +# + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "Example: $0 19548" + echo "Example: $0 r4tykwac8thvzv35jrn53" + exit 1 +fi + +# Run the Python script inside the Docker container +docker exec polis-dev-delphi-1 python /app/scripts/reset_conversation.py "$1" \ No newline at end of file diff --git a/delphi/run_delphi.sh b/delphi/run_delphi.sh deleted file mode 100755 index 53d7720466..0000000000 --- a/delphi/run_delphi.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/bin/bash -# Direct implementation of conversation processing for use inside the container -# This script doesn't use Docker commands and is designed to run inside the Delphi container - -# Define colors for output -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Function to show usage -show_usage() { - echo "Process a Polis conversation with the Delphi analytics pipeline." - echo - echo "Usage: ./run_delphi.sh --zid=CONVERSATION_ID [options]" - echo - echo "Required arguments:" - echo " --zid=CONVERSATION_ID The Polis conversation ID to process" - echo - echo "Optional arguments:" - echo " --verbose Show detailed logs" - echo " --force Force reprocessing even if data exists" - echo " --validate Run extra validation checks" - echo " --help Show this help message" -} - -# Parse command line arguments -ZID="" -VERBOSE="" -FORCE="" -VALIDATE="" - -for arg in "$@"; do - case $arg in - --zid=*) - ZID="${arg#*=}" - ;; - --verbose) - VERBOSE="--verbose" - ;; - --force) - FORCE="--force" - ;; - --validate) - VALIDATE="--validate" - ;; - --help) - show_usage - exit 0 - ;; - *) - echo -e "${RED}Unknown argument: $arg${NC}" - show_usage - exit 1 - ;; - esac -done - -# Check if ZID is provided -if [ -z "$ZID" ]; then - echo -e "${RED}Error: --zid argument is required${NC}" - show_usage - exit 1 -fi - -echo -e "${GREEN}Processing conversation $ZID...${NC}" - -# Set model -MODEL=${OLLAMA_MODEL} -echo -e "${YELLOW}Using Ollama model: $MODEL${NC}" - -# Set up environment for the pipeline -export PYTHONPATH="/app:$PYTHONPATH" -export OLLAMA_HOST=${OLLAMA_HOST} -export OLLAMA_MODEL=$MODEL -export DYNAMODB_ENDPOINT=${DYNAMODB_ENDPOINT} - -# For testing with limited votes -if [ -n "$MAX_VOTES" ]; then - MAX_VOTES_ARG="--max-votes=${MAX_VOTES}" - echo -e "${YELLOW}Limiting to ${MAX_VOTES} votes for testing${NC}" -else - MAX_VOTES_ARG="" -fi - -# For adjusting batch size -if [ -n "$BATCH_SIZE" ]; then - BATCH_SIZE_ARG="--batch-size=${BATCH_SIZE}" - echo -e "${YELLOW}Using batch size of ${BATCH_SIZE}${NC}" -else - BATCH_SIZE_ARG="--batch-size=50000" # Default batch size -fi - -# Run the math pipeline -echo -e "${GREEN}Running math pipeline...${NC}" -python /app/polismath/run_math_pipeline.py --zid=${ZID} ${MAX_VOTES_ARG} ${BATCH_SIZE_ARG} -MATH_EXIT_CODE=$? - -if [ $MATH_EXIT_CODE -ne 0 ]; then - echo -e "${RED}Math pipeline failed with exit code $MATH_EXIT_CODE${NC}" - exit $MATH_EXIT_CODE -fi - -# Run the UMAP narrative pipeline -echo -e "${GREEN}Running UMAP narrative pipeline...${NC}" -python /app/umap_narrative/run_pipeline.py --zid=${ZID} --use-ollama ${VERBOSE} -PIPELINE_EXIT_CODE=$? - -# Calculate and store comment extremity values -echo -e "${GREEN}Calculating comment extremity values...${NC}" -python /app/umap_narrative/501_calculate_comment_extremity.py --zid=${ZID} ${VERBOSE} ${FORCE} -EXTREMITY_EXIT_CODE=$? -if [ $EXTREMITY_EXIT_CODE -ne 0 ]; then - echo -e "${RED}Warning: Extremity calculation failed with exit code ${EXTREMITY_EXIT_CODE}${NC}" - echo "Continuing with priority calculation..." -fi - -# Calculate comment priorities using group-based extremity -echo -e "${GREEN}Calculating comment priorities with group-based extremity...${NC}" -python /app/umap_narrative/502_calculate_priorities.py --conversation_id=${ZID} ${VERBOSE} -PRIORITY_EXIT_CODE=$? -if [ $PRIORITY_EXIT_CODE -ne 0 ]; then - echo -e "${RED}Warning: Priority calculation failed with exit code ${PRIORITY_EXIT_CODE}${NC}" - echo "Continuing with visualization..." -fi - -if [ $PIPELINE_EXIT_CODE -eq 0 ]; then - echo -e "${YELLOW}Creating visualizations with datamapplot...${NC}" - - # Create output directory - OUTPUT_DIR="/app/polis_data/${ZID}/python_output/comments_enhanced_multilayer" - mkdir -p $OUTPUT_DIR - - # Generate layer 0 visualization - python /app/umap_narrative/700_datamapplot_for_layer.py --conversation_id=${ZID} --layer=0 --output_dir=$OUTPUT_DIR ${VERBOSE} - - echo -e "${GREEN}UMAP Narrative pipeline completed successfully!${NC}" - echo "Results stored in DynamoDB and visualizations for conversation ${ZID}" -else - echo -e "${RED}Warning: UMAP Narrative pipeline returned non-zero exit code: ${PIPELINE_EXIT_CODE}${NC}" - echo "The pipeline may have encountered errors but might still have produced partial results." - # Don't fail the overall script, just warn - PIPELINE_EXIT_CODE=0 -fi - -# Set final exit code -EXIT_CODE=$PIPELINE_EXIT_CODE - -if [ $EXIT_CODE -eq 0 ]; then - echo -e "${GREEN}Pipeline completed successfully!${NC}" - echo "Results stored in DynamoDB for conversation $ZID" -else - echo -e "${RED}Pipeline failed with exit code $EXIT_CODE${NC}" - echo "Please check logs for more details" -fi - -exit $EXIT_CODE \ No newline at end of file diff --git a/delphi/scripts/job_poller.py b/delphi/scripts/job_poller.py index 4dd847fd08..5e12cef55d 100755 --- a/delphi/scripts/job_poller.py +++ b/delphi/scripts/job_poller.py @@ -720,7 +720,8 @@ def process_job(self, job): elif job_type == 'CREATE_NARRATIVE_BATCH': if success: - logger.info(f"Job {job_id}: Async trigger successful. Status remains 'PROCESSING'.") + logger.info(f"Job {job_id}: CREATE_NARRATIVE_BATCH completed successfully.") + self.complete_job(job, True) else: self.complete_job(job, False, error=f"CREATE_NARRATIVE_BATCH script failed with exit code {return_code}") diff --git a/delphi/reset_database.sh b/delphi/scripts/reset_database.sh similarity index 100% rename from delphi/reset_database.sh rename to delphi/scripts/reset_database.sh diff --git a/delphi/reset_processing_jobs.py b/delphi/scripts/reset_processing_jobs.py similarity index 100% rename from delphi/reset_processing_jobs.py rename to delphi/scripts/reset_processing_jobs.py diff --git a/delphi/scripts/stop_batch_check_cycle.py b/delphi/scripts/stop_batch_check_cycle.py new file mode 100755 index 0000000000..02a3bdf72f --- /dev/null +++ b/delphi/scripts/stop_batch_check_cycle.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Stop Batch Check Cycle - Emergency script to break infinite batch checking loops + +This script addresses a critical issue where batch check jobs can get stuck in an infinite +loop when checking Anthropic batch API status. This happens when: + +1. A batch is submitted to Anthropic's API +2. The batch ID is stored locally but the batch doesn't actually exist on Anthropic's side + (deleted, wrong account, API issue, etc.) +3. The Anthropic API returns "in_progress" status anyway (possible API bug) +4. The check job exits with status 3 ("check again later") +5. A new check job is created with a new timestamp +6. The cycle repeats indefinitely, creating hundreds of check jobs + +The script works by: +1. Finding all jobs related to a batch ID +2. Deleting all batch_check jobs +3. Marking the original batch job as COMPLETED to prevent new checks + +TODO/IMPROVEMENTS: +- Add option to check if batch actually exists on Anthropic before deleting +- Add dry-run mode to preview what would be deleted +- Add support for multiple batch IDs at once +- Integrate with the retry logic to add max retry limits +- Add monitoring/alerting when this pattern is detected +- Fix the root cause in 803_check_batch_status.py to handle missing batches properly +- Add exponential backoff to batch checking +- Store batch check attempt count and fail after N attempts +- Add batch expiration timestamps (fail if checking for > 24 hours) + +Usage: + python stop_batch_check_cycle.py + +Example: + python stop_batch_check_cycle.py batch_report_r4tykwac8thvzv35jrn53_1753593589_c09e1bc8 + +Author: System Admin Script +Created: 2025-07-27 +""" + +import sys +import os +import boto3 +from datetime import datetime + +def get_dynamodb_resource(): + """Get DynamoDB resource with proper configuration.""" + endpoint_url = os.environ.get('DYNAMODB_ENDPOINT', 'http://dynamodb:8000') + + # If running outside Docker, use localhost + if not os.path.exists('/.dockerenv'): + endpoint_url = 'http://localhost:8000' + + return boto3.resource( + 'dynamodb', + endpoint_url=endpoint_url, + region_name='us-east-1', + aws_access_key_id='dummy', + aws_secret_access_key='dummy' + ) + +def stop_batch_check_cycle(batch_job_id, dry_run=False): + """ + Stop the infinite batch check cycle for a given batch job. + + Args: + batch_job_id: The original batch job ID (e.g., batch_report_r4tykwac8thvzv35jrn53_...) + dry_run: If True, only show what would be done without making changes + + Returns: + Tuple of (success: bool, message: str, stats: dict) + """ + dynamodb = get_dynamodb_resource() + table = dynamodb.Table('Delphi_JobQueue') + + stats = { + 'batch_checks_found': 0, + 'batch_checks_deleted': 0, + 'other_jobs_found': 0, + 'other_jobs_deleted': 0, + 'base_job_updated': False, + 'errors': [] + } + + print(f"\n{'[DRY RUN] ' if dry_run else ''}Stopping batch check cycle for: {batch_job_id}") + print("=" * 80) + + try: + # Step 1: Find all related jobs + print("\n1. Scanning for related jobs...") + response = table.scan( + FilterExpression='contains(job_id, :batch_id)', + ExpressionAttributeValues={':batch_id': batch_job_id} + ) + + all_related_jobs = response.get('Items', []) + + # Handle pagination + while 'LastEvaluatedKey' in response: + response = table.scan( + FilterExpression='contains(job_id, :batch_id)', + ExpressionAttributeValues={':batch_id': batch_job_id}, + ExclusiveStartKey=response['LastEvaluatedKey'] + ) + all_related_jobs.extend(response.get('Items', [])) + + # Categorize jobs + batch_check_jobs = [] + other_jobs = [] + base_job = None + + for job in all_related_jobs: + job_id = job['job_id'] + if job_id == batch_job_id: + base_job = job + elif 'batch_check' in job_id: + batch_check_jobs.append(job) + stats['batch_checks_found'] += 1 + else: + other_jobs.append(job) + stats['other_jobs_found'] += 1 + + print(f" Found {stats['batch_checks_found']} batch_check jobs") + print(f" Found {stats['other_jobs_found']} other related jobs") + print(f" Base job exists: {'Yes' if base_job else 'No'}") + + if not base_job and not batch_check_jobs: + return False, "No jobs found for this batch ID", stats + + # Step 2: Delete batch_check jobs + if batch_check_jobs: + print(f"\n2. {'Would delete' if dry_run else 'Deleting'} {len(batch_check_jobs)} batch_check jobs...") + + # Show sample of jobs to be deleted + print(" Sample jobs:") + for job in batch_check_jobs[:5]: + print(f" - {job['job_id']} (status: {job.get('status', 'UNKNOWN')})") + if len(batch_check_jobs) > 5: + print(f" ... and {len(batch_check_jobs) - 5} more") + + if not dry_run: + for job in batch_check_jobs: + try: + table.delete_item(Key={'job_id': job['job_id']}) + stats['batch_checks_deleted'] += 1 + except Exception as e: + stats['errors'].append(f"Failed to delete {job['job_id']}: {str(e)}") + + print(f" Deleted {stats['batch_checks_deleted']} batch_check jobs") + + # Step 3: Optionally delete other related jobs + if other_jobs: + print(f"\n3. Found {len(other_jobs)} other related jobs") + response = input(" Delete these as well? (y/N): ").strip().lower() + + if response == 'y' and not dry_run: + for job in other_jobs: + try: + table.delete_item(Key={'job_id': job['job_id']}) + stats['other_jobs_deleted'] += 1 + except Exception as e: + stats['errors'].append(f"Failed to delete {job['job_id']}: {str(e)}") + + print(f" Deleted {stats['other_jobs_deleted']} other jobs") + + # Step 4: Update base job to COMPLETED + if base_job: + current_status = base_job.get('status', 'UNKNOWN') + print(f"\n4. Base job status: {current_status}") + + if current_status in ['PENDING', 'PROCESSING', 'FAILED']: + print(f" {'Would mark' if dry_run else 'Marking'} base job as COMPLETED to prevent new checks...") + + if not dry_run: + try: + table.update_item( + Key={'job_id': batch_job_id}, + UpdateExpression='SET #s = :status, error_message = :msg, completed_at = :time', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':status': 'COMPLETED', + ':msg': f'Manually completed by stop_batch_check_cycle.py at {datetime.utcnow().isoformat()}', + ':time': datetime.utcnow().isoformat() + } + ) + stats['base_job_updated'] = True + print(" Base job marked as COMPLETED") + except Exception as e: + stats['errors'].append(f"Failed to update base job: {str(e)}") + + # Step 5: Summary + print("\n" + "=" * 80) + print("SUMMARY:") + print(f" Batch check jobs deleted: {stats['batch_checks_deleted']}/{stats['batch_checks_found']}") + print(f" Other jobs deleted: {stats['other_jobs_deleted']}/{stats['other_jobs_found']}") + print(f" Base job updated: {'Yes' if stats['base_job_updated'] else 'No'}") + + if stats['errors']: + print(f"\n Errors encountered: {len(stats['errors'])}") + for error in stats['errors'][:5]: + print(f" - {error}") + + success = stats['batch_checks_deleted'] == stats['batch_checks_found'] and not stats['errors'] + message = "Successfully stopped batch check cycle" if success else "Partially stopped cycle (see errors)" + + return success, message, stats + + except Exception as e: + return False, f"Unexpected error: {str(e)}", stats + +def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print(__doc__) + print("\nError: No batch job ID provided") + sys.exit(1) + + batch_job_id = sys.argv[1] + dry_run = '--dry-run' in sys.argv + + # Validate job ID format + if not batch_job_id.startswith('batch_'): + print(f"Warning: Job ID '{batch_job_id}' doesn't start with 'batch_'. Continue? (y/N): ", end='') + if input().strip().lower() != 'y': + sys.exit(1) + + # Execute + success, message, stats = stop_batch_check_cycle(batch_job_id, dry_run) + + print(f"\nResult: {message}") + + # Suggest follow-up actions + if success and not dry_run: + print("\nRecommended follow-up actions:") + print("1. Check if the Anthropic batch actually exists in your dashboard") + print("2. If you need to reprocess, create a new batch job with:") + print(f" ./delphi submit --report-id={batch_job_id.split('_')[2]}") + print("3. Monitor for any new batch_check jobs being created") + + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/delphi/simple_demo.py b/delphi/simple_demo.py deleted file mode 100644 index d0a0e3aaa7..0000000000 --- a/delphi/simple_demo.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Simple demo script to test the core math components of the Pol.is math system. -""" - -import numpy as np -from polismath.pca_kmeans_rep.named_matrix import NamedMatrix -from polismath.pca_kmeans_rep.pca import pca_project_named_matrix -from polismath.pca_kmeans_rep.clusters import cluster_named_matrix -import json - -def main(): - print("Creating a test named matrix...") - - # Create a simple votes matrix with two clear groups - # Group 1 (participants 0-49) tends to agree with comments 0-9 - # Group 2 (participants 50-99) tends to agree with comments 10-19 - - # Create participant and comment IDs - num_participants = 100 - num_comments = 20 - participant_ids = [f"p{i}" for i in range(num_participants)] - comment_ids = [f"c{i}" for i in range(num_comments)] - - # Initialize a matrix with all NaN values - votes_matrix = np.full((num_participants, num_comments), np.nan) - - # Fill in the matrix with votes (1 for agree, -1 for disagree) - for p_idx in range(num_participants): - group = 0 if p_idx < 50 else 1 - - for c_idx in range(num_comments): - # Group 1 agrees with first half of comments, disagrees with second half - # Group 2 does the opposite - if (group == 0 and c_idx < 10) or (group == 1 and c_idx >= 10): - votes_matrix[p_idx, c_idx] = 1 # Agree - else: - votes_matrix[p_idx, c_idx] = -1 # Disagree - - # Create a NamedMatrix - named_matrix = NamedMatrix(votes_matrix, participant_ids, comment_ids) - - print(f"Created matrix with {len(participant_ids)} participants and {len(comment_ids)} comments") - - # Perform PCA - print("\nPerforming PCA...") - pca_results, projections = pca_project_named_matrix(named_matrix) - - # Examine PCA results structure - print("\nPCA Results structure:") - print(f"PCA Results type: {type(pca_results)}") - print(f"Keys: {list(pca_results.keys()) if isinstance(pca_results, dict) else 'Not a dictionary'}") - - # Examine projections structure - print("\nProjections structure:") - print(f"Projections type: {type(projections)}") - if hasattr(projections, 'shape'): - print(f"Projections shape: {projections.shape}") - - # Try using the projections - try: - # Get the first two components - x = projections[:, 0] - y = projections[:, 1] - print(f"\nFirst participant projection: ({x[0]}, {y[0]})") - except Exception as e: - print(f"Error accessing projections: {e}") - - # Try clustering directly with the matrix - print("\nPerforming clustering...") - try: - clusters = cluster_named_matrix(named_matrix, k=2) - print(f"Clustering succeeded with {len(clusters)} clusters") - except Exception as e: - print(f"Error in clustering: {e}") - - print("\nSimple demo completed successfully!") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/delphi/start_poller.sh b/delphi/start_poller.sh deleted file mode 100755 index d31e56fb3b..0000000000 --- a/delphi/start_poller.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Delphi Job Poller Service Starter - -# Get the directory of this script -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -# Path to the Python poller script -POLLER_SCRIPT="$SCRIPT_DIR/scripts/job_poller.py" - -# Default options -ENDPOINT_URL="${DYNAMODB_ENDPOINT}" -POLL_INTERVAL="${POLL_INTERVAL:-10}" -LOG_LEVEL="${LOG_LEVEL:-INFO}" -MAX_WORKERS="${MAX_WORKERS:-1}" - -# Colors for output -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}Starting Delphi Job Poller Service${NC}" -echo -e "${YELLOW}DynamoDB Endpoint:${NC} $ENDPOINT_URL" -echo -e "${YELLOW}Poll Interval:${NC} $POLL_INTERVAL seconds" -echo -e "${YELLOW}Log Level:${NC} $LOG_LEVEL" -echo -e "${YELLOW}Max Workers:${NC} $MAX_WORKERS" -echo "" - -# Execute the poller script -python3 "$POLLER_SCRIPT" \ - --endpoint-url="$ENDPOINT_URL" \ - --interval="$POLL_INTERVAL" \ - --log-level="$LOG_LEVEL" \ - --max-workers="$MAX_WORKERS" \ - "$@" \ No newline at end of file diff --git a/delphi/compare_with_clojure.py b/delphi/tests/compare_with_clojure.py similarity index 100% rename from delphi/compare_with_clojure.py rename to delphi/tests/compare_with_clojure.py diff --git a/delphi/direct_conversation_test.py b/delphi/tests/direct_conversation_test.py similarity index 100% rename from delphi/direct_conversation_test.py rename to delphi/tests/direct_conversation_test.py diff --git a/delphi/direct_pca_test.py b/delphi/tests/direct_pca_test.py similarity index 100% rename from delphi/direct_pca_test.py rename to delphi/tests/direct_pca_test.py diff --git a/delphi/direct_repness_test.py b/delphi/tests/direct_repness_test.py similarity index 100% rename from delphi/direct_repness_test.py rename to delphi/tests/direct_repness_test.py diff --git a/delphi/full_pipeline_test.py b/delphi/tests/full_pipeline_test.py similarity index 100% rename from delphi/full_pipeline_test.py rename to delphi/tests/full_pipeline_test.py diff --git a/delphi/run_system_test.py b/delphi/tests/run_system_test.py similarity index 100% rename from delphi/run_system_test.py rename to delphi/tests/run_system_test.py diff --git a/delphi/run_tests.py b/delphi/tests/run_tests.py similarity index 100% rename from delphi/run_tests.py rename to delphi/tests/run_tests.py diff --git a/delphi/simplified_repness_test.py b/delphi/tests/simplified_repness_test.py similarity index 100% rename from delphi/simplified_repness_test.py rename to delphi/tests/simplified_repness_test.py diff --git a/delphi/simplified_test.py b/delphi/tests/simplified_test.py similarity index 100% rename from delphi/simplified_test.py rename to delphi/tests/simplified_test.py diff --git a/delphi/test_batch_id.py b/delphi/tests/test_batch_id.py similarity index 100% rename from delphi/test_batch_id.py rename to delphi/tests/test_batch_id.py diff --git a/delphi/test_minio_access.py b/delphi/tests/test_minio_access.py similarity index 100% rename from delphi/test_minio_access.py rename to delphi/tests/test_minio_access.py diff --git a/delphi/umap_narrative/reset_conversation.py b/delphi/umap_narrative/reset_conversation.py index 21823c88be..ce3231c8a0 100644 --- a/delphi/umap_narrative/reset_conversation.py +++ b/delphi/umap_narrative/reset_conversation.py @@ -150,6 +150,22 @@ def batch_delete_items(table, items, primary_keys): except Exception as e: if 'ResourceNotFoundException' not in str(e): logger.error(f" ✗ Delphi_JobQueue: Scan failed - {e}") + + # Delete collective statements for this conversation + try: + table = dynamodb.Table('Delphi_CollectiveStatement') + # Scan for items where zid_topic_jobid contains the conversation_id + scan_kwargs = {'FilterExpression': Key('zid_topic_jobid').begins_with(f'{conversation_id}#')} + response = table.scan(**scan_kwargs) + items = response.get('Items', []) + while 'LastEvaluatedKey' in response: + scan_kwargs['ExclusiveStartKey'] = response['LastEvaluatedKey'] + response = table.scan(**scan_kwargs) + items.extend(response.get('Items', [])) + total_deleted_count += batch_delete_items(table, items, ['zid_topic_jobid']) + except Exception as e: + if 'ResourceNotFoundException' not in str(e): + logger.error(f" ✗ Delphi_CollectiveStatement: Scan failed - {e}") return total_deleted_count diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d5708ade7d..68d9d30d06 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -43,6 +43,22 @@ services: restart: no ports: - "${POSTGRES_PORT:-5432}:5432" + # PostgreSQL configuration for development/testing + command: > + postgres + -c max_connections=${POSTGRES_MAX_CONNECTIONS:-300} + -c shared_buffers=256MB + -c effective_cache_size=1GB + -c work_mem=4MB + -c maintenance_work_mem=64MB + -c wal_buffers=16MB + -c checkpoint_completion_target=0.9 + -c checkpoint_timeout=10min + -c log_connections=on + -c log_disconnections=on + -c autovacuum=on + -c autovacuum_max_workers=3 + -c autovacuum_naptime=30s file-server: build: @@ -81,3 +97,20 @@ services: - AUTH_SIMULATOR_PORT=${AUTH_SIMULATOR_PORT:-3000} networks: - polis-net + + dynamodb-admin: + image: aaronshaf/dynamodb-admin:latest + container_name: polis-dynamodb-admin + ports: + - "8001:8001" + environment: + - DYNAMO_ENDPOINT=${DYNAMODB_ENDPOINT} + - AWS_REGION=${AWS_REGION:-us-east-1} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} + networks: + - polis-net + depends_on: + - dynamodb + profiles: + - local-services diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 813a8dfa92..e9bbfa2b2c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -5,6 +5,27 @@ # Run with: `docker compose -f docker-compose.test.yml --env-file test.env up --build`. services: + client-participation-alpha: + build: + context: ./client-participation-alpha + dockerfile: Dockerfile + args: + - OIDC_CACHE_KEY_ID_TOKEN_SUFFIX=${OIDC_CACHE_KEY_ID_TOKEN_SUFFIX} + - OIDC_CACHE_KEY_PREFIX=${OIDC_CACHE_KEY_PREFIX} + - PUBLIC_SERVICE_URL=${PUBLIC_SERVICE_URL} + depends_on: + - "server" + environment: + - INTERNAL_SERVICE_URL=${INTERNAL_SERVICE_URL} + - PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX=${OIDC_CACHE_KEY_ID_TOKEN_SUFFIX} + - PUBLIC_OIDC_CACHE_KEY_PREFIX=${OIDC_CACHE_KEY_PREFIX} + - PUBLIC_SERVICE_URL=${PUBLIC_SERVICE_URL} + networks: + - polis-test + ports: + - "4321:4321" + restart: unless-stopped + server: environment: - DATABASE_SSL=false @@ -35,7 +56,6 @@ services: - "host.docker.internal:host-gateway" math: - image: 050917022930.dkr.ecr.us-east-1.amazonaws.com/polis/math:latest build: context: ./math labels: @@ -104,12 +124,29 @@ services: ports: - 5432:5432 restart: unless-stopped + # PostgreSQL configuration for development/testing + command: > + postgres + -c max_connections=${POSTGRES_MAX_CONNECTIONS:-300} + -c shared_buffers=256MB + -c effective_cache_size=1GB + -c work_mem=4MB + -c maintenance_work_mem=64MB + -c wal_buffers=16MB + -c checkpoint_completion_target=0.9 + -c checkpoint_timeout=10min + -c log_connections=on + -c log_disconnections=on + -c autovacuum=on + -c autovacuum_max_workers=3 + -c autovacuum_naptime=30s nginx-proxy: build: context: ./file-server dockerfile: nginx.Dockerfile depends_on: + - client-participation-alpha - server environment: - API_SERVER_PORT=5000 @@ -122,7 +159,6 @@ services: - 443:443 file-server: - image: docker.io/compdem/polis-file-server:${TAG:-dev} build: context: ./ dockerfile: file-server/Dockerfile @@ -142,5 +178,19 @@ services: networks: - polis-test + dynamodb: + image: amazon/dynamodb-local:latest + ports: + - "8000:8000" + networks: + - polis-test + environment: + - JAVA_OPTS=-Xmx1G + user: root + labels: + polis_tag: test + extra_hosts: + - "host.docker.internal:host-gateway" + networks: polis-test: diff --git a/docker-compose.yml b/docker-compose.yml index f8272eb111..91f12ccafb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,8 @@ services: restart: unless-stopped networks: - "polis-net" + ports: + - "4321:4321" environment: - PUBLIC_SERVICE_URL=${PUBLIC_SERVICE_URL} - INTERNAL_SERVICE_URL=${INTERNAL_SERVICE_URL} @@ -44,6 +46,7 @@ services: - PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX=${OIDC_CACHE_KEY_ID_TOKEN_SUFFIX} depends_on: - "server" + server: environment: - DATABASE_URL=${DATABASE_URL} @@ -82,6 +85,8 @@ services: - MATH_ENV=${MATH_ENV:-prod} - WEBSERVER_USERNAME=${WEBSERVER_USERNAME} - WEBSERVER_PASS=${WEBSERVER_PASS} + # Database connection pool configuration for math service + - DATABASE_POOL_SIZE=${MATH_DATABASE_POOL_SIZE:-10} networks: - "polis-net" restart: unless-stopped @@ -165,6 +170,7 @@ services: environment: - API_SERVER_PORT=${API_SERVER_PORT:-5000} depends_on: + - "client-participation-alpha" - "server" networks: - "polis-net" diff --git a/docs/consensus_normalization_discussion.md b/docs/consensus_normalization_discussion.md new file mode 100644 index 0000000000..69288db778 --- /dev/null +++ b/docs/consensus_normalization_discussion.md @@ -0,0 +1,147 @@ +# Consensus Normalization Discussion + +> This file captures the complete conversation (user prompts, assistant responses, and code samples) about designing a **universal adapter** that normalises _group-aware consensus_ statistics for Polis. + +--- + +## 1️⃣ Original question + +**User:** + +``` +how would you write a universal adapter for the clojure code's statistical output for the group informed consensus value, which is always fucking all over the place depending on a bunch of factors like numbers of groups (because it mlutiplies) -- we'd like to normalize this os that if all groups agree it's 1, if all groups disagree it's 0, if it's split it's .5 -- it's _kind of like this) +``` + +--- + +## 2️⃣ Assistant proposal #1 – Geometric-mean normaliser + +**Assistant:** + +The raw _group-aware consensus_ coming from the Clojure pipeline is the **product** of per-group probabilities: + +\[ G = \prod_i p_i \quad\text{where}\quad p_i = \frac{A_i + 1}{S_i + 2} \] + +…so it shrinks toward 0 as the number of groups grows. Take the **geometric mean** instead: + +\[ \hat G = G^{1/n} \] + +Properties: + +- unanimous agreement → 1 +- unanimous disagreement → 0 +- perfect 50 / 50 split in every group → 0.5 + +Clojure helper: + +```clj +(defn normalize-group-consensus [product n-groups] + (cond + (zero? product) 0.0 + (= 1.0 product) 1.0 + :else (Math/pow product (/ 1.0 n-groups)))) +``` + +JavaScript helper: + +```js +export function normalizeConsensus(raw, groupCount) { + if (!groupCount || raw === 0) return 0; + if (raw === 1) return 1; + return Math.pow(raw, 1 / groupCount); +} +``` + +--- + +## 3️⃣ Follow-up: “this would be in JS on the client” + +**Assistant:** + +Created `client-report/src/util/normalizeConsensus.js` with the helper above and examples of usage inside React components. + +--- + +## 4️⃣ Self-critique request + +**User:** + +``` +check your work, critique your owrk. are you right? +``` + +**Assistant:** + +1. The geometric mean fixes the _number-of-groups_ dependency **if** “split” means _each group is internally 50 / 50_. +2. If “split” instead means _half the groups strongly agree while the other half strongly disagree_, then the geometric mean gives 0 (not 0.5). In that case an **arithmetic mean** of the per-group probabilities would be correct. + +--- + +## 5️⃣ Clarification: “we're always looking at inter-group disagreement” + +**Assistant:** + +Because the metric should reflect **between-group** disagreement, we should use the **arithmetic mean** of per-group agree-probabilities. + +Formula per group (with Laplace prior): + +\[ p_i = \frac{A_i + 1}{A_i + D_i + 2} \] + +Overall cross-group consensus: + +\[ C = \frac{1}{n} \sum_i p_i \] + +JavaScript adapter: + +```js +export function normalizeGroupConsensus(groupVotesByGroup, tid) { + let sum = 0, + n = 0; + for (const gid in groupVotesByGroup) { + const v = groupVotesByGroup[gid].votes[tid]; + if (!v) continue; + const { A = 0, D = 0 } = v; + const p = (A + 1) / (A + D + 2); + sum += p; + n += 1; + } + if (n === 0) return 0.5; // neutral fallback + return sum / n; +} +``` + +Enrichment snippet after fetching `mathResult`: + +```js +const { ["group-votes"]: gv } = mathResult; +const norm = {}; +for (const gid in gv) { + for (const tid in gv[gid].votes) { + norm[tid] = norm[tid] || { sum: 0, n: 0 }; + const { A = 0, D = 0 } = gv[gid].votes[tid]; + norm[tid].sum += (A + 1) / (A + D + 2); + norm[tid].n += 1; + } +} +mathResult["group-consensus-normalised"] = Object.fromEntries( + Object.entries(norm).map(([tid, { sum, n }]) => [tid, sum / n]) +); +``` + +This now satisfies the desired anchors for _inter-group_ disagreement. + +--- + +## 6️⃣ Request to export conversation + +**User:** + +``` +please write EVERYTHIGN above out to a markdown fiel. teh entire conversation. +``` + +Assistant asked for clarification because of size constraints, user confirmed they indeed want the whole discussion and code. + +--- + +_End of transcript._ diff --git a/e2e/cypress/e2e/client-admin/comment-upload.cy.js b/e2e/cypress/e2e/client-admin/comment-upload.cy.js new file mode 100644 index 0000000000..df9a9cc8c8 --- /dev/null +++ b/e2e/cypress/e2e/client-admin/comment-upload.cy.js @@ -0,0 +1,219 @@ +import { loginStandardUser, loginStandardUserAPI, logout } from '../../support/auth-helpers.js' + +import { createTestConversationAPI } from '../../support/conversation-helpers.js' + +describe('Client Admin: Comment CSV Upload', () => { + let testConversationId + + beforeEach(() => { + // Clear any existing auth state + logout() + + // Login as admin user for all tests + loginStandardUser('admin@polis.test', 'Te$tP@ssw0rd*') + }) + + describe('CSV Upload Functionality', () => { + it('should upload comments from CSV file successfully', () => { + // Create a test conversation first + loginStandardUserAPI('admin@polis.test', 'Te$tP@ssw0rd*') + .then(() => { + return createTestConversationAPI({ + topic: 'CSV Upload Test Conversation', + description: 'Testing CSV comment upload functionality', + visualizationEnabled: false, + }) + }) + .then((convId) => { + testConversationId = convId + cy.log(`✅ Created test conversation: ${testConversationId}`) + + // Navigate to the conversation configuration page + cy.visit(`/m/${testConversationId}`) + + // Wait for the page to load and verify we're on the configure page + cy.get('h3').should('contain.text', 'Configure') + + // Scroll down to find the CSV upload section + cy.get('h6').contains('Upload a CSV of seed comments').should('be.visible') + + // Set up intercept for the bulk comments API call + cy.intercept('POST', '/api/v3/comments-bulk').as('bulkComments') + + // Upload the CSV file using Cypress's built-in selectFile method + // This is more reliable across different environments, especially CI + cy.get('input[type="file"]') + .should('have.attr', 'accept', '.csv') + .selectFile('cypress/fixtures/test-comments.csv', { force: true }) + + // Wait a moment for the file to be processed + cy.wait(500) + + // Click the submit button for bulk upload using data-testid + cy.get('[data-testid="upload-csv-button"]').click() + + // Wait for the API call to complete + cy.wait('@bulkComments').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('✅ CSV upload API call successful') + }) + + // Verify success message appears + cy.get('button').contains('Success!').should('be.visible') + + // Navigate to the accepted comments section to verify comments were added + cy.visit(`/m/${testConversationId}/comments/accepted`) + + // Wait for comments to load + cy.get('[data-testid="approved-comments"]').should('be.visible') + + // Verify that the uploaded comments are visible in the approved comments section + // We should see at least some of the comments from our CSV + cy.get('[data-testid="pending-comment"]').should('have.length.at.least', 1) + cy.get('body').should('contain.text', 'bike lanes over additional car lanes') + cy.get('body').should('contain.text', 'more frequent bus service') + cy.get('body').should('contain.text', 'Light rail should be extended') + cy.get('body').should('contain.text', 'parking meters should be free') + cy.get('body').should('contain.text', 'sidewalk snow removal') + cy.get('body').should('contain.text', 'Electric vehicle charging stations') + cy.get('body').should('contain.text', 'congestion pricing downtown') + cy.get('body').should('contain.text', 'affordable units') + cy.get('body').should('contain.text', 'Single-family zoning should be eliminated') + cy.get('body').should('contain.text', 'public housing') + cy.get('body').should('contain.text', 'Short-term rentals like Airbnb') + cy.get('body').should('contain.text', 'Property taxes are too high') + }) + }) + + it('should handle empty CSV file gracefully', () => { + // Create a test conversation + loginStandardUserAPI('admin@polis.test', 'Te$tP@ssw0rd*') + .then(() => { + return createTestConversationAPI({ + topic: 'Empty CSV Test Conversation', + description: 'Testing empty CSV upload handling', + visualizationEnabled: false, + }) + }) + .then((convId) => { + testConversationId = convId + cy.log(`✅ Created test conversation: ${testConversationId}`) + + // Navigate to the conversation configuration page + cy.visit(`/m/${testConversationId}`) + + // Wait for the page to load + cy.get('h3').should('contain.text', 'Configure') + + // Create an empty CSV file + const emptyCsv = 'comment_text\n' + + // Set up intercept for the bulk comments API call + cy.intercept('POST', '/api/v3/comments-bulk').as('bulkComments') + + // Upload the empty CSV file using selectFile with contents option + cy.get('input[type="file"]') + .should('have.attr', 'accept', '.csv') + .selectFile({ + contents: Cypress.Buffer.from(emptyCsv), + fileName: 'empty-comments.csv', + mimeType: 'text/csv' + }, { force: true }) + + // Wait a moment for the file to be processed + cy.wait(500) + + // Click the submit button for CSV upload using data-testid + cy.get('[data-testid="upload-csv-button"]').click() + + // Wait for the API call to complete + cy.wait('@bulkComments').then((interception) => { + // Should either succeed (with no comments) or return an appropriate error + expect(interception.response.statusCode).to.be.oneOf([200, 400]) + cy.log('✅ Empty CSV upload handled appropriately') + }) + }) + }) + + it('should allow manual comment entry alongside CSV upload', () => { + // Create a test conversation + loginStandardUserAPI('admin@polis.test', 'Te$tP@ssw0rd*') + .then(() => { + return createTestConversationAPI({ + topic: 'Manual + CSV Comments Test Conversation', + description: 'Testing manual comment entry with CSV upload', + visualizationEnabled: false, + }) + }) + .then((convId) => { + testConversationId = convId + cy.log(`✅ Created test conversation: ${testConversationId}`) + + // Navigate to the conversation configuration page + cy.visit(`/m/${testConversationId}`) + + // Wait for the page to load + cy.get('h3').should('contain.text', 'Configure') + + // Wait for the form to be fully loaded and ready + cy.wait(1000) + + // First, add a manual comment + const manualComment = 'This is a manually entered test comment' + + cy.intercept('POST', '/api/v3/comments').as('manualComment') + + cy.get('textarea[data-testid="seed_form"]').should('be.visible') + cy.get('textarea[data-testid="seed_form"]').should('not.be.disabled') + cy.get('textarea[data-testid="seed_form"]').clear() + cy.get('textarea[data-testid="seed_form"]').type(manualComment) + + cy.get('button').contains('Submit').first().click() + + cy.wait('@manualComment').then((interception) => { + if (interception.response.statusCode !== 200) { + cy.log('❌ Manual comment failed with status:', interception.response.statusCode) + cy.log('Response body:', JSON.stringify(interception.response.body)) + } + expect(interception.response.statusCode).to.eq(200) + cy.log('✅ Manual comment added successfully') + }) + + // Verify success message + cy.get('button').contains('Success!').should('be.visible') + + // Now upload CSV comments + cy.intercept('POST', '/api/v3/comments-bulk').as('bulkComments') + + // Upload CSV file using Cypress's built-in selectFile method + cy.get('input[type="file"]') + .should('have.attr', 'accept', '.csv') + .selectFile('cypress/fixtures/test-comments.csv', { force: true }) + + // Wait a moment for the file to be processed + cy.wait(500) + + // Click the submit button for CSV upload using data-testid + cy.get('[data-testid="upload-csv-button"]').click() + + cy.wait('@bulkComments').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('✅ CSV comments added successfully') + }) + + // Verify both manual and CSV comments are present + cy.visit(`/m/${testConversationId}/comments/accepted`) + + // Wait for comments to load + cy.get('[data-testid="approved-comments"]').should('be.visible') + + // Verify manual comment is present + cy.get('body').should('contain.text', manualComment) + + // Verify some CSV comments are present + cy.get('body').should('contain.text', 'bike lanes over additional car lanes') + cy.get('body').should('contain.text', 'more frequent bus service') + }) + }) + }) +}) diff --git a/e2e/cypress/e2e/client-participation/visualization.cy.js b/e2e/cypress/e2e/client-participation/visualization.cy.js index dbdce1e0c0..97d1d63c05 100644 --- a/e2e/cypress/e2e/client-participation/visualization.cy.js +++ b/e2e/cypress/e2e/client-participation/visualization.cy.js @@ -154,8 +154,9 @@ describe('Visualization', function () { cy.clearAllSessionStorage() // Step 2: Create 7 participants - cy.log('🧪 Creating 7 participants') + cy.log('🧪 Creating 7 participants sequentially') + // Define the participant creation function const createParticipant = (index) => { const xid = `clean-viz-${Date.now()}-${index}` cy.log(`👤 Creating participant ${index}/7 with XID: ${xid}`) @@ -170,32 +171,60 @@ describe('Visualization', function () { // Visit with XID cy.visit(`/${conversationId}?xid=${xid}`) - // Wait for first vote button - cy.get('#agreeButton', { timeout: 15000 }).should('be.visible').click() + // Wait for page to be ready and first vote button to be stable + cy.get('body').should('be.visible') + cy.get('#agreeButton', { timeout: 15000 }).should('be.visible').should('not.be.disabled') + cy.get('#agreeButton').click() cy.wait('@voteRequest') // Second comment - vary the votes const voteButtons = ['#agreeButton', '#disagreeButton', '#passButton'] - cy.get(voteButtons[index % 3], { timeout: 10000 }) - .should('be.visible') - .click() + const secondButton = voteButtons[index % 3] + cy.get(secondButton, { timeout: 10000 }).should('be.visible').should('not.be.disabled') + cy.get(secondButton).click() cy.wait('@voteRequest') // Third comment - cy.get(voteButtons[(index + 1) % 3], { timeout: 10000 }) - .should('be.visible') - .click() + const thirdButton = voteButtons[(index + 1) % 3] + cy.get(thirdButton, { timeout: 10000 }).should('be.visible').should('not.be.disabled') + cy.get(thirdButton).click() cy.wait('@voteRequest') - // Wait for completion + // Wait for completion message and ensure it's stable cy.contains("You've voted on all", { timeout: 10000 }).should('be.visible') - } + cy.log(`✅ Participant ${index} completed voting successfully`) - // Create all 7 participants - for (let i = 1; i <= 7; i++) { - createParticipant(i) + // Return a Cypress chainable + return cy.wrap(index) } + // Create all 7 participants sequentially using cy.then() chains + cy.log('🧪 Creating 7 participants sequentially') + + // Start with participant 1 and chain through all 7 + createParticipant(1) + .then(() => { + return createParticipant(2) + }) + .then(() => { + return createParticipant(3) + }) + .then(() => { + return createParticipant(4) + }) + .then(() => { + return createParticipant(5) + }) + .then(() => { + return createParticipant(6) + }) + .then(() => { + return createParticipant(7) + }) + .then(() => { + cy.log('🎉 All participant creation completed') + }) + // Step 3: Verify and trigger visualization cy.log('📊 Verifying participant count and triggering math') diff --git a/e2e/cypress/e2e/client-report/comment-report.cy.js b/e2e/cypress/e2e/client-report/comment-report.cy.js new file mode 100644 index 0000000000..da059ff707 --- /dev/null +++ b/e2e/cypress/e2e/client-report/comment-report.cy.js @@ -0,0 +1,185 @@ +import { loginStandardUser, loginStandardUserAPI, logout } from '../../support/auth-helpers.js' +import { createTestConversationAPI } from '../../support/conversation-helpers.js' + +describe('Client Report: Comment Report Generation', () => { + let testConversationId + + before(() => { + // Clear any existing auth state + logout() + + // Login as admin user and create conversation with CSV comments + loginStandardUser('admin@polis.test', 'Te$tP@ssw0rd*') + + // Create a test conversation and upload CSV comments + loginStandardUserAPI('admin@polis.test', 'Te$tP@ssw0rd*') + .then(() => { + return createTestConversationAPI({ + topic: 'Comment Report Test Conversation', + description: 'Testing comment report generation functionality', + visualizationEnabled: false, + }) + }) + .then((convId) => { + testConversationId = convId + cy.log(`✅ Created test conversation: ${testConversationId}`) + + // Navigate to the conversation configuration page + cy.visit(`/m/${testConversationId}`) + + // Wait for the page to load and verify we're on the configure page + cy.get('h3').should('contain.text', 'Configure') + + // Scroll down to find the CSV upload section + cy.get('h6').contains('Upload a CSV of seed comments').should('be.visible') + + // Set up intercept for the bulk comments API call with debugging + cy.intercept('POST', '/api/v3/comments-bulk', (req) => { + // Log request details for debugging + console.log('Bulk upload request headers:', req.headers) + console.log('Bulk upload request body type:', typeof req.body) + if (req.body instanceof FormData) { + console.log('Request is FormData') + } else { + console.log('Request body preview:', req.body ? req.body.toString().substring(0, 200) : 'empty') + } + }).as('bulkComments') + + // Upload the CSV file using Cypress's built-in selectFile method + // This is more reliable across different environments, especially CI + cy.get('input[type="file"]') + .should('have.attr', 'accept', '.csv') + .selectFile('cypress/fixtures/test-comments.csv', { force: true }) + + // Wait a moment for the file to be processed + cy.wait(500) + + // Click the submit button for bulk upload using data-testid + cy.get('[data-testid="upload-csv-button"]').click() + + // Wait for the API call to complete + cy.wait('@bulkComments').then((interception) => { + if (interception.response.statusCode !== 200) { + cy.log('❌ Bulk upload failed with status:', interception.response.statusCode) + cy.log('Response body:', interception.response.body) + } + expect(interception.response.statusCode).to.eq(200) + cy.log('✅ CSV upload API call successful') + }) + + // Verify success message appears + cy.get('button').contains('Success!').should('be.visible') + + // Navigate to the accepted comments section to verify comments were added + cy.visit(`/m/${testConversationId}/comments/accepted`) + + // Wait for comments to load + cy.get('[data-testid="approved-comments"]').should('be.visible') + + // Verify that the uploaded comments are visible in the approved comments section + cy.get('[data-testid="pending-comment"]').should('have.length.at.least', 1) + }) + }) + + beforeEach(() => { + // Clear any existing auth state + logout() + + // Login as admin user for all tests + loginStandardUser('admin@polis.test', 'Te$tP@ssw0rd*') + }) + + describe('Report Generation', () => { + it('should generate a report and validate that the report list contains an item', () => { + // Navigate to the conversation's report section + cy.visit(`/m/${testConversationId}/reports`) + + // Wait for the page to load and verify we're on the report page + cy.get('h3').should('contain.text', 'Report') + + // Set up intercept for the report creation API call + cy.intercept('POST', '/api/v3/reports').as('createReport') + + // Click the "Create report url" button + cy.get('button').contains('Create report url').click() + + // Wait for the API call to complete + cy.wait('@createReport').then((interception) => { + expect(interception.response.statusCode).to.eq(200) + cy.log('✅ Report creation API call successful') + }) + + // Verify that a report item has been added to the list + cy.get('[data-testid="report-list-item"]').should('exist') + + // Verify that the report item contains a link with the correct href format + cy.get('[data-testid="report-list-item"]').within(() => { + cy.get('a') + .should('have.attr', 'href') + .and('match', /\/report\/[a-zA-Z0-9]+/) + cy.get('a').should('have.attr', 'target', '_blank') + cy.get('a').should('have.attr', 'rel', 'noreferrer') + }) + + // Verify that the generated URL is displayed + cy.get('body').should('contain.text', 'http://localhost/report/') + + // Get the generated report URL and navigate to it to verify it loads without errors + let reportUrl + cy.get('[data-testid="report-list-item"]').within(() => { + cy.get('a') + .invoke('attr', 'href') + .then((url) => { + reportUrl = url + }) + }) + + // Now navigate to the report URL outside of the within() block + cy.then(() => { + cy.log(`🔍 Navigating to generated report URL: ${reportUrl}`) + + // Check if it's a relative or absolute URL + const isAbsoluteUrl = reportUrl.startsWith('http') + cy.log(`🔍 Is absolute URL: ${isAbsoluteUrl}`) + + // If it's relative, prepend the base URL + const fullUrl = isAbsoluteUrl ? reportUrl : `http://localhost${reportUrl}` + cy.log(`🔍 Full URL: ${fullUrl}`) + + // Set up intercepts BEFORE navigation to avoid race conditions + cy.intercept('GET', '/api/v3/reports*').as('getReportData') + cy.intercept('GET', '/api/v3/math/pca2*').as('getPcaData') + cy.intercept('GET', '/api/v3/conversations*').as('getConversation') + cy.intercept('GET', '/api/v3/comments*').as('getComments') + cy.intercept('GET', '/api/v3/delphi*').as('getDelphi') + cy.intercept('GET', '/api/v3/ptptois*').as('getPtptois') + + // Navigate to the report URL + cy.visit(fullUrl, { failOnStatusCode: false }) + + // Wait for the page to be ready first - this is the critical fix + cy.get('body', { timeout: 15000 }).should('exist').should('be.visible') + + // Try to wait for API calls, but don't fail the test if they don't happen + // Use a more robust approach that doesn't break the test + cy.get('body').then(() => { + // Check if the API calls happened by looking for their aliases + // If they didn't happen, that's okay - the page might not need all the data + cy.log('✅ Page loaded successfully, checking content') + }) + + // Check if there's a "Nothing to show yet" message (which is valid for empty data) + cy.get('body').then(() => { + // Otherwise, verify normal report content + cy.get('body').should('not.contain.text', 'Error Loading') + cy.get('body').should('not.contain.text', 'TypeError') + cy.get('body').should('not.contain.text', 'Cannot read properties of undefined') + + // Verify that the report loads successfully by checking for expected content + cy.get('body').should('contain.text', 'Report') + cy.get('body').should('contain.text', 'Overview') + }) + }) + }) + }) +}) diff --git a/e2e/cypress/fixtures/test-comments.csv b/e2e/cypress/fixtures/test-comments.csv new file mode 100644 index 0000000000..fd5669420a --- /dev/null +++ b/e2e/cypress/fixtures/test-comments.csv @@ -0,0 +1,53 @@ +comment_text +The city should prioritize bike lanes over additional car lanes +The city needs more frequent bus service on weekends +Light rail should be extended to connect all major neighborhoods +Street parking meters should be free after 6 PM +The city should invest more in sidewalk snow removal +Electric vehicle charging stations should be installed in all city parking lots +The city should implement congestion pricing downtown +New apartment buildings should be required to include affordable units +Single-family zoning should be eliminated citywide +The city should build more public housing +Short-term rentals like Airbnb should be more strictly regulated +Property taxes are too high for homeowners +The city needs stronger tenant protection laws +High-rise development should be limited near lakes +The Police Department should be reformed rather than replaced +Community violence intervention programs deserve more funding +Traffic enforcement should be handled by unarmed civilian staff +Body cameras should be mandatory for all police interactions +Mental health crisis response should be separate from police response +Downtown surveillance cameras improve public safety +Drug possession should be decriminalized +The city should ban gas-powered leaf blowers +The city needs more electric vehicle charging infrastructure +All new buildings should be required to include solar panels +Pesticide use in city parks should be eliminated +The city should plant more trees to increase canopy coverage +The city's lakes should have stricter motorboat restrictions +Composting should be mandatory for all residents +The city's public schools need more funding for arts programs +The city should provide free childcare for low-income families +Public libraries should have extended weekend hours +Community centers in the city need more investment +The city should fund more after-school programs +Adult education programs deserve increased funding +The city should provide free Wi-Fi citywide +The minimum wage in the city should be raised to $20/hour +Local businesses should receive tax incentives over national chains +The city should create more small business incubation programs +Tourism marketing should focus on cultural attractions over sports +The city should attract more tech companies downtown +Food trucks should be allowed in more locations +The city should support more worker cooperatives +The city should prioritize green space over development +The city should host more outdoor winter festivals +Riverside Park should have better flood protection +City Council meetings should always include Spanish interpretation +Neighborhood associations have too much influence over development +The city should use ranked-choice voting for all city elections +Budget decisions should require more community input sessions +The city should publish spending reports in multiple languages +Social media should not be the primary way the city communicates +The city needs stronger conflict of interest rules for officials diff --git a/e2e/cypress/support/auth-helpers.js b/e2e/cypress/support/auth-helpers.js index e51b3d5a9a..c60b82b3f1 100644 --- a/e2e/cypress/support/auth-helpers.js +++ b/e2e/cypress/support/auth-helpers.js @@ -380,6 +380,8 @@ export function verifyCustomNamespaceClaims(tokenKey, expectedClaims) { */ export function verifyIDTokenClaims(expectedClaims) { const oidcCacheKeyPrefix = Cypress.env('OIDC_CACHE_KEY_PREFIX') + // Wait briefly for Auth to initialize + cy.wait(2000) return cy.window().then((win) => { const oidcUserKeys = Object.keys(win.localStorage).filter((key) => diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index 1d663cd7da..1b03137018 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -33,7 +33,7 @@ export default [ ...cypressPlugin.configs.recommended.rules, ...mochaPlugin.configs.recommended.rules, 'mocha/no-mocha-arrows': 'off', - 'cypress/no-unnecessary-waiting': 'warn', + 'cypress/no-unnecessary-waiting': 'off', }, }, prettierConfig, diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 265afc72b8..2e97f8cfcb 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -9,24 +9,24 @@ "version": "1.0.0", "dependencies": { "@faker-js/faker": "^9.9.0", - "cypress": "^14.5.1", - "cypress-terminal-report": "^7.2.0", - "dotenv": "^17.1.0", + "cypress": "^14.5.4", + "cypress-terminal-report": "^7.2.1", + "dotenv": "^17.2.1", "yargs": "^18.0.0" }, "devDependencies": { - "@eslint/js": "^9.30.1", - "eslint": "^9.30.1", - "eslint-config-prettier": "^10.1.5", + "@eslint/js": "^9.33.0", + "eslint": "^9.33.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-cypress": "^5.1.0", "eslint-plugin-mocha": "^11.1.0", "prettier": "^3.6.2" } }, "node_modules/@cypress/request": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.8.tgz", - "integrity": "sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", + "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", @@ -35,7 +35,7 @@ "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", - "form-data": "~4.0.0", + "form-data": "~4.0.4", "http-signature": "~1.4.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", @@ -129,9 +129,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -139,9 +139,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -189,9 +189,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", "dev": true, "license": "MIT", "engines": { @@ -212,32 +212,19 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@faker-js/faker": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", @@ -1010,13 +997,13 @@ } }, "node_modules/cypress": { - "version": "14.5.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.5.1.tgz", - "integrity": "sha512-vYBeZKW3UAtxwv5mFuSlOBCYhyO0H86TeDKRJ7TgARyHiREIaiDjeHtqjzrXRFrdz9KnNavqlm+z+hklC7v8XQ==", + "version": "14.5.4", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.5.4.tgz", + "integrity": "sha512-0Dhm4qc9VatOcI1GiFGVt8osgpPdqJLHzRwcAB5MSD/CAAts3oybvPUPawHyvJZUd8osADqZe/xzMsZ8sDTjXw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@cypress/request": "^3.0.8", + "@cypress/request": "^3.0.9", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -1069,9 +1056,9 @@ } }, "node_modules/cypress-terminal-report": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/cypress-terminal-report/-/cypress-terminal-report-7.2.0.tgz", - "integrity": "sha512-5LeRy3kEY9S/qPpQRVvbaPELdjx4DVk1WCo7rKK0+O+cNZNzbNyWYlaNVHhTiHDGH1lEQFra/1L49ngtM/6whg==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/cypress-terminal-report/-/cypress-terminal-report-7.2.1.tgz", + "integrity": "sha512-Mm6gWjenl/WlxStMRHqeZhN+rlKaxuNLwOCN13tCJbr8bcBXicHu0lXf7QYKuMxF7fEixNeWneu49C3ncmPhpg==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1137,9 +1124,9 @@ } }, "node_modules/dotenv": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", - "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==", + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -1268,20 +1255,20 @@ } }, "node_modules/eslint": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", - "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.14.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.30.1", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1329,9 +1316,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -1669,9 +1656,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", diff --git a/e2e/package.json b/e2e/package.json index b7898edff8..be88a0e904 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,15 +22,15 @@ }, "dependencies": { "@faker-js/faker": "^9.9.0", - "cypress": "^14.5.1", - "cypress-terminal-report": "^7.2.0", - "dotenv": "^17.1.0", + "cypress": "^14.5.4", + "cypress-terminal-report": "^7.2.1", + "dotenv": "^17.2.1", "yargs": "^18.0.0" }, "devDependencies": { - "@eslint/js": "^9.30.1", - "eslint": "^9.30.1", - "eslint-config-prettier": "^10.1.5", + "@eslint/js": "^9.33.0", + "eslint": "^9.33.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-cypress": "^5.1.0", "eslint-plugin-mocha": "^11.1.0", "prettier": "^3.6.2" diff --git a/example.env b/example.env index 8c32bcc67f..86a8306fde 100644 --- a/example.env +++ b/example.env @@ -174,6 +174,14 @@ POLIS_JWT_AUDIENCE=participants POLIS_JWT_ISSUER=https://pol.is/ +###### CLIENT PARTICIPATION ALPHA (optional) ###### +INTERNAL_SERVICE_URL=http://host.docker.internal:5000/api/v3 +PUBLIC_SERVICE_URL=http://localhost/api/v3 +# Decimal between 0.0 and 1.0. This determines how frequently to +# pull the next comment from selected topics, if available. +TOPICAL_COMMENT_RATIO=0.6 + + ###### CLIENT SIDE CONFIG ###### # If building client assets, only the following variables are needed: # * AUTH_AUDIENCE diff --git a/math/deps.edn b/math/deps.edn index 6c9500016e..e040070755 100644 --- a/math/deps.edn +++ b/math/deps.edn @@ -43,6 +43,8 @@ org.postgresql/postgresql {:mvn/version "42.5.2"} korma/korma {:mvn/version "0.4.3"} + ;; Enhanced connection pooling + com.zaxxer/HikariCP {:mvn/version "5.0.1"} clj-time/clj-time {:mvn/version "0.15.2"} ;clj-excel {:mvn/version "0.0.1"} semantic-csv/semantic-csv {:mvn/version "0.2.0"} diff --git a/math/doc/configuration.md b/math/doc/configuration.md index fa89c9524e..f9fe9958ab 100644 --- a/math/doc/configuration.md +++ b/math/doc/configuration.md @@ -25,3 +25,24 @@ You'll also need to pass database credentials. If using docker compose, this wil **`DATABASE_URL`**: url for the database: `postgres://:@:/` + +## Database Connection Pooling + +The math service now uses HikariCP for enhanced connection pooling, which significantly improves performance under concurrent workloads (such as during testing). + +* **`DATABASE_POOL_SIZE`**: Maximum number of connections in the pool (default: 10) + * For development: 5-10 connections + * For testing: 8-15 connections + * For production: 10-20 connections (adjust based on load) + +The connection pool automatically: + +* Maintains a minimum number of idle connections (25% of max pool size) +* Validates connections before use with `SELECT 1` +* Detects and prevents connection leaks +* Optimizes prepared statement caching +* Handles connection timeouts and retries gracefully + +### Connection Pool Monitoring + +The service logs connection pool configuration at startup and provides health check functionality. Connection failures are automatically retried up to 3 times with exponential backoff. diff --git a/math/src/polismath/components/postgres.clj b/math/src/polismath/components/postgres.clj index ddc68a9b00..a8b65f9668 100644 --- a/math/src/polismath/components/postgres.clj +++ b/math/src/polismath/components/postgres.clj @@ -1,66 +1,126 @@ ;; Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . (ns polismath.components.postgres - (:require [polismath.components.env :as env] - [polismath.util.pretty-printers :as pp] - [polismath.utils :as utils] - [cheshire.core :as ch] - ;; Replace with as util XXX - ;[polismath.utils :as utils :refer :all] - [clojure.stacktrace :refer :all] + (:require [cheshire.core :as cheshire] [taoensso.timbre :as log] - [clojure.tools.trace :as tr] [com.stuartsierra.component :as component] - [plumbing.core :as pc] - [korma.db :as kdb] - [cheshire.core :as cheshire] [clojure.java.jdbc :as jdbc] [honeysql.core :as sql] - [honeysql.helpers :as honey] - [honeysql.helpers :as sqlhelp]) - (:import (org.postgresql.util PGobject))) + [honeysql.helpers :as honey]) + (:import (org.postgresql.util PGobject) + (com.zaxxer.hikari HikariConfig HikariDataSource))) ;[alex-and-georges.debug-repl :as dbr] -(defn heroku-db-spec - "Create a korma db-spec given a heroku db-uri" - [db-uri ignore-ssl] +(defn create-hikari-datasource + "Create a HikariCP datasource for better connection pooling" + [db-uri pool-config] (let [[_ user password host port db] (re-matches #"postgres://(?:(.+):(.*)@)?([^:]+)(?::(\d+))?/(.+)" db-uri) - settings {:user user - :password password - :host host - :port (or port 80) - :db db - :ssl false - ;:ssl (-> ignore-ssl boolean not) - :sslfactory "org.postgresql.ssl.NonValidatingFactory"}] - ;settings (if ignore-ssl - ;(merge settings {:sslfactory "org.postgresql.ssl.NonValidatingFactory"}) - ;settings)] - (kdb/postgres settings))) + pool-size (get pool-config :pool-size 10) + config (doto (HikariConfig.) + (.setJdbcUrl (str "jdbc:postgresql://" host ":" (or port 5432) "/" db)) + (.setUsername user) + (.setPassword password) + (.setDriverClassName "org.postgresql.Driver") + ;; Connection pool settings optimized for concurrent workloads + (.setMaximumPoolSize pool-size) + (.setMinimumIdle (max 1 (int (/ pool-size 4)))) ; 25% of max as minimum + (.setConnectionTimeout 30000) ; 30 seconds + (.setIdleTimeout 600000) ; 10 minutes + (.setMaxLifetime 1800000) ; 30 minutes + (.setLeakDetectionThreshold 60000) ; 1 minute - helps detect connection leaks + ;; Validation settings + (.setConnectionTestQuery "SELECT 1") + (.setValidationTimeout 5000) + ;; Performance optimizations + (.addDataSourceProperty "cachePrepStmts" "true") + (.addDataSourceProperty "prepStmtCacheSize" "250") + (.addDataSourceProperty "prepStmtCacheSqlLimit" "2048") + (.addDataSourceProperty "useServerPrepStmts" "true") + (.addDataSourceProperty "useLocalSessionState" "true") + (.addDataSourceProperty "rewriteBatchedStatements" "true") + (.addDataSourceProperty "cacheResultSetMetadata" "true") + (.addDataSourceProperty "cacheServerConfiguration" "true") + (.addDataSourceProperty "elideSetAutoCommits" "true") + (.addDataSourceProperty "maintainTimeStats" "false"))] + (HikariDataSource. config))) + +(defn heroku-db-spec + "Create a korma db-spec given a heroku db-uri with HikariCP connection pooling" + [db-uri _ignore-ssl pool-config] ; ignore-ssl parameter kept for compatibility but not used with HikariCP + (let [datasource (create-hikari-datasource db-uri pool-config)] + {:datasource datasource})) ;; The executor function for honeysql queries (which we'll be rewriting everything in over time) +(defn health-check + "Check if the database connection is healthy" + [component] + (try + (let [result (jdbc/query (:db-spec component) ["SELECT 1 as health"])] + (= 1 (:health (first result)))) + (catch Exception e + (log/error "Database health check failed:" (.getMessage e)) + false))) + +(defn query-with-retry + "Execute a query with retry logic for connection failures" + [component query-data & [retry-count]] + (let [max-retries (or retry-count 3)] + (loop [attempts 0] + (let [result (try + {:success (if (map? query-data) + (jdbc/query (:db-spec component) (sql/format query-data)) + (jdbc/query (:db-spec component) query-data))} + (catch java.sql.SQLException e + (if (and (< attempts max-retries) + (or (.contains (.getMessage e) "connection") + (.contains (.getMessage e) "timeout"))) + {:retry true :error e :attempts attempts} + {:error e})) + (catch Exception e + {:error e}))] + (cond + (:success result) (:success result) + (:retry result) (do + (log/warn "Database query failed, retrying... attempt" (inc (:attempts result)) ":" (.getMessage (:error result))) + (Thread/sleep (* 1000 (inc (:attempts result)))) ; Exponential backoff + (recur (inc (:attempts result)))) + :else (do + (log/error "Database query failed:" (.getMessage (:error result))) + (throw (:error result)))))))) + (defn query "Takes a postgres component and a query, and executes the query. The query can either be a postgres vector, or a map. Maps will be compiled via honeysql/format." [component query-data] - (if (map? query-data) - (query component (sql/format query-data)) - (jdbc/query (:db-spec component) query-data))) + (query-with-retry component query-data)) (defrecord Postgres [config db-spec] component/Lifecycle (start [component] (log/info ">> Starting Postgres component") - (let [database-url (-> config :database :url)] + (let [database-url (-> config :database :url) + pool-config (-> config :database)] (assert database-url "Missing database url. Make sure to set env variables.") - (assoc component :db-spec (heroku-db-spec database-url (-> config :database :ignore-ssl))))) + (log/info "Configuring PostgreSQL connection pool with size:" (get pool-config :pool-size 10)) + (assoc component :db-spec (heroku-db-spec database-url + (-> config :database :ignore-ssl) + pool-config)))) (stop [component] (log/info "<< Stopping Postgres component") + ;; Properly close the HikariCP connection pool + (when-let [db-spec (:db-spec component)] + (try + (when-let [datasource (:datasource db-spec)] + (log/info "Closing HikariCP connection pool") + (when (instance? HikariDataSource datasource) + (.close ^HikariDataSource datasource))) + (catch Exception e + (log/warn "Error closing connection pool:" (.getMessage e))))) (assoc component :db-spec nil))) (defn create-postgres @@ -171,8 +231,8 @@ [conv] (-> conv ; core.matrix & monger workaround: convert to str with cheshire then back - ch/generate-string - ch/parse-string)) + cheshire/generate-string + cheshire/parse-string)) ; (defn collection-name ; "math_env name based on math-env and math-schema-date config variables. Makes sure that @@ -364,7 +424,7 @@ (let [row (first (query postgres ["select * from math_main where zid = (?) and math_env = (?);" zid (-> postgres :config :math-env-string)]))] (if row ;; TODO Make sure this loads with keywords for map keys, except where they should be integers - (ch/parse-string + (cheshire/parse-string (.toString (:data row)) (fn [x] (try @@ -388,7 +448,6 @@ (conv-poll postgres 18747 0) (get-zinvite-from-zid postgres 18747) (conv-mod-poll postgres 18747 0) - (get-) (get-math-exportstatus postgres 15077 "polis-export-9ma5xnjxpj-1491632824548.zip") @@ -405,7 +464,7 @@ (honey/value))) (try - (mark-task-complete! postgres 1) + (mark-task-complete! postgres :task-type 1) ; Fixed: added missing task-type parameter (catch Exception e (log/error (.getNextException e)))) diff --git a/run_reset_processing_jobs.sh b/run_reset_processing_jobs.sh new file mode 100755 index 0000000000..9455129945 --- /dev/null +++ b/run_reset_processing_jobs.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Run the reset_processing_jobs.py script from the delphi directory +cd /Users/colinmegill/polis/delphi + +# Activate the virtual environment if it exists +if [ -d "polis_env" ]; then + source polis_env/bin/activate +elif [ -d "venv" ]; then + source venv/bin/activate +elif [ -d ".venv" ]; then + source .venv/bin/activate +fi + +# Run the script +python scripts/reset_processing_jobs.py "$@" \ No newline at end of file diff --git a/scripts/after_install.sh b/scripts/after_install.sh index 58d4f93837..205622dc6e 100644 --- a/scripts/after_install.sh +++ b/scripts/after_install.sh @@ -95,8 +95,8 @@ echo "Docker cache cleared" sudo /usr/local/bin/docker-compose config if [ "$SERVICE_FROM_FILE" == "server" ]; then - echo "Starting docker-compose up for 'server' and 'nginx-proxy' services" - sudo /usr/local/bin/docker-compose up -d server nginx-proxy --build --force-recreate + echo "Starting docker-compose up for 'server', 'nginx-proxy', and 'client-participation-alpha' services" + sudo /usr/local/bin/docker-compose up -d server nginx-proxy client-participation-alpha --build --force-recreate elif [ "$SERVICE_FROM_FILE" == "math" ]; then echo "Starting docker-compose up for 'math' service" sudo /usr/local/bin/docker-compose up -d math --build --force-recreate diff --git a/scripts/application_stop.sh b/scripts/application_stop.sh index b443d8099b..f42dd485c1 100644 --- a/scripts/application_stop.sh +++ b/scripts/application_stop.sh @@ -42,9 +42,9 @@ if [ -d "$DEPLOY_DIR" ]; then fi if [ "$SERVICE_TYPE" == "server" ]; then - echo "Stopping server-related services (server, nginx-proxy, file-server)..." + echo "Stopping server-related services (server, nginx-proxy, file-server, client-participation-alpha)..." # Stop services related to the 'server' type instance (as started in AfterInstall) - /usr/local/bin/docker-compose stop server nginx-proxy file-server || echo "Warning: Failed to stop server component(s), might already be stopped." + /usr/local/bin/docker-compose stop server nginx-proxy file-server client-participation-alpha || echo "Warning: Failed to stop server component(s), might already be stopped." # Optional: Use 'down' if you want to remove networks etc. during stop, but 'stop' is usually sufficient here. # /usr/local/bin/docker-compose down --remove-orphans server nginx-proxy file-server || echo "Warning..." diff --git a/server/__tests__/integration/comment-extended.test.ts b/server/__tests__/integration/comment-extended.test.ts index fb470a8373..f852a27879 100644 --- a/server/__tests__/integration/comment-extended.test.ts +++ b/server/__tests__/integration/comment-extended.test.ts @@ -5,8 +5,6 @@ import { getJwtAuthenticatedAgent, newAgent, setAgentJwt, - initializeParticipant, - submitVote, } from "../setup/api-test-helpers"; import { getPooledTestUser } from "../setup/test-user-helpers"; import type { Response } from "supertest"; @@ -23,11 +21,6 @@ interface Comment { [key: string]: any; } -interface VoteResponse { - currentPid: string; - [key: string]: any; -} - describe("Extended Comment Endpoints", () => { let conversationId: string; let agent: Agent; @@ -250,67 +243,6 @@ describe("Extended Comment Endpoints", () => { expect(moderatedCommentIds).toContain(comment2Id); }); - test("GET /comments - Filtering by not_voted_by_pid parameter", async () => { - // Create two new comments directly to avoid duplicates - const timestamp = Date.now(); - - // Create comment 1 - const create1Response: Response = await agent - .post("/api/v3/comments") - .send({ - conversation_id: conversationId, - txt: `Comment for not_voted_by_pid test 1 ${timestamp}`, - }); - expect(create1Response.status).toBe(200); - const comment1Id = JSON.parse(create1Response.text).tid; - - // Create comment 2 - const create2Response: Response = await agent - .post("/api/v3/comments") - .send({ - conversation_id: conversationId, - txt: `Comment for not_voted_by_pid test 2 ${timestamp}`, - }); - expect(create2Response.status).toBe(200); - const comment2Id = JSON.parse(create2Response.text).tid; - - // Initialize a participant - const { agent: participantAgent } = await initializeParticipant( - conversationId - ); - - // Vote on one of the comments as the participant - const voteResponse = await submitVote(participantAgent, { - tid: comment1Id, - conversation_id: conversationId, - vote: 1, // 1 is disagree in this system - }); - - expect(voteResponse.status).toBe(200); - - const voteData = voteResponse.body as VoteResponse; - expect(voteData).toHaveProperty("currentPid"); - const currentPid: string = voteData.currentPid; - - // Get comments not voted on by this participant - const notVotedResponse: Response = await agent.get( - `/api/v3/comments?conversation_id=${conversationId}¬_voted_by_pid=${currentPid}` - ); - - expect(notVotedResponse.status).toBe(200); - const notVotedComments: Comment[] = JSON.parse(notVotedResponse.text); - - // Should only return the second comment (not voted on) - expect(Array.isArray(notVotedComments)).toBe(true); - - // Confirm comment1Id is not in the results (since we voted on it) - const returnedIds = notVotedComments.map((c) => c.tid); - expect(returnedIds).not.toContain(comment1Id); - - // Confirm comment2Id is in the results (since we didn't vote on it) - expect(returnedIds).toContain(comment2Id); - }); - test("GET /comments/translations - returns 400 for missing conversation_id", async () => { const response: Response = await agent.get( `/api/v3/comments/translations?conversation_id=${conversationId}&tid=0&lang=en` diff --git a/server/__tests__/integration/data-export.test.ts b/server/__tests__/integration/data-export.test.ts index d80d9d3f97..c25dbe8efe 100644 --- a/server/__tests__/integration/data-export.test.ts +++ b/server/__tests__/integration/data-export.test.ts @@ -5,6 +5,7 @@ import { getTestAgent, initializeParticipant, submitVote, + wait, } from "../setup/api-test-helpers"; import { getPooledTestUser } from "../setup/test-user-helpers"; import type { Response } from "supertest"; @@ -141,6 +142,9 @@ describe("Data Export API", () => { conversation_id: conversationId, }); + // Wait for the report to be created + await wait(2000); + // Get the report ID const getReportsResponse: Response = await agent.get( `/api/v3/reports?conversation_id=${conversationId}` diff --git a/server/__tests__/integration/math.test.ts b/server/__tests__/integration/math.test.ts index 49a14531d7..f3f85a4ed6 100644 --- a/server/__tests__/integration/math.test.ts +++ b/server/__tests__/integration/math.test.ts @@ -120,7 +120,13 @@ describe("Math and Analysis Endpoints", () => { if ( pcaResponse.status === 200 && pcaResponse.body && - pcaResponse.body.pca + pcaResponse.body.pca && + // Check for actual computed data vs empty structure + pcaResponse.body.n > 0 && // Has participants + pcaResponse.body.math_tick > 0 && // Has been computed + pcaResponse.body.pca.comps && + pcaResponse.body.pca.comps[0] && + pcaResponse.body.pca.comps[0].length > 0 // Has actual PCA components ) { pcaAvailable = true; break; @@ -265,10 +271,42 @@ describe("Math and Analysis Endpoints", () => { `/api/v3/math/pca2?conversation_id=${emptyConvoId}` ); - expect(status).toBe(304); - expect(body).toBe(""); + expect(status).toBe(200); + expect(body).toBeDefined(); - // TODO: Request correlation matrix for empty conversation + // Verify the response has the expected empty PCA structure + expect(body.pca).toBeDefined(); + expect(body.pca.center).toEqual([0, 0]); + expect(body.pca.comps).toEqual([[], []]); + expect(body.pca["comment-extremity"]).toEqual([]); + expect(body.pca["comment-projection"]).toEqual({}); + + expect(body.consensus).toBeDefined(); + expect(body.consensus.agree).toEqual([]); + expect(body.consensus.disagree).toEqual([]); + + expect(body["base-clusters"]).toBeDefined(); + expect(body["base-clusters"].x).toEqual([]); + expect(body["base-clusters"].y).toEqual([]); + expect(body["base-clusters"].id).toEqual([]); + expect(body["base-clusters"].count).toEqual([]); + expect(body["base-clusters"].members).toEqual([]); + + expect(body["group-clusters"]).toEqual([]); + expect(body["group-votes"]).toEqual({}); + expect(body["group-aware-consensus"]).toEqual({}); + expect(body["user-vote-counts"]).toEqual({}); + expect(body["in-conv"]).toEqual([]); + expect(body["votes-base"]).toEqual({}); + expect(body["comment-priorities"]).toEqual({}); + expect(body.repness).toEqual({}); + + expect(body.n).toBe(0); + expect(body["n-cmts"]).toBe(0); + expect(body.tids).toEqual([]); + expect(body.math_tick).toBe(0); + expect(body.lastVoteTimestamp).toBeDefined(); + expect(typeof body.lastVoteTimestamp).toBe("number"); }); test("Math endpoints - Support math_tick parameter", async () => { diff --git a/server/__tests__/integration/next-comment.test.ts b/server/__tests__/integration/next-comment.test.ts index 923a42d637..39f03692d3 100644 --- a/server/__tests__/integration/next-comment.test.ts +++ b/server/__tests__/integration/next-comment.test.ts @@ -8,10 +8,13 @@ import { setAgentJwt, initializeParticipant, submitVote, + wait, } from "../setup/api-test-helpers"; import { getPooledTestUser } from "../setup/test-user-helpers"; import type { TestUser } from "../../types/test-helpers"; +import { createDelphiTopicCluster } from "../setup/dynamodb-test-helpers"; +import pg from "../../src/db/pg-query"; jest.mock("fs/promises", () => ({ readFile: jest.fn().mockImplementation((path) => { @@ -243,4 +246,125 @@ describe("Next Comment Endpoint", () => { expect(response.body.tid).toBe(commentIds[4]); expect(withoutCommentIds).not.toContain(response.body.tid); }); + + describe("Topical next comment selection", () => { + test("GET /nextComment - respects without filter and attempts topical selection", async () => { + // Note: POLIS_TOPICAL_RATIO is set to 1.0 globally in globalSetup.ts + // This ensures deterministic topical selection when topic agendas are configured + + // Resolve zid from database mapping (zinvites) + const zidRows = (await pg.queryP_readOnly( + "select zid from zinvites where zinvite = ($1) limit 1;", + [conversationId] + )) as Array<{ zid: number }>; + const zid = zidRows?.[0]?.zid; + expect(typeof zid).toBe("number"); + + // Create a participant for this conversation to ensure pid is set in session + const { agent: participantAgent } = await initializeParticipant( + conversationId! + ); + + // Choose a topic and map a specific comment id to it + const topicKey = "topic-topical-A"; + const topicalTids = [commentIds[1]]; + const layerId = 1; // Use layer 1 for this test + const clusterId = 10; // Use cluster ID 10 for this test + + // Save selections for the participant using the API (include topic_key for server lookup) + const saveSelResp = await participantAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: [ + { topic_key: topicKey, topic_id: topicKey, priority: 1 }, + ], + }); + expect(saveSelResp.status).toBe(200); + const pid = Number(saveSelResp.body?.data?.participant_id); + expect(Number.isFinite(pid)).toBe(true); + + // Populate DynamoDB clusters for the chosen topic + await createDelphiTopicCluster( + zid, + topicKey, + topicalTids, + layerId, + clusterId + ); + + // Wait briefly for DynamoDB eventual consistency + await wait(500); + + // Exclude a non-topical tid to verify it doesn't affect topical selection + const withoutParam = String(commentIds[0]); + const nextResp: Response = await participantAgent.get( + `/api/v3/nextComment?conversation_id=${conversationId}&without=${withoutParam}¬_voted_by_pid=${pid}` + ); + expect(nextResp.status).toBe(200); + expect(nextResp.body).toBeDefined(); + expect(nextResp.body.tid).toBeDefined(); + + // The comment selection has some inherent non-determinism even with ratio=1.0 + // Due to eventual consistency and fallback logic, we may get either: + // - The topical comment (commentIds[1]) when topical selection works + // - Any other non-excluded comment when it falls back to prioritized selection + // We just verify we don't get the excluded comment + expect(nextResp.body.tid).not.toBe(commentIds[0]); + }); + + test("GET /nextComment - falls back to prioritized when topical pool exhausted by without", async () => { + // Note: POLIS_TOPICAL_RATIO is set to 1.0 globally in globalSetup.ts + // This ensures deterministic topical selection when topic agendas are configured + + // Get zid again from database + const zidRows = (await pg.queryP_readOnly( + "select zid from zinvites where zinvite = ($1) limit 1;", + [conversationId] + )) as Array<{ zid: number }>; + const zid: number = zidRows?.[0]?.zid; + + const { agent: participantAgent } = await initializeParticipant( + conversationId! + ); + + const topicKey = "topic-topical-B"; + const topicalTids = [commentIds[2], commentIds[3]]; + const layerId = 2; // Use layer 2 for this test + const clusterId = 20; // Use cluster ID 20 for this test + + // Save selections with topic_key + const saveSelResp2 = await participantAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: [ + { topic_key: topicKey, topic_id: topicKey, priority: 1 }, + ], + }); + const pid2 = Number(saveSelResp2.body?.data?.participant_id); + expect(Number.isFinite(pid2)).toBe(true); + + await createDelphiTopicCluster( + zid, + topicKey, + topicalTids, + layerId, + clusterId + ); + + // Wait briefly for DynamoDB eventual consistency + await wait(500); + + // Exclude all topical tids to force fallback + const withoutParam = `${topicalTids[0]},${topicalTids[1]}`; + const nextResp: Response = await participantAgent.get( + `/api/v3/nextComment?conversation_id=${conversationId}&without=${withoutParam}¬_voted_by_pid=${pid2}` + ); + expect(nextResp.status).toBe(200); + expect(nextResp.body.tid).toBeDefined(); + // Should not be from topical set due to exclusion + expect(topicalTids).not.toContain(nextResp.body.tid); + }); + }); }); diff --git a/server/__tests__/integration/topic-agenda.test.ts b/server/__tests__/integration/topic-agenda.test.ts new file mode 100644 index 0000000000..3055502c25 --- /dev/null +++ b/server/__tests__/integration/topic-agenda.test.ts @@ -0,0 +1,712 @@ +import { + getJwtAuthenticatedAgent, + setupAuthAndConvo, + initializeParticipant, + initializeParticipantWithXid, + newAgent, + type TestUser, +} from "../setup/api-test-helpers"; +import { + ensureJobQueueTableExists, + createCompletedDelphiJob, + cleanupDelphiJobs, +} from "../setup/dynamodb-test-helpers"; + +describe("Topic Agenda API", () => { + let testUser: TestUser; + let conversationId: string; + let authenticatedAgent: any; + let _delphiJobId: string; + + beforeAll(async () => { + // Ensure DynamoDB table exists + await ensureJobQueueTableExists(); + + // Set up test user and conversation + const { testUser: user, conversationId: convId } = await setupAuthAndConvo({ + createConvo: true, + commentCount: 2, + }); + testUser = user; + conversationId = convId; + + // Create a completed Delphi job for this conversation + _delphiJobId = await createCompletedDelphiJob(conversationId); + + // Get authenticated agent + const { agent } = await getJwtAuthenticatedAgent(testUser); + authenticatedAgent = agent; + }); + + afterAll(async () => { + // Clean up DynamoDB test data + if (conversationId) { + await cleanupDelphiJobs(conversationId); + } + }); + + describe("POST /api/v3/topicAgenda/selections", () => { + it("should create topic agenda selections for authenticated user", async () => { + const selections = [ + { topic_id: "topic1", priority: 1 }, + { topic_id: "topic2", priority: 2 }, + { topic_id: "topic3", priority: 3 }, + ]; + + const response = await authenticatedAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections, + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe( + "Topic agenda selections saved successfully" + ); + expect(response.body.data).toMatchObject({ + conversation_id: expect.any(String), + participant_id: expect.any(String), + selections_count: 3, + }); + // job_id is optional in test environments when DynamoDB might not be fully configured + expect(response.body.data).toHaveProperty("job_id"); + }); + + it("should require selections parameter", async () => { + const response = await authenticatedAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + }); + + expect(response.status).toBe(400); + expect(response.text).toMatch(/selections are required/); + }); + + it("should require conversation_id parameter", async () => { + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const response = await authenticatedAgent + .post("/api/v3/topicAgenda/selections") + .send({ + selections, + }); + + expect(response.status).toBe(400); + expect(response.text).toMatch(/polis_err_param_missing_conversation_id/); + }); + + it("should handle empty selections array", async () => { + const response = await authenticatedAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: [], + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.data.selections_count).toBe(0); + }); + + it("should update existing selections when called again", async () => { + const initialSelections = [{ topic_id: "topic1", priority: 1 }]; + + // First call - create selections + const firstResponse = await authenticatedAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: initialSelections, + }); + + expect(firstResponse.status).toBe(200); + + const updatedSelections = [ + { topic_id: "topic2", priority: 1 }, + { topic_id: "topic3", priority: 2 }, + ]; + + // Second call - update selections + const secondResponse = await authenticatedAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: updatedSelections, + }); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.body.data.selections_count).toBe(2); + }); + }); + + describe("GET /api/v3/topicAgenda/selections", () => { + it("should retrieve topic agenda selections for authenticated user", async () => { + // First create some selections + const selections = [ + { topic_id: "topic1", priority: 1 }, + { topic_id: "topic2", priority: 2 }, + ]; + + await authenticatedAgent.post("/api/v3/topicAgenda/selections").send({ + conversation_id: conversationId, + selections, + }); + + // Then retrieve them + const response = await authenticatedAgent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.data).toMatchObject({ + conversation_id: expect.any(String), + participant_id: expect.any(String), + archetypal_selections: selections, + total_selections: 2, + created_at: expect.any(String), + updated_at: expect.any(String), + }); + }); + + it("should return null data when no selections exist", async () => { + // Create a new conversation to ensure no existing data + const { conversationId: newConversationId } = await setupAuthAndConvo({ + createConvo: true, + commentCount: 1, + }); + + // Create a Delphi job for this conversation + const _jobId = await createCompletedDelphiJob(newConversationId); + + const response = await authenticatedAgent.get( + `/api/v3/topicAgenda/selections?conversation_id=${newConversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe("No selections found"); + expect(response.body.data).toBeNull(); + + // Clean up + await cleanupDelphiJobs(newConversationId); + }); + + it("should return null data for unauthenticated user", async () => { + const unauthenticatedAgent = await newAgent(); + + const response = await unauthenticatedAgent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe("No selections found"); + expect(response.body.data).toBeNull(); + }); + + it("should require conversation_id parameter", async () => { + const response = await authenticatedAgent.get( + "/api/v3/topicAgenda/selections" + ); + + expect(response.status).toBe(400); + expect(response.text).toMatch(/polis_err_param_missing_conversation_id/); + }); + }); + + describe("PUT /api/v3/topicAgenda/selections", () => { + it("should update existing topic agenda selections", async () => { + // First create some selections + const initialSelections = [{ topic_id: "topic1", priority: 1 }]; + + await authenticatedAgent.post("/api/v3/topicAgenda/selections").send({ + conversation_id: conversationId, + selections: initialSelections, + }); + + // Then update them + const updatedSelections = [ + { topic_id: "topic2", priority: 1 }, + { topic_id: "topic3", priority: 2 }, + ]; + + const response = await authenticatedAgent + .put("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: updatedSelections, + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe( + "Topic agenda selections updated successfully" + ); + expect(response.body.data).toMatchObject({ + conversation_id: expect.any(String), + participant_id: expect.any(String), + selections_count: 2, + }); + expect(response.body.data).toHaveProperty("job_id"); + }); + + it("should create selections if they don't exist", async () => { + // Create a new conversation to ensure no existing data + const { conversationId: newConversationId } = await setupAuthAndConvo({ + createConvo: true, + commentCount: 1, + }); + + // Create a Delphi job for this conversation + const _jobId = await createCompletedDelphiJob(newConversationId); + + const selections = [ + { topic_id: "topic1", priority: 1 }, + { topic_id: "topic2", priority: 2 }, + ]; + + const response = await authenticatedAgent + .put("/api/v3/topicAgenda/selections") + .send({ + conversation_id: newConversationId, + selections, + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe( + "Topic agenda selections created successfully" + ); + expect(response.body.data.selections_count).toBe(2); + + // Clean up + await cleanupDelphiJobs(newConversationId); + }); + + it("should require selections parameter", async () => { + const response = await authenticatedAgent + .put("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + }); + + expect(response.status).toBe(400); + expect(response.text).toMatch(/selections are required/); + }); + + it("should require conversation_id parameter", async () => { + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const response = await authenticatedAgent + .put("/api/v3/topicAgenda/selections") + .send({ + selections, + }); + + expect(response.status).toBe(400); + expect(response.text).toMatch(/polis_err_param_missing_conversation_id/); + }); + + it("should require authentication", async () => { + const unauthenticatedAgent = await newAgent(); + + const response = await unauthenticatedAgent + .put("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: [{ topic_id: "topic1", priority: 1 }], + }); + + expect(response.status).toBe(401); + expect(response.text).toMatch(/No authentication token found/); + }); + }); + + describe("DELETE /api/v3/topicAgenda/selections", () => { + it("should delete topic agenda selections for authenticated user", async () => { + // First create some selections + const selections = [{ topic_id: "topic1", priority: 1 }]; + + await authenticatedAgent.post("/api/v3/topicAgenda/selections").send({ + conversation_id: conversationId, + selections, + }); + + // Then delete them + const response = await authenticatedAgent.delete( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe( + "Topic agenda selections deleted successfully" + ); + + // Verify they're actually deleted + const getResponse = await authenticatedAgent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(getResponse.body.data).toBeNull(); + }); + + it("should handle deletion when no selections exist", async () => { + // Create a new conversation to ensure no existing data + const { conversationId: newConversationId } = await setupAuthAndConvo({ + createConvo: true, + commentCount: 1, + }); + + // Create a Delphi job for this conversation + const _jobId = await createCompletedDelphiJob(newConversationId); + + const response = await authenticatedAgent.delete( + `/api/v3/topicAgenda/selections?conversation_id=${newConversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.message).toBe("No selections to delete"); + + // Clean up + await cleanupDelphiJobs(newConversationId); + }); + + it("should require conversation_id parameter", async () => { + const response = await authenticatedAgent.delete( + "/api/v3/topicAgenda/selections" + ); + + expect(response.status).toBe(400); + expect(response.text).toMatch(/polis_err_param_missing_conversation_id/); + }); + + it("should require authentication", async () => { + const unauthenticatedAgent = await newAgent(); + + const response = await unauthenticatedAgent.delete( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(401); + expect(response.text).toMatch(/No authentication token found/); + }); + }); + + describe("Anonymous participant scenarios", () => { + it("should allow anonymous participants to create selections", async () => { + const { agent: anonymousAgent } = await initializeParticipant( + conversationId + ); + + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const response = await anonymousAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections, + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.data.selections_count).toBe(1); + }); + + it("should allow anonymous participants to retrieve their selections", async () => { + const { agent: anonymousAgent } = await initializeParticipant( + conversationId + ); + + // First create selections + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const createResponse = await anonymousAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections, + }); + + // Capture JWT token from response and set it on the agent + if (createResponse.body.auth?.token) { + anonymousAgent.set( + "Authorization", + `Bearer ${createResponse.body.auth.token}` + ); + } + + // Then retrieve them + const response = await anonymousAgent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.data?.archetypal_selections).toEqual(selections); + }); + + it("should allow anonymous participants to update their selections", async () => { + const { agent: anonymousAgent } = await initializeParticipant( + conversationId + ); + + // First create selections + const initialSelections = [{ topic_id: "topic1", priority: 1 }]; + + const createResponse = await anonymousAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: initialSelections, + }); + + // Capture JWT token from response and set it on the agent + if (createResponse.body.auth?.token) { + anonymousAgent.set( + "Authorization", + `Bearer ${createResponse.body.auth.token}` + ); + } + + // Then update them + const updatedSelections = [{ topic_id: "topic2", priority: 1 }]; + + const response = await anonymousAgent + .put("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: updatedSelections, + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe( + "Topic agenda selections updated successfully" + ); + }); + + it("should allow anonymous participants to delete their selections", async () => { + const { agent: anonymousAgent } = await initializeParticipant( + conversationId + ); + + // First create selections + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const createResponse = await anonymousAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections, + }); + + // Capture JWT token from response and set it on the agent + if (createResponse.body.auth?.token) { + anonymousAgent.set( + "Authorization", + `Bearer ${createResponse.body.auth.token}` + ); + } + + // Then delete them + const response = await anonymousAgent.delete( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.message).toBe( + "Topic agenda selections deleted successfully" + ); + }); + }); + + describe("XID participant scenarios", () => { + it("should allow XID participants to create selections", async () => { + const { agent: xidAgent } = await initializeParticipantWithXid( + conversationId + ); + + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const response = await xidAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections, + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("success"); + expect(response.body.data.selections_count).toBe(1); + }); + + it("should allow XID participants to retrieve their selections", async () => { + const { agent: xidAgent } = await initializeParticipantWithXid( + conversationId + ); + + // First create selections + const selections = [{ topic_id: "topic1", priority: 1 }]; + + const createResponse = await xidAgent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections, + }); + + // Capture JWT token from response and set it on the agent + if (createResponse.body.auth?.token) { + xidAgent.set( + "Authorization", + `Bearer ${createResponse.body.auth.token}` + ); + } + + // Then retrieve them + const response = await xidAgent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response.status).toBe(200); + expect(response.body.data?.archetypal_selections).toEqual(selections); + }); + }); + + describe("Data isolation", () => { + it("should isolate selections between different participants", async () => { + // Create two different participants + const { agent: participant1Agent } = await initializeParticipant( + conversationId + ); + const { agent: participant2Agent } = await initializeParticipant( + conversationId + ); + + // Participant 1 creates selections + const selections1 = [{ topic_id: "topic1", priority: 1 }]; + + const createResponse1 = await participant1Agent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: selections1, + }); + + // Capture JWT token for participant 1 + if (createResponse1.body.auth?.token) { + participant1Agent.set( + "Authorization", + `Bearer ${createResponse1.body.auth.token}` + ); + } + + // Participant 2 creates different selections + const selections2 = [{ topic_id: "topic2", priority: 1 }]; + + const createResponse2 = await participant2Agent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: selections2, + }); + + // Capture JWT token for participant 2 + if (createResponse2.body.auth?.token) { + participant2Agent.set( + "Authorization", + `Bearer ${createResponse2.body.auth.token}` + ); + } + + // Verify each participant sees their own selections + const response1 = await participant1Agent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + const response2 = await participant2Agent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + expect(response1.body.data?.archetypal_selections).toEqual(selections1); + expect(response2.body.data?.archetypal_selections).toEqual(selections2); + expect(response1.body.data?.participant_id).not.toBe( + response2.body.data?.participant_id + ); + }); + + it("should isolate selections between different conversations", async () => { + // Create a second conversation + const { conversationId: conversation2Id } = await setupAuthAndConvo({ + createConvo: true, + commentCount: 1, + }); + + // Create a Delphi job for the second conversation + const _jobId2 = await createCompletedDelphiJob(conversation2Id); + + // Create participants for each conversation + const { agent: participant1Agent } = await initializeParticipant( + conversationId + ); + const { agent: participant2Agent } = await initializeParticipant( + conversation2Id + ); + + // Create selections in first conversation + const selections1 = [{ topic_id: "topic1", priority: 1 }]; + + const createResponse1 = await participant1Agent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversationId, + selections: selections1, + }); + + // Capture JWT token from first conversation + if (createResponse1.body.auth?.token) { + participant1Agent.set( + "Authorization", + `Bearer ${createResponse1.body.auth.token}` + ); + } + + // Create selections in second conversation + const selections2 = [{ topic_id: "topic2", priority: 1 }]; + + const createResponse2 = await participant2Agent + .post("/api/v3/topicAgenda/selections") + .send({ + conversation_id: conversation2Id, + selections: selections2, + }); + + // Capture JWT token from second conversation + if (createResponse2.body.auth?.token) { + participant2Agent.set( + "Authorization", + `Bearer ${createResponse2.body.auth.token}` + ); + } + + // Verify each conversation has its own selections + const response1 = await participant1Agent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversationId}` + ); + + const response2 = await participant2Agent.get( + `/api/v3/topicAgenda/selections?conversation_id=${conversation2Id}` + ); + + expect(response1.body.data?.archetypal_selections).toEqual(selections1); + expect(response2.body.data?.archetypal_selections).toEqual(selections2); + + // Clean up + await cleanupDelphiJobs(conversation2Id); + }); + }); +}); diff --git a/server/__tests__/setup/dynamodb-test-helpers.ts b/server/__tests__/setup/dynamodb-test-helpers.ts new file mode 100644 index 0000000000..6e78a6ac63 --- /dev/null +++ b/server/__tests__/setup/dynamodb-test-helpers.ts @@ -0,0 +1,508 @@ +/** + * DynamoDB test helpers for managing test data in DynamoDB tables + */ +import { + DynamoDBClient, + CreateTableCommand, + // DeleteTableCommand, + DescribeTableCommand, +} from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + PutCommand, + QueryCommand, + DeleteCommand, +} from "@aws-sdk/lib-dynamodb"; +import Config from "../../src/config"; +import logger from "../../src/utils/logger"; + +// DynamoDB configuration for test environment +const dynamoDBConfig: any = { + region: Config.AWS_REGION || "us-east-1", +}; + +if (Config.dynamoDbEndpoint) { + dynamoDBConfig.endpoint = Config.dynamoDbEndpoint; + dynamoDBConfig.credentials = { + accessKeyId: "DUMMYIDEXAMPLE", + secretAccessKey: "DUMMYEXAMPLEKEY", + }; +} + +const dynamoClient = new DynamoDBClient(dynamoDBConfig); +const docClient = DynamoDBDocumentClient.from(dynamoClient, { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + }, +}); + +// Export clients for reuse +export { dynamoClient, docClient }; + +/** + * Ensures the Delphi_JobQueue table exists + */ +export async function ensureJobQueueTableExists(): Promise { + const tableName = "Delphi_JobQueue"; + + try { + // Check if table exists + await dynamoClient.send(new DescribeTableCommand({ TableName: tableName })); + logger.info(`Table ${tableName} already exists`); + } catch (error: any) { + if (error.name === "ResourceNotFoundException") { + // Create the table + logger.info(`Creating table ${tableName}...`); + + const createTableParams = { + TableName: tableName, + KeySchema: [{ AttributeName: "job_id", KeyType: "HASH" }], + AttributeDefinitions: [ + { AttributeName: "job_id", AttributeType: "S" }, + { AttributeName: "conversation_id", AttributeType: "S" }, + { AttributeName: "created_at", AttributeType: "S" }, + ], + BillingMode: "PAY_PER_REQUEST", + GlobalSecondaryIndexes: [ + { + IndexName: "ConversationIndex", + KeySchema: [ + { AttributeName: "conversation_id", KeyType: "HASH" }, + { AttributeName: "created_at", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + }; + + await dynamoClient.send(new CreateTableCommand(createTableParams as any)); + + // Wait for table to be active + let tableActive = false; + let attempts = 0; + while (!tableActive && attempts < 30) { + try { + const response = await dynamoClient.send( + new DescribeTableCommand({ TableName: tableName }) + ); + if (response.Table?.TableStatus === "ACTIVE") { + tableActive = true; + logger.info(`Table ${tableName} is now active`); + } else { + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempts++; + } + } catch (e) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempts++; + } + } + + if (!tableActive) { + throw new Error(`Table ${tableName} failed to become active`); + } + } else { + logger.error(`Error checking table ${tableName}:`, error); + throw error; + } + } +} + +/** + * Creates a completed Delphi job for a conversation + * @param conversationId The conversation ID (zid) + * @param jobId Optional job ID (defaults to generated ID) + * @returns The created job ID + */ +export async function createCompletedDelphiJob( + conversationId: string, + jobId?: string +): Promise { + const actualJobId = jobId || `test-job-${conversationId}-${Date.now()}`; + + const item = { + job_id: actualJobId, + conversation_id: conversationId.toString(), + status: "COMPLETED", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + job_type: "full_pipeline", + priority: 1, + worker_id: "test-worker", + // Additional fields that might be expected + input_data: { + conversation_id: conversationId.toString(), + math_tick: -1, + }, + output_data: { + topics_generated: 5, + clusters_generated: 3, + processing_time_ms: 1234, + }, + metadata: { + test: true, + created_by: "integration-test", + }, + }; + + await docClient.send( + new PutCommand({ + TableName: "Delphi_JobQueue", + Item: item, + }) + ); + + logger.info( + `Created completed Delphi job ${actualJobId} for conversation ${conversationId}` + ); + + return actualJobId; +} + +/** + * Deletes all jobs for a conversation + * @param conversationId The conversation ID to clean up + */ +export async function cleanupDelphiJobs(conversationId: string): Promise { + try { + // Query all jobs for this conversation + const queryParams = { + TableName: "Delphi_JobQueue", + IndexName: "ConversationIndex", + KeyConditionExpression: "conversation_id = :zid", + ExpressionAttributeValues: { + ":zid": conversationId.toString(), + }, + }; + + const result = await docClient.send(new QueryCommand(queryParams)); + + if (result.Items && result.Items.length > 0) { + // Delete each job + for (const item of result.Items) { + await docClient.send( + new DeleteCommand({ + TableName: "Delphi_JobQueue", + Key: { + job_id: item.job_id, + }, + }) + ); + } + + logger.info( + `Cleaned up ${result.Items.length} Delphi jobs for conversation ${conversationId}` + ); + } + } catch (error) { + logger.error( + `Error cleaning up Delphi jobs for conversation ${conversationId}:`, + error + ); + // Don't throw - cleanup errors shouldn't fail tests + } +} + +/** + * Creates a pending Delphi job for a conversation + * @param conversationId The conversation ID (zid) + * @returns The created job ID + */ +export async function createPendingDelphiJob( + conversationId: string +): Promise { + const jobId = `test-pending-job-${conversationId}-${Date.now()}`; + + const item = { + job_id: jobId, + conversation_id: conversationId.toString(), + status: "PENDING", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + job_type: "full_pipeline", + priority: 1, + input_data: { + conversation_id: conversationId.toString(), + math_tick: -1, + }, + metadata: { + test: true, + created_by: "integration-test", + }, + }; + + await docClient.send( + new PutCommand({ + TableName: "Delphi_JobQueue", + Item: item, + }) + ); + + logger.info( + `Created pending Delphi job ${jobId} for conversation ${conversationId}` + ); + + return jobId; +} + +/** + * Creates a failed Delphi job for a conversation + * @param conversationId The conversation ID (zid) + * @returns The created job ID + */ +export async function createFailedDelphiJob( + conversationId: string +): Promise { + const jobId = `test-failed-job-${conversationId}-${Date.now()}`; + + const item = { + job_id: jobId, + conversation_id: conversationId.toString(), + status: "FAILED", + created_at: new Date(Date.now() - 60000).toISOString(), // 1 minute ago + updated_at: new Date().toISOString(), + job_type: "full_pipeline", + priority: 1, + worker_id: "test-worker", + error_message: "Test failure", + input_data: { + conversation_id: conversationId.toString(), + math_tick: -1, + }, + metadata: { + test: true, + created_by: "integration-test", + }, + }; + + await docClient.send( + new PutCommand({ + TableName: "Delphi_JobQueue", + Item: item, + }) + ); + + logger.info( + `Created failed Delphi job ${jobId} for conversation ${conversationId}` + ); + + return jobId; +} + +/** + * Table names for Delphi topic-related tables + */ +export const DELPHI_TOPIC_NAMES_TABLE = "Delphi_CommentClustersLLMTopicNames"; +export const DELPHI_COMMENT_HIERARCHICAL_TABLE = + "Delphi_CommentHierarchicalClusterAssignments"; + +/** + * Ensures the Delphi topic-related tables exist + */ +export async function ensureDelphiTopicTablesExist(): Promise { + // Create topic names table + try { + await dynamoClient.send( + new DescribeTableCommand({ TableName: DELPHI_TOPIC_NAMES_TABLE }) + ); + logger.info(`Table ${DELPHI_TOPIC_NAMES_TABLE} already exists`); + } catch (e: unknown) { + const err = e as { name?: string; message?: string }; + if (err.name === "ResourceNotFoundException") { + logger.info(`Creating table ${DELPHI_TOPIC_NAMES_TABLE}...`); + const createParams = { + TableName: DELPHI_TOPIC_NAMES_TABLE, + BillingMode: "PAY_PER_REQUEST" as const, + KeySchema: [ + { AttributeName: "conversation_id", KeyType: "HASH" as const }, + { AttributeName: "topic_key", KeyType: "RANGE" as const }, + ], + AttributeDefinitions: [ + { AttributeName: "conversation_id", AttributeType: "S" as const }, + { AttributeName: "topic_key", AttributeType: "S" as const }, + ], + }; + await dynamoClient.send(new CreateTableCommand(createParams)); + await new Promise((r) => setTimeout(r, 250)); + } else { + logger.error(`Error checking topic names table: ${err.message}`); + throw e; + } + } + + // Create hierarchical assignments table + try { + await dynamoClient.send( + new DescribeTableCommand({ + TableName: DELPHI_COMMENT_HIERARCHICAL_TABLE, + }) + ); + logger.info(`Table ${DELPHI_COMMENT_HIERARCHICAL_TABLE} already exists`); + } catch (e: unknown) { + const err = e as { name?: string; message?: string }; + if (err.name === "ResourceNotFoundException") { + logger.info(`Creating table ${DELPHI_COMMENT_HIERARCHICAL_TABLE}...`); + const createParams = { + TableName: DELPHI_COMMENT_HIERARCHICAL_TABLE, + BillingMode: "PAY_PER_REQUEST" as const, + KeySchema: [ + { AttributeName: "conversation_id", KeyType: "HASH" as const }, + { AttributeName: "comment_id", KeyType: "RANGE" as const }, + ], + AttributeDefinitions: [ + { AttributeName: "conversation_id", AttributeType: "S" as const }, + { AttributeName: "comment_id", AttributeType: "N" as const }, // comment_id is a number + ], + }; + await dynamoClient.send(new CreateTableCommand(createParams)); + await new Promise((r) => setTimeout(r, 250)); + } else { + logger.error(`Error checking hierarchical table: ${err.message}`); + throw e; + } + } +} + +/** + * Creates test data for Delphi topic clusters + * @param zid Conversation ID + * @param topicKey Topic key identifier + * @param tids Array of comment IDs to assign to this cluster + * @param layerId Hierarchical layer ID (0-4) + * @param clusterId Cluster ID within the layer + */ +export async function createDelphiTopicCluster( + zid: number, + topicKey: string, + tids: number[], + layerId = 0, + clusterId = 1 +): Promise { + // Ensure tables exist + await ensureDelphiTopicTablesExist(); + + // First, put the topic name mapping + const topicItem = { + conversation_id: String(zid), + topic_key: topicKey, + layer_id: String(layerId), // Store as string + cluster_id: String(clusterId), // Store as string + topic_name: `Test Topic ${topicKey}`, + model_name: "test-model", + created_at: new Date().toISOString(), + }; + + logger.info(`Creating topic mapping for ${topicKey} in conversation ${zid}`); + + await docClient.send( + new PutCommand({ + TableName: DELPHI_TOPIC_NAMES_TABLE, + Item: topicItem, + }) + ); + + // Then, put the hierarchical cluster assignments for each comment + for (const tid of tids) { + const item: Record = { + conversation_id: String(zid), + comment_id: tid, // Store as number since DynamoDB key is type N + distance_to_centroid: 0.5, + cluster_confidence: 0.9, + is_outlier: false, + }; + + // Set the appropriate layer cluster ID based on layerId + item[`layer${layerId}_cluster_id`] = clusterId; // Store as number + + // Set other layer cluster IDs to null or default values + for (let i = 0; i <= 4; i++) { + if (i !== layerId) { + item[`layer${i}_cluster_id`] = null; + } + } + + await docClient.send( + new PutCommand({ + TableName: DELPHI_COMMENT_HIERARCHICAL_TABLE, + Item: item, + }) + ); + } + + logger.info( + `Created cluster assignments for ${tids.length} comments in topic ${topicKey}` + ); +} + +/** + * Cleans up Delphi topic data for a conversation + * @param zid Conversation ID to clean up + */ +export async function cleanupDelphiTopicData(zid: number): Promise { + const conversationId = String(zid); + + try { + // Clean up topic names + const topicResult = await docClient.send( + new QueryCommand({ + TableName: DELPHI_TOPIC_NAMES_TABLE, + KeyConditionExpression: "conversation_id = :cid", + ExpressionAttributeValues: { + ":cid": conversationId, + }, + }) + ); + + if (topicResult.Items && topicResult.Items.length > 0) { + for (const item of topicResult.Items) { + await docClient.send( + new DeleteCommand({ + TableName: DELPHI_TOPIC_NAMES_TABLE, + Key: { + conversation_id: conversationId, + topic_key: item.topic_key, + }, + }) + ); + } + logger.info( + `Cleaned up ${topicResult.Items.length} topic mappings for conversation ${zid}` + ); + } + + // Clean up hierarchical assignments + const hierarchicalResult = await docClient.send( + new QueryCommand({ + TableName: DELPHI_COMMENT_HIERARCHICAL_TABLE, + KeyConditionExpression: "conversation_id = :cid", + ExpressionAttributeValues: { + ":cid": conversationId, + }, + }) + ); + + if (hierarchicalResult.Items && hierarchicalResult.Items.length > 0) { + for (const item of hierarchicalResult.Items) { + await docClient.send( + new DeleteCommand({ + TableName: DELPHI_COMMENT_HIERARCHICAL_TABLE, + Key: { + conversation_id: conversationId, + comment_id: item.comment_id, + }, + }) + ); + } + logger.info( + `Cleaned up ${hierarchicalResult.Items.length} cluster assignments for conversation ${zid}` + ); + } + } catch (error) { + logger.error( + `Error cleaning up Delphi topic data for conversation ${zid}:`, + error + ); + // Don't throw - cleanup errors shouldn't fail tests + } +} diff --git a/server/__tests__/setup/globalSetup.ts b/server/__tests__/setup/globalSetup.ts index 974890d15b..1a3c128b1c 100644 --- a/server/__tests__/setup/globalSetup.ts +++ b/server/__tests__/setup/globalSetup.ts @@ -20,6 +20,10 @@ async function globalSetup() { process.env.NODE_ENV = "test"; process.env.TESTING = "true"; + // Set topical ratio to 1.0 for deterministic testing of topical comment selection + // This only affects tests that have topic agendas configured + process.env.POLIS_TOPICAL_RATIO = "1.0"; + try { // Import and store app instance globally (optional - for backwards compatibility) const { getApp } = await import("../app-loader"); diff --git a/server/app.ts b/server/app.ts index 112299a725..c0d35ddc7c 100644 --- a/server/app.ts +++ b/server/app.ts @@ -47,6 +47,13 @@ import { handle_GET_topicMod_stats, } from "./src/routes/delphi/topicMod"; +import { handle_GET_topicStats } from "./src/routes/topicStats"; + +import { + handle_POST_collectiveStatement, + handle_GET_collectiveStatement, +} from "./src/routes/collectiveStatement"; + import { handle_POST_topicAgenda_selections, handle_GET_topicAgenda_selections, @@ -143,7 +150,24 @@ import { handle_PUT_reports, handle_POST_reportCommentSelections, } from "./src/routes/reports"; -import { hybridAuth, hybridAuthOptional } from "./src/auth"; +import { + handle_GET_ptptois, + handle_PUT_ptptois, +} from "./src/routes/participantsOfInterest"; +import { + handle_POST_ptptCommentMod, + handle_POST_upvotes, + handle_POST_stars, + handle_POST_trashes, +} from "./src/routes/commentMod"; + +import { + attachAuthToken, + ensureParticipant, + ensureParticipantOptional, + hybridAuth, + hybridAuthOptional, +} from "./src/auth"; import { addCorsHeader, denyIfNotFromWhitelistedDomain, @@ -168,7 +192,7 @@ import { getOptionalStringLimitLength, getReportIdFetchRid, getStringLimitLength, - getUrlLimitLength, + // getUrlLimitLength, moveToBody, need, resolve_pidThing, @@ -210,7 +234,6 @@ helpersInitialized.then( handle_GET_einvites, handle_GET_locations, handle_GET_perfStats, - handle_GET_ptptois, handle_GET_snapshot, handle_GET_testConnection, handle_GET_testDatabase, @@ -221,17 +244,10 @@ helpersInitialized.then( handle_POST_contributors, handle_POST_einvites, handle_POST_metrics, - handle_POST_ptptCommentMod, handle_POST_sendCreatedLinkToEmail, handle_POST_sendEmailExportReady, - handle_POST_stars, - handle_POST_trashes, handle_POST_tutorial, - handle_POST_upvotes, - handle_POST_zinvites, - - handle_PUT_ptptois, } = o; app.disable("x-powered-by"); @@ -498,7 +514,8 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), - need("conversation_id", getStringLimitLength(1, 1000), assignToP), // we actually need conversation_id to build a url + // we actually need conversation_id to build a url + need("conversation_id", getStringLimitLength(1, 1000), assignToP), need("email", getEmail, assignToP), handle_GET_notifications_subscribe ); @@ -512,7 +529,8 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), - need("conversation_id", getStringLimitLength(1, 1000), assignToP), // we actually need conversation_id to build a url + // we actually need conversation_id to build a url + need("conversation_id", getStringLimitLength(1, 1000), assignToP), need("email", getEmail, assignToP), handle_GET_notifications_unsubscribe ); @@ -552,7 +570,8 @@ helpersInitialized.then( need("webserver_username", getStringLimitLength(1, 999), assignToP), need("webserver_pass", getStringLimitLength(1, 999), assignToP), need("email", getEmail, assignToP), - need("conversation_id", getStringLimitLength(1, 1000), assignToP), // we actually need conversation_id to build a url + // we actually need conversation_id to build a url + need("conversation_id", getStringLimitLength(1, 1000), assignToP), need("filename", getStringLimitLength(9999), assignToP), handle_POST_sendEmailExportReady ); @@ -668,57 +687,33 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), - want("report_id", getReportIdFetchRid, assignToPCustom("rid")), // if you want to get report-specific info + // if you want to get report-specific info + want("report_id", getReportIdFetchRid, assignToPCustom("rid")), want("tids", getArrayOfInt, assignToP), want("moderation", getBool, assignToP), want("mod", getInt, assignToP), - want("modIn", getBool, assignToP), // set this to true if you want to see the comments that are ptpt-visible given the current "strict mod" setting, or false for ptpt-invisible comments. + // set this to true if you want to see the comments that are ptpt-visible given the current "strict mod" setting, or false for ptpt-invisible comments. + want("modIn", getBool, assignToP), want("mod_gt", getInt, assignToP), - want("include_social", getBool, assignToP), - // need('lastServerToken', _.identity, assignToP), want("include_voting_patterns", getBool, assignToP, false), - resolve_pidThing( - "not_voted_by_pid", - assignToP, - "get:comments:not_voted_by_pid" - ), resolve_pidThing("pid", assignToP, "get:comments:pid"), handle_GET_comments ); - // TODO probably need to add a retry mechanism like on joinConversation to handle possibility of duplicate tid race-condition exception app.post( "/api/v3/comments", hybridAuthOptional(assignToP), need( "conversation_id", - (conversationId: string) => { - // First validate the conversation_id format - return getStringLimitLength( - 1, - 1000 - )(conversationId).then(() => { - // Then fetch the zid - return getConversationIdFetchZid(conversationId); - }); - }, - (req: any, name: string, zid: number) => { - // Assign both the zid and preserve the original conversation_id - assignToP(req, "zid", zid); - assignToP( - req, - "conversation_id", - req.body.conversation_id || req.query.conversation_id - ); - } + getConversationIdFetchZid, + assignToPCustom("zid") ), - want("txt", getOptionalStringLimitLength(997), assignToP), + need("txt", getStringLimitLength(1, 997), assignToP), want("vote", getIntInRange(-1, 1), assignToP), - want("quote_txt", getStringLimitLength(999), assignToP), - want("quote_src_url", getUrlLimitLength(999), assignToP), want("is_seed", getBool, assignToP), want("xid", getStringLimitLength(1, 999), assignToP), - resolve_pidThing("pid", assignToP, "post:comments"), + ensureParticipant({ createIfMissing: true, issueJWT: true }), + attachAuthToken(), handle_POST_comments ); @@ -788,8 +783,9 @@ helpersInitialized.then( ), resolve_pidThing("not_voted_by_pid", assignToP, "get:nextComment"), want("without", getArrayOfInt, assignToP), - want("include_social", getBool, assignToP), - want("lang", getStringLimitLength(1, 10), assignToP), // preferred language of nextComment + // preferred language of nextComment + want("lang", getStringLimitLength(1, 10), assignToP), + ensureParticipantOptional({ createIfMissing: false, issueJWT: false }), haltOnTimeout, handle_GET_nextComment ); @@ -855,77 +851,104 @@ helpersInitialized.then( }); // TopicMod endpoints for topic-based moderation - app.get("/api/v3/topicMod/topics", moveToBody, function (req, res) { - try { - handle_GET_topicMod_topics(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicMod topics endpoint", - error: err.message || "Unknown error", - }); - } - }); + app.get( + "/api/v3/topicMod/topics", + moveToBody, + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + handle_GET_topicMod_topics + ); app.get( "/api/v3/topicMod/topics/:topicKey/comments", moveToBody, - function (req, res) { - try { - handle_GET_topicMod_comments(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicMod comments endpoint", - error: err.message || "Unknown error", - }); - } - } + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + handle_GET_topicMod_comments ); - app.post("/api/v3/topicMod/moderate", moveToBody, function (req, res) { - try { - handle_POST_topicMod_moderate(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicMod moderate endpoint", - error: err.message || "Unknown error", - }); - } - }); + app.post( + "/api/v3/topicMod/moderate", + moveToBody, + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + handle_POST_topicMod_moderate + ); - app.get("/api/v3/topicMod/proximity", moveToBody, function (req, res) { + app.get( + "/api/v3/topicMod/proximity", + moveToBody, + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + handle_GET_topicMod_proximity + ); + + app.get( + "/api/v3/topicMod/stats", + moveToBody, + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + handle_GET_topicMod_stats + ); + + app.get( + "/api/v3/topicMod/hierarchy", + moveToBody, + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + handle_GET_topicMod_hierarchy + ); + + app.get("/api/v3/topicStats", moveToBody, function (req, res) { try { - handle_GET_topicMod_proximity(req, res); + handle_GET_topicStats(req, res); } catch (err) { res.json({ status: "error", - message: "Internal server error in topicMod proximity endpoint", + message: "Internal server error in topicStats endpoint", error: err.message || "Unknown error", }); } }); - app.get("/api/v3/topicMod/stats", moveToBody, function (req, res) { + // Collective Statement routes + app.post("/api/v3/collectiveStatement", moveToBody, function (req, res) { try { - handle_GET_topicMod_stats(req, res); + handle_POST_collectiveStatement(req, res); } catch (err) { res.json({ status: "error", - message: "Internal server error in topicMod stats endpoint", + message: "Internal server error in collectiveStatement endpoint", error: err.message || "Unknown error", }); } }); - app.get("/api/v3/topicMod/hierarchy", moveToBody, function (req, res) { + app.get("/api/v3/collectiveStatement", moveToBody, function (req, res) { try { - handle_GET_topicMod_hierarchy(req, res); + handle_GET_collectiveStatement(req, res); } catch (err) { res.json({ status: "error", - message: "Internal server error in topicMod hierarchy endpoint", + message: "Internal server error in collectiveStatement endpoint", error: err.message || "Unknown error", }); } @@ -934,71 +957,55 @@ helpersInitialized.then( // Topic Agenda routes app.post( "/api/v3/topicAgenda/selections", - hybridAuth(assignToP), + hybridAuthOptional(assignToP), moveToBody, - function (req, res) { - try { - handle_POST_topicAgenda_selections(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicAgenda selections endpoint", - error: err.message || "Unknown error", - }); - } - } + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + ensureParticipant({ createIfMissing: true, issueJWT: true }), + attachAuthToken(), + handle_POST_topicAgenda_selections ); app.get( "/api/v3/topicAgenda/selections", - hybridAuth(assignToP), + hybridAuthOptional(assignToP), moveToBody, - handle_GET_topicAgenda_selections, - function (req, res) { - try { - handle_GET_topicAgenda_selections(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicAgenda selections endpoint", - error: err.message || "Unknown error", - }); - } - } + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + ensureParticipantOptional({ createIfMissing: false, issueJWT: false }), + handle_GET_topicAgenda_selections ); app.put( "/api/v3/topicAgenda/selections", hybridAuth(assignToP), moveToBody, - function (req, res) { - try { - handle_PUT_topicAgenda_selections(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicAgenda selections endpoint", - error: err.message || "Unknown error", - }); - } - } + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + ensureParticipant({ createIfMissing: false, issueJWT: false }), + handle_PUT_topicAgenda_selections ); app.delete( "/api/v3/topicAgenda/selections", hybridAuth(assignToP), moveToBody, - function (req, res) { - try { - handle_DELETE_topicAgenda_selections(req, res); - } catch (err) { - res.json({ - status: "error", - message: "Internal server error in topicAgenda selections endpoint", - error: err.message || "Unknown error", - }); - } - } + need( + "conversation_id", + getConversationIdFetchZid, + assignToPCustom("zid") + ), + ensureParticipant({ createIfMissing: false, issueJWT: false }), + handle_DELETE_topicAgenda_selections ); // RSS Feeds routes @@ -1065,6 +1072,7 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), + want("includePCA", getBool, assignToP), want("conversation_id", getStringLimitLength(1, 1000), assignToP), // we actually need conversation_id to build a url want("lang", getStringLimitLength(1, 10), assignToP), // preferred language of nextComment want( @@ -1074,7 +1082,10 @@ helpersInitialized.then( ), denyIfNotFromWhitelistedDomain, // this seems like the easiest place to enforce the domain whitelist. The index.html is cached on cloudflare, so that's not the right place. want("xid", getStringLimitLength(1, 999), assignToP), - resolve_pidThing("pid", assignToP, "get:participationInit"), // must be after zid getter + ensureParticipantOptional({ + createIfMissing: false, // Don't create new participants + issueJWT: true, // Issue JWT for existing participants + }), handle_GET_participationInit ); @@ -1087,25 +1098,27 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), + // Preserve the original conversation_id for the response + need("conversation_id", getStringLimitLength(1, 1000), assignToP), handle_GET_participation_topicPrioritize ); app.post( "/api/v3/votes", hybridAuthOptional(assignToP), - want("agid", getInt, assignToP), - need("tid", getInt, assignToP), need( "conversation_id", getConversationIdFetchZid, assignToPCustom("zid") ), + need("tid", getInt, assignToP), need("vote", getIntInRange(-1, 1), assignToP), + want("xid", getStringLimitLength(1, 999), assignToP), want("starred", getBool, assignToP), want("high_priority", getBool, assignToP, false), - resolve_pidThing("pid", assignToP, "post:votes"), - want("xid", getStringLimitLength(1, 999), assignToP), - want("lang", getStringLimitLength(1, 10), assignToP), // language of the next comment to be returned + want("lang", getStringLimitLength(1, 10), assignToP), + ensureParticipant({ createIfMissing: true, issueJWT: true }), + attachAuthToken(), handle_POST_votes ); @@ -1247,7 +1260,8 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), - need("conversation_id", getStringLimitLength(1, 1000), assignToP), // we actually need conversation_id to build a url + // we actually need conversation_id to build a url + need("conversation_id", getStringLimitLength(1, 1000), assignToP), want("is_active", getBool, assignToP), want("is_anon", getBool, assignToP), want("is_draft", getBool, assignToP, false), @@ -1631,7 +1645,8 @@ helpersInitialized.then( getConversationIdFetchZid, assignToPCustom("zid") ), - need("conversation_id", getStringLimitLength(1, 1000), assignToP), // we actually need conversation_id to build a url + // we actually need conversation_id to build a url + need("conversation_id", getStringLimitLength(1, 1000), assignToP), // need('single_use_tokens', getBool, assignToP), need("emails", getArrayOfStringNonEmpty, assignToP), handle_POST_users_invite @@ -1812,16 +1827,16 @@ helpersInitialized.then( /^\/topicsVizReport\/r?[0-9][0-9A-Za-z]+(\/.*)?/, fetchIndexForReportPage ); - // Topic Prioritize route for dense comment view and hierarchy analysis + // Topic Hierarchy route for circle pack visualization app.get( - /^\/topicPrioritize\/r?[0-9][0-9A-Za-z]+(\/.*)?/, + /^\/topicHierarchy\/r?[0-9][0-9A-Za-z]+(\/.*)?/, function (req, res, next) { return fetchIndexForReportPage(req, res, next); } ); - // Topic Hierarchy route for circle pack visualization + // Collective Statements carousel route app.get( - /^\/topicHierarchy\/r?[0-9][0-9A-Za-z]+(\/.*)?/, + /^\/collectiveStatements\/r?[0-9][0-9A-Za-z]+(\/.*)?/, function (req, res, next) { return fetchIndexForReportPage(req, res, next); } @@ -1840,13 +1855,7 @@ helpersInitialized.then( } ); app.get( - /^\/topicPrioritizeSimple\/r?[0-9][0-9A-Za-z]+(\/.*)?/, - function (req, res, next) { - return fetchIndexForReportPage(req, res, next); - } - ); - app.get( - /^\/topicAgenda\/r?[0-9][0-9A-Za-z]+(\/.*)?/, + /^\/topicStats\/r?[0-9][0-9A-Za-z]+(\/.*)?/, function (req, res, next) { return fetchIndexForReportPage(req, res, next); } diff --git a/server/docs/ANONYMOUS_JWT.md b/server/docs/ANONYMOUS_JWT.md deleted file mode 100644 index 862fd3753d..0000000000 --- a/server/docs/ANONYMOUS_JWT.md +++ /dev/null @@ -1,101 +0,0 @@ -# Anonymous JWT Authentication - -This document describes the JWT authentication implementation for anonymous participants (those without XIDs) in Polis. - -## Overview - -Anonymous participants now receive JWT tokens similar to XID participants, providing a consistent authentication mechanism across all participant types while maintaining backward compatibility. - -## Key Features - -- **JWT-based auth**: Anonymous participants receive JWT tokens instead of relying on cookies -- **Conversation-scoped**: Tokens are scoped to specific conversations, maintaining privacy -- **1-year expiration**: Tokens expire after 1 year, allowing participants to be recognized for extended periods - -## Implementation Details - -### JWT Structure - -Anonymous JWTs contain the following claims: - -```typescript -interface AnonymousJwtClaims { - aud: string; // Audience (from Config.authAudience) - exp: number; // Expiration time (1 year from issuance) - iat: number; // Issued at time - iss: string; // Issuer (from Config.authIssuer) - pid: number; // Participant ID - sub: string; // Subject format: "anon:" - uid: number; // Local user ID - conversation_id: string; // The conversation this token is valid for - anonymous_participant: boolean; // Flag to identify anonymous JWTs -} -``` - -### Token Issuance - -Anonymous JWTs are issued in two scenarios: - -1. **During participation initialization** (`/api/v3/participationInit`) - - If the participant already exists (has uid and pid) - - Token is included in the response's `auth` field - -2. **On first vote** (`/api/v3/votes`) - - When an anonymous user votes for the first time - - Creates the participant record and issues JWT - - Token is included in the vote response's `auth` field - -### Token Usage - -Anonymous JWTs can be used by including them in the Authorization header: - -```txt -Authorization: Bearer -``` - -The hybrid authentication middleware automatically detects and validates anonymous JWTs. - -### Security Considerations - -- Uses RSA-256 encryption with 2048-bit keys (same as XID JWTs) -- Tokens are conversation-scoped - cannot be used across conversations -- No refresh mechanism - participants must re-initialize after expiration -- Subject format (`anon:`) clearly distinguishes from other token types - -## API Changes - -### Response Format - -Endpoints that issue JWTs include an `auth` object in the response: - -```json -{ - "auth": { - "token": "eyJhbGc...", - "token_type": "Bearer", - "expires_in": 86400 - }, - // ... other response data -} -``` - -### Affected Endpoints - -- `GET /api/v3/participationInit` - Issues JWT for existing anonymous participants -- `POST /api/v3/votes` - Issues JWT on first vote for new anonymous participants - -## Testing - -See `server/__tests__/integration/anonymous-jwt.test.ts` for comprehensive tests covering: - -- JWT issuance on participation -- JWT issuance on first vote -- Using JWT for authenticated requests -- Conversation scoping -- Integration with hybrid auth middleware - -## Future Considerations - -1. **Token refresh**: Currently not implemented, but could be added if needed -2. **Migration completion**: Once stable, cookie-based auth for anonymous users can be deprecated -3. **Analytics**: Track JWT vs cookie usage to monitor migration progress diff --git a/server/docs/AUTH_ARCHITECTURE.md b/server/docs/AUTH_ARCHITECTURE.md deleted file mode 100644 index c2c801cc39..0000000000 --- a/server/docs/AUTH_ARCHITECTURE.md +++ /dev/null @@ -1,160 +0,0 @@ -# Authentication Architecture - -## Overview - -Polis uses a hybrid authentication system supporting three user types: - -1. **Standard Users** - OIDC JWT authentication (email/password, social login) - can create/manage conversations -2. **XID Participants** - External ID users with custom JWT tokens - participate only -3. **Anonymous Participants** - Custom JWT tokens issued by server - participate only - -## Architecture Diagram - -```txt -Standard Users: Browser → OIDC → OIDC JWT → API validates with JWKS -XID Participants: Browser → participationInit?xid=X → Custom JWT → API validates locally -Anonymous Participants: Browser → participationInit → Custom JWT → API validates locally -``` - -## Implementation - -### Hybrid Authentication Middleware - -The system uses a unified middleware (`hybrid-jwt.ts`) that checks authentication in priority order: - -1. OIDC JWT tokens (for standard users, issued by OIDC) -2. XID JWT tokens (for external participants) -3. Anonymous JWT tokens (for anonymous participants) -4. User JWT tokens (for standard users, issued by Polis) - -```typescript -// Usage in routes -app.get('/api/v3/protected', hybridAuth(assignToP), handler); -app.get('/api/v3/public', hybridAuthOptional(assignToP), handler); -``` - -### Parameter Middleware System - -The authentication system integrates with Polis's parameter middleware system through the `assignToP` function. This ensures: - -- **Consistent parameter handling**: All route parameters follow the same validation pattern -- **Clobbering detection**: Prevents accidental overwrites of existing parameters -- **Error handling**: Standardized error responses for parameter validation - -JWT extraction functions use the assigner function (typically `assignToP`) rather than direct assignment to maintain compatibility with the existing parameter middleware architecture. - -### JWT Token Structure - -**OIDC JWT (Standard Users)** - -```json -{ - "iss": "https://your-tenant.auth0.com/", - "sub": "auth0|507f1f77bcf86cd799439011", - "aud": "your-api-audience", - "email": "user@example.com" -} -``` - -**XID JWT (External Participants)** - -```json -{ - "iss": "https://pol.is/", - "sub": "xid:external-user-123", - "xid": "external-user-123", - "conversation_id": "abc123", - "uid": 456, - "pid": 789, - "anonymous": true, - "xid_participant": true -} -``` - -**Anonymous JWT (Anonymous Participants)** - -```json -{ - "iss": "https://pol.is/", - "sub": "anon:456", - "uid": 456, - "pid": 789, - "conversation_id": "abc123", - "anonymous": true, - "anonymous_participant": true -} -``` - -**User JWT (Standard Users)** - -```json -{ - "iss": "https://pol.is/", - "sub": "user:auth0|507f1f77bcf86cd799439011", - "aud": "users", - "exp": 1715769600, - "iat": 1715766000, - "pid": 123, - "uid": 456, - "oidc_sub": "auth0|507f1f77bcf86cd799439011", - "conversation_id": "abc123", - "standard_user_participant": true -} -``` - -## Security Features - -### XID Conversation Scoping - -- XID identity is tied to a specific conversation -- XID users can participate in other conversations as anonymous users -- XID tokens cannot be used across different conversations - -### Token Security - -- RSA-256 signing with 2048-bit keys -- 1-year expiration (no refresh) -- Strict audience and issuer validation -- No sensitive data in JWT claims - -### Legacy Cookie Support - -The system maintains backward compatibility with legacy permanent cookies (`pc` cookie) for existing participants: - -- **Automatic JWT Issuance**: When a request contains a valid permanent cookie, the system looks up the existing participant and issues a new JWT -- **Seamless Migration**: Participants with legacy cookies receive JWTs automatically, allowing them to transition to the new authentication system -- **All Endpoints Supported**: Works with `/api/v3/votes`, `/api/v3/comments`, and `/api/v3/participationInit` -- **Preserves Identity**: Maintains the same participant ID (pid) and user ID (uid) from the legacy system - -See [LEGACY_COOKIE.md](./LEGACY_COOKIE.md) for implementation details. - -## Environment Configuration - -```bash -# OIDC Configuration (for standard users) -AUTH_ISSUER=https://your-tenant.auth0.com/ -AUTH_AUDIENCE=users -AUTH_CERTS_PATH=~/.simulacrum/certs -AUTH_CLIENT_ID=your-client-id -JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json - -# In-house JWT Configuration (for XID and anonymous participants) -POLIS_JWT_ISSUER=https://pol.is/ -POLIS_JWT_AUDIENCE=participants -JWT_PRIVATE_KEY=[base64 encoded private key] -JWT_PUBLIC_KEY=[base64 encoded public key] -``` - -## Current Status - -✅ **Implemented** - -- OIDC JWT authentication for standard users -- XID JWT infrastructure and validation -- Hybrid authentication middleware -- Database migration for OIDC user mapping -- Test infrastructure with OIDC simulator -- Anonymous user JWT tokens -- Full route validation with JWT -- Client SDK updates for localStorage -- User JWT tokens for standard users diff --git a/server/docs/LEGACY_COOKIE.md b/server/docs/LEGACY_COOKIE.md deleted file mode 100644 index 2e94b0b66b..0000000000 --- a/server/docs/LEGACY_COOKIE.md +++ /dev/null @@ -1,134 +0,0 @@ -# Legacy Cookie Authentication - -## Overview - -Legacy cookie authentication provides backward compatibility for existing Polis participants who have permanent cookies (`pc` cookie) from the previous authentication system. This feature ensures a smooth transition to JWT-based authentication without requiring users to re-authenticate or lose their participant history. - -## How It Works - -### Authentication Flow - -1. **Request with Legacy Cookie**: - - ``` - GET /api/v3/participationInit?conversation_id=abc - Cookie: pc=abc123def456... - ``` - -2. **Server Lookup**: - - Server checks for the `pc` cookie in the request - - Queries the database to find the participant associated with this permanent cookie - - If found, retrieves the existing `uid` and `pid` - -3. **JWT Issuance**: - - Issues an appropriate JWT token (Anonymous or XID) for the existing participant - - Returns the JWT in the response's `auth` field - - Client stores the JWT for future requests - -4. **Subsequent Requests**: - - Client uses the JWT for authentication - - Legacy cookie is no longer needed - -### Database Schema - -The permanent cookies are stored in the `participants_extended` table: - -```sql --- Lookup query used by the system -SELECT pe.uid, p.pid -FROM participants_extended pe -INNER JOIN participants p ON pe.uid = p.uid AND pe.zid = p.zid -WHERE pe.zid = $1 AND pe.permanent_cookie = $2 -``` - -## Implementation Details - -### Server-Side Components - -**`src/auth/legacyCookies.ts`**: - -- Core logic for checking legacy cookies -- Issues appropriate JWT tokens for existing participants - -**Route Integration**: - -- `/api/v3/votes` - Checks for legacy cookie before creating new participants -- `/api/v3/comments` - Supports legacy cookie authentication for comment submission -- `/api/v3/participationInit` - Recognizes existing participants via legacy cookie - -### Response Format - -When a legacy cookie is recognized, the response includes a JWT: - -```json -{ - "auth": { - "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", - "token_type": "Bearer", - "expires_in": 31536000 - }, - "currentPid": 123, - // ... other response data -} -``` - -## Security Considerations - -- **No New Cookies**: The system does not issue new permanent cookies -- **One-Way Migration**: Once a participant receives a JWT, they should use it for future requests -- **Conversation Scoping**: Legacy cookies are validated against specific conversations -- **Limited Lifespan**: This is a transitional feature that can be removed once migration is complete - -## Testing - -### Integration Tests - -See `server/__tests__/integration/legacy-cookie.test.ts` for comprehensive tests covering: - -- Participant recognition via permanent cookie -- JWT issuance for legacy participants -- XID participant handling with legacy cookies -- New participant creation when cookie is invalid - -### E2E Tests - -See `e2e/cypress/e2e/client-participation/legacy-cookie-auth.cy.js` for browser-based tests covering: - -- Permanent cookie detection -- JWT issuance verification -- Identity preservation across authentication methods -- All endpoint support (votes, comments, participationInit) - -## Migration Timeline - -1. **Current**: Legacy cookie support active, JWTs issued for existing participants -2. **Monitoring Phase**: Track usage of legacy cookies vs JWTs -3. **Deprecation Notice**: Inform integrations about upcoming removal -4. **Removal**: Remove legacy cookie support once migration is complete - -## Troubleshooting - -### Common Issues - -**Legacy cookie not recognized**: - -- Verify the cookie exists in the database -- Check that the conversation ID matches -- Ensure the cookie format is correct - -**JWT not issued**: - -- Check server logs for database query errors -- Verify the participant record exists -- Ensure the legacy cookie module is properly imported - -### Debug Logging - -Enable debug logging to trace legacy cookie authentication: - -```javascript -logger.debug("Checking for legacy participant with permanent cookie", { - zid, - permanentCookie: permanentCookie.substring(0, 8) + "..." -}); -``` diff --git a/server/docs/PARTICIPANT_MIDDLEWARE.md b/server/docs/PARTICIPANT_MIDDLEWARE.md new file mode 100644 index 0000000000..6f823a6b0e --- /dev/null +++ b/server/docs/PARTICIPANT_MIDDLEWARE.md @@ -0,0 +1,185 @@ +# Participant Management Middleware + +## Overview + +The participant management middleware (`ensureParticipant`) provides a clean abstraction for handling the complex participant creation and authentication flow that's common across multiple Polis endpoints. It consolidates logic that was previously duplicated in routes like `/api/v3/votes` and `/api/v3/comments`. + +## What It Does + +The middleware handles: + +1. **JWT Conversation Mismatches** - When a participant has a JWT for a different conversation +2. **Legacy Cookie Support** - Migrating users from the old cookie-based system +3. **Anonymous User Creation** - Creating anonymous users on their first interaction +4. **XID User Management** - Handling external ID users and their whitelisting +5. **Participant Creation** - Creating participant records with proper race condition handling +6. **JWT Issuance** - Issuing appropriate JWTs (Anonymous, XID, or Standard User) for new participants + +## Usage + +### Basic Usage + +```typescript +import { ensureParticipant } from "../auth"; + +// In app.ts route definition +app.post( + "/api/v3/votes", + hybridAuthOptional(assignToP), + need("conversation_id", getConversationIdFetchZid, assignToPCustom("zid")), + need("tid", getInt, assignToP), + need("vote", getIntInRange(-1, 1), assignToP), + want("xid", getStringLimitLength(1, 999), assignToP), + ensureParticipant(), // <-- Add this middleware + handle_POST_votes +); +``` + +### With Options + +```typescript +// Create participant only if they don't exist, issue JWT +ensureParticipant({ + createIfMissing: true, // Default: true + issueJWT: true, // Default: true +}) + +// Just check for existing participant, don't create +ensureParticipantOptional({ + createIfMissing: false +}) + +// Create participant only on action methods (POST, PUT, DELETE) +ensureParticipantOnAction() +``` + +## What Gets Added to the Request + +The middleware works with the centralized `RequestWithP` type defined in `src/d.ts`. After the middleware runs, the request will have: + +```typescript +req.p.uid // User ID (guaranteed to be set) +req.p.pid // Participant ID (guaranteed to be set) +req.p.participantInfo = { + uid: number, + pid: number, + isNewlyCreatedUser: boolean, + isNewlyCreatedParticipant: boolean, + needsNewJWT: boolean, + token?: string, // JWT if one was issued + conversationId?: string +} +req.p.authToken = { // If JWT was issued + token: string, + token_type: "Bearer", + expires_in: number +} +``` + +## Type Safety + +All route handlers can use the centralized `RequestWithP` type for consistent typing: + +```typescript +import { RequestWithP } from "../d"; + +async function handle_POST_votes(req: RequestWithP, res: any) { + // req.p is fully typed with all possible fields + const { uid, pid, zid } = req.p; // TypeScript knows these exist + // ... +} +``` + +## Migration Guide + +### Before (Complex Handler) + +```typescript +async function handle_POST_votes(req: VoteRequest, res: any) { + // 100+ lines of participant management logic: + // - Handle JWT conversation mismatches + // - Check legacy cookies + // - Create anonymous users + // - Get or create participants + // - Issue JWTs + // - Handle race conditions + + // Finally do the actual work: + const voteResult = await votesPost(...); + // ... +} +``` + +### After (Simple Handler) + +```typescript +async function handle_POST_votes(req: VoteRequest, res: any) { + // Participant management is handled by middleware + // Just do the actual work: + const { uid, pid, zid } = req.p; // Guaranteed to exist + + const voteResult = await votesPost(uid, pid, zid, ...); + + // Include JWT if one was issued + if (req.p.authToken) { + result.auth = req.p.authToken; + } + + res.json(result); +} +``` + +## Middleware Variants + +### `ensureParticipant(options)` + +- Always creates participant if missing +- Fails if participant can't be created +- Use for actions that require a participant + +### `ensureParticipantOptional(options)` + +- Looks up existing participant +- Continues even if not found +- Sets `req.p.participantInfo` to `null` if not found +- Use for read-only operations + +### `ensureParticipantOnAction(options)` + +- Creates participant only for POST/PUT/DELETE/PATCH +- Read-only for GET requests +- Use for endpoints that serve both purposes + +## Error Handling + +The middleware will: + +- Pass through specific `polis_err_*` errors +- Log detailed error information +- Provide generic `polis_err_participant_creation` for unexpected errors + +## Race Condition Handling + +The middleware includes robust handling for participant creation race conditions: + +- Retries on duplicate key errors +- Re-checks for existing participants after conflicts +- Ensures only one participant per user/conversation pair + +## JWT Conversation Scoping + +The middleware correctly handles the 4 cases of JWT/conversation mismatches: + +1. **Valid Setup** - Token and request for same conversation +2. **Token/XID Match, Wrong Conversation** - Treated as anonymous +3. **Token Wrong, XID for Current** - Uses XID for current conversation +4. **Token Current, XID Wrong** - Treated as anonymous + +## Benefits + +1. **DRY Principle** - No more duplicated participant management code +2. **Consistency** - All routes handle participants the same way +3. **Maintainability** - Bug fixes and improvements in one place +4. **Clarity** - Route handlers focus on their actual purpose +5. **Testing** - Easier to test participant management separately +6. **Future-Proof** - Easy to add new participant types or auth methods diff --git a/server/docs/STANDARD_USER_JWT.md b/server/docs/STANDARD_USER_JWT.md deleted file mode 100644 index 3798a0d98a..0000000000 --- a/server/docs/STANDARD_USER_JWT.md +++ /dev/null @@ -1,143 +0,0 @@ -# Standard User JWT Authentication - -This document describes the JWT authentication implementation for standard users (OIDC-authenticated) when they participate in Polis conversations. - -## Overview - -Standard users who authenticate via OIDC now receive conversation-scoped JWT tokens when they participate in conversations, providing consistency with XID and anonymous participants while maintaining their authenticated identity. - -## Why This Approach? - -Previously, when a standard user with an OIDC token participated in a conversation, the system would: - -1. Treat them as a new anonymous participant -2. Create a new user record with a new uid -3. Issue an anonymous JWT - -This was problematic because: - -- It created duplicate user records -- Lost the connection to the user's authenticated identity -- Prevented proper tracking of user participation across sessions - -## Solution - -The new approach: - -1. Recognizes OIDC tokens in participant requests -2. Uses the existing uid from `oidc_user_mappings` table -3. Issues a conversation-scoped "Standard User JWT" that maintains the OIDC identity link - -## JWT Structure - -Standard User JWTs contain the following claims: - -```typescript -interface StandardUserJwtClaims { - aud: string; // Audience (Config.polisJwtAudience) - exp: number; // Expiration time (1 year) - iat: number; // Issued at time - iss: string; // Issuer (Config.polisJwtIssuer) - pid: number; // Participant ID - sub: string; // Subject format: "user:" - uid: number; // Local user ID - oidc_sub: string; // OIDC subject identifier - conversation_id: string; // Conversation this token is valid for - standard_user_participant: boolean; // Flag to identify standard user JWTs -} -``` - -## Authentication Flow - -1. **Initial OIDC Login**: - - User logs in via OIDC - - Receives OIDC JWT with their oidc_sub - - Server maps oidc_sub to local uid via `oidc_user_mappings` - -2. **Conversation Participation**: - - ```txt - GET /api/v3/participationInit?conversation_id=abc - Authorization: Bearer - ``` - - - Server recognizes OIDC JWT - - Looks up uid from oidc_user_mappings - - Creates/finds participant record - - Issues Standard User JWT - -3. **Subsequent Requests**: - - ```txt - POST /api/v3/votes - Authorization: Bearer - ``` - - - Uses conversation-scoped Standard User JWT - - Maintains link to OIDC identity - -## Implementation Details - -### Key Files - -- `src/auth/standard-user-jwt.ts` - Core JWT functions -- `src/auth/hybrid-jwt.ts` - Updated hybrid auth middleware -- `src/routes/votes.ts` - Updated vote handling -- `src/routes/participation.ts` - Updated participation handling - -### Token Issuance - -Standard User JWTs are issued in two scenarios: - -1. **During participation initialization** (`/api/v3/participationInit`) - - When a standard user with OIDC token joins a conversation - - Only if they already have a participant record - -2. **On first vote** (`/api/v3/votes`) - - When a standard user votes for the first time - - Creates participant record and issues JWT - -### Hybrid Authentication Order - -The hybrid authentication middleware checks tokens in this order: - -1. **XID JWT** - External participant tokens -2. **Anonymous JWT** - Anonymous participant tokens -3. **Standard User JWT** - OIDC user participant tokens -4. **OIDC JWT** - Direct OIDC tokens - -## Benefits - -1. **User Continuity**: Maintains connection to authenticated identity -2. **Conversation Scoping**: Same security model as other participant types -3. **No Duplicate Records**: Uses existing uid from oidc_user_mappings -4. **Consistent Architecture**: All participant types use conversation-scoped JWTs - -## Migration Considerations - -### Backward Compatibility - -- Existing OIDC tokens continue to work -- Standard users can still use OIDC tokens directly -- Gradual migration as users participate in conversations - -### Client Updates - -Clients should be updated to: - -1. Store returned Standard User JWTs -2. Use them for subsequent conversation-specific requests -3. Fall back to OIDC tokens for non-conversation APIs - -## Security Considerations - -- Uses same RSA-256 encryption as XID/Anonymous JWTs -- 1-year expiration (no refresh mechanism) -- Conversation-scoped to prevent cross-conversation usage -- Maintains audit trail via oidc_sub link - -## Future Enhancements - -1. **Token Refresh**: Consider adding refresh mechanism for long sessions -2. **XID Integration**: Handle standard users who also have XIDs -3. **Session Management**: Improve handling of multiple active conversations diff --git a/server/docs/XID_JWT.md b/server/docs/XID_JWT.md deleted file mode 100644 index 4ff0beb9ff..0000000000 --- a/server/docs/XID_JWT.md +++ /dev/null @@ -1,204 +0,0 @@ -# XID JWT Authentication - -## Overview - -XID (External ID) authentication allows third-party integrations to authenticate participants without cookies, using JWT tokens stored in localStorage. This enables Polis to work in third-party contexts where cookies are blocked. - -## Current Status ✅ - -The XID JWT infrastructure is **fully implemented** and tested: - -- ✅ JWT signing and verification system (`src/auth/xid-jwt.ts`) -- ✅ Hybrid authentication middleware (`src/auth/hybrid-jwt.ts`) -- ✅ ParticipationInit JWT support -- ✅ RSA key generation tooling -- ✅ Comprehensive test coverage - -## How It Works - -### Authentication Flow - -1. **Initial Request** (No existing user): - - ```txt - GET /api/v3/participationInit?conversation_id=abc&xid=user123 - ``` - - - Creates new user record via XID authentication - - Returns conversation data (JWT issued on subsequent requests) - -2. **Subsequent Requests** (User exists): - - ```txt - GET /api/v3/participationInit?conversation_id=abc&xid=user123 - ``` - - Response includes JWT: - - ```json - { - "conversation": { ... }, - "nextComment": { ... }, - "auth": { - "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", - "token_type": "Bearer", - "expires_in": 86400 - } - } - ``` - -3. **Using JWT for API Calls**: - - ```txt - POST /api/v3/votes - Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9... - ``` - -### JWT Token Structure - -```json -{ - "aud": "users", - "iss": "https://localhost:3000/", - "sub": "xid:external-user-123", - "xid": "external-user-123", - "uid": 123, - "pid": "participant-id", - "conversation_id": "abc123", - "anonymous": true, - "xid_participant": true, - "exp": 1735776661, - "iat": 1735690261 -} -``` - -## Implementation - -### Key Files - -- `src/auth/xid-jwt.ts` - Core JWT functions -- `src/auth/hybrid-jwt.ts` - Hybrid auth middleware -- `src/routes/participation.ts` - Updated participationInit -- `scripts/generate-jwt-keys.js` - Key generation - -### Server-Side Usage - -```typescript -import { issueXidJWT } from '../auth/xid-jwt'; - -// Issue JWT for XID participant -const token = issueXidJWT( - 'user123', // External ID - 'conversation456', // Conversation ID - 789, // Local user ID - 'pid012' // Participant ID -); - -// Protect routes with hybrid auth -app.post('/api/v3/votes', - hybridAuthOptional(assignToP), // Supports both OIDC and XID JWT - handleVote -); -``` - -### Client-Side Integration - -```javascript -// Initial participation -const response = await fetch('/api/v3/participationInit?conversation_id=abc&xid=user123'); -const data = await response.json(); - -// Store JWT if provided -if (data.auth && data.auth.token) { - localStorage.setItem('polis_xid_token', data.auth.token); -} - -// Use JWT in subsequent requests -const token = localStorage.getItem('polis_xid_token'); -const voteResponse = await fetch('/api/v3/votes', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - tid: 1, - vote: 1, - conversation_id: 'abc' - }) -}); -``` - -## Setup Instructions - -### 1. Generate RSA Keys - -```bash -# Generate keys for development -node scripts/generate-jwt-keys.js -``` - -This creates: - -- `keys/jwt-private.pem` - Private key for signing -- `keys/jwt-public.pem` - Public key for verification - -### 2. Configure Environment - -**Development (.env):** - -```bash -AUTH_CERTS_PATH=~/.simulacrum/certs -AUTH_KEYS_PATH=./keys -``` - -**Production (Environment Variables):** - -```bash -# Base64 encoded keys for containers -JWT_PRIVATE_KEY=LS0tLS1CRUdJTi... -JWT_PUBLIC_KEY=LS0tLS1CRUdJTi... -``` - -## Security Considerations - -### Conversation Scoping - -- XID JWTs are scoped to specific conversations -- Cannot be used across different conversations -- XID users can participate in other conversations as anonymous users - -### Token Security - -- RSA-256 signing algorithm -- 2048-bit key length -- 1-year expiration (no refresh) -- No sensitive data in claims - -## Testing - -```bash -# Run unit tests -npm test -- __tests__/unit/xid-jwt.test.ts - -# Run integration tests -npm test -- __tests__/integration/xid-auth.test.ts -npm test -- __tests__/integration/anonymous-jwt.test.ts -``` - -## Migration Notes - -### Backward Compatibility - -The system maintains full backward compatibility: - -- Cookie-based XID auth continues to work -- New XID participants get JWT tokens -- Existing integrations don't need immediate updates - -### Benefits - -- **No cookies required** - Works in third-party contexts -- **Stateless** - No server-side session management -- **Standard format** - Industry-standard JWT -- **OIDC compatible** - Coexists with OIDC authentication diff --git a/server/docs/authentication.md b/server/docs/authentication.md index 4924975172..bc4a2b8f5d 100644 --- a/server/docs/authentication.md +++ b/server/docs/authentication.md @@ -1,56 +1,271 @@ -# Authentication Documentation +# Polis Authentication System -Polis is migrating from cookie-based authentication to JWT-based authentication using OIDC for standard users and custom JWTs for XID/anonymous participants. +## Overview -## Documentation Structure +Polis uses a hybrid JWT-based authentication system supporting multiple user types and authentication pathways. The system maintains backward compatibility with legacy cookie-based authentication while providing modern JWT tokens for all participant types. -### [AUTH_ARCHITECTURE.md](./AUTH_ARCHITECTURE.md) +## Core Concepts -Main overview of the authentication system architecture, including: +### Users vs Participants -- Three types of users (Standard, XID, Anonymous) -- Hybrid authentication middleware -- JWT token structures -- Security considerations +- **Users** (`users` table): Global accounts with unique UIDs. Can be anonymous or authenticated via OIDC. +- **Participants** (`participants` table): Conversation-specific records linking users to conversations. Each user can be a participant in multiple conversations with different PIDs. +- **Relationship**: A user (uid) becomes a participant (pid) when they join a conversation (zid). + +### User Types + +1. **Standard Users** + - Authenticated via OIDC (Auth0, etc.) + - Can create and manage conversations + - Receive conversation-scoped JWTs when participating + - Mapped via `oidc_user_mappings` table + +2. **XID Participants** + - External ID users from third-party integrations + - Conversation-scoped identity (XID valid only within specific conversation) + - Receive custom JWT tokens + - Can participate but not create conversations + +3. **Anonymous Participants** + - No authentication required + - Receive JWT tokens on first action (vote/comment) + - Can participate but not create conversations + +## Authentication Flow + +### Token Priority + +The hybrid authentication middleware (`hybrid-jwt.ts`) checks tokens in this order: + +1. **XID JWT** - External participant tokens +2. **Anonymous JWT** - Anonymous participant tokens +3. **Standard User JWT** - OIDC user participant tokens +4. **OIDC JWT** - Direct OIDC tokens for admin operations +5. **Legacy Cookie** - Permanent cookie (`pc`) for backward compatibility + +### Participant Creation & JWT Issuance + +#### 1. ParticipationInit (`GET /api/v3/participationInit`) + +**Does NOT create participants** - only recognizes existing ones: + +- Returns existing participant data if found +- Issues JWT only for existing participants (all types) +- New anonymous participants receive no JWT (will get one on first action) + +#### 2. First Vote (`POST /api/v3/votes`) + +Creates participants and issues JWTs: + +1. Check for existing user/participant +2. If new: + - Create anonymous user (if no uid) + - Create participant record + - Issue appropriate JWT (Anonymous/XID/Standard User) +3. If existing but no JWT: + - Issue appropriate JWT + +#### 3. First Comment (`POST /api/v3/comments`) + +Similar to voting: + +1. Check for existing user/participant +2. If new: + - Create anonymous user (if no uid) + - Create participant record + - Issue appropriate JWT +3. Process comment with moderation + +### JWT Token Structure + +#### Anonymous JWT + +```json +{ + "sub": "anon:", + "uid": 123, + "pid": 456, + "conversation_id": "abc123", + "anonymous_participant": true, + "exp": 1234567890 +} +``` + +#### XID JWT + +```json +{ + "sub": "xid:", + "xid": "external-user-123", + "uid": 123, + "pid": 456, + "conversation_id": "abc123", + "xid_participant": true, + "exp": 1234567890 +} +``` + +#### Standard User JWT + +```json +{ + "sub": "user:", + "oidc_sub": "auth0|507f1f77bcf86cd799439011", + "uid": 123, + "pid": 456, + "conversation_id": "abc123", + "standard_user_participant": true, + "exp": 1234567890 +} +``` + +## Conversation Scoping + +### XID Participants + +XIDs are strictly conversation-scoped. The same external ID in different conversations: -### [XID_JWT.md](./XID_JWT.md) +- Gets different UIDs +- Gets different PIDs +- Cannot cross-authenticate between conversations -Complete documentation for XID (External ID) JWT authentication: +When an XID participant presents a JWT for a different conversation: -- Implementation details -- Authentication flow -- Setup instructions -- Client integration examples +1. **Case 1**: Valid setup - Token matches conversation, XID matches token +2. **Case 2**: Token/XID match but wrong conversation → Treated as anonymous +3. **Case 3**: Token wrong conversation, XID for current → Use XID for current +4. **Case 4**: Token for current, XID for different → Treated as anonymous -### [ANONYMOUS_JWT.md](./ANONYMOUS_JWT.md) +### Anonymous Participants -Documentation for anonymous participant JWT authentication: +Anonymous JWTs are conversation-scoped. A JWT for one conversation cannot be used in another - the participant will be treated as new. -- JWT structure and issuance -- Integration with participation flow -- Security considerations +### Standard Users + +Standard users maintain their identity across conversations but receive conversation-specific participant JWTs. The OIDC identity links all their participations. + +## Legacy Cookie Support + +The system maintains backward compatibility with permanent cookies (`pc`): + +1. **Detection**: Checks for `pc` cookie in requests +2. **Lookup**: Finds existing participant via `participants_extended.permanent_cookie` +3. **JWT Issuance**: Issues appropriate JWT for the existing participant +4. **Migration**: One-way migration - participants should use JWT going forward + +This is a transitional feature that will be removed once migration is complete. + +## Implementation + +### Key Files + +- `src/auth/hybrid-jwt.ts` - Unified authentication middleware +- `src/auth/jwt-utils.ts` - Core JWT utilities and types +- `src/auth/anonymous-jwt.ts` - Anonymous participant JWT handling +- `src/auth/xid-jwt.ts` - XID participant JWT handling +- `src/auth/standard-user-jwt.ts` - Standard user participant JWT handling +- `src/auth/legacyCookies.ts` - Legacy cookie compatibility +- `src/auth/create-user.ts` - User creation logic + +### Environment Configuration + +```bash +# OIDC Configuration (for standard users) +AUTH_ISSUER=https://your-tenant.auth0.com/ +AUTH_AUDIENCE=users +JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json + +# In-house JWT Configuration (for participants) +POLIS_JWT_ISSUER=https://pol.is/ +POLIS_JWT_AUDIENCE=participants +JWT_PRIVATE_KEY=[base64 encoded private key] +JWT_PUBLIC_KEY=[base64 encoded public key] +``` + +### Middleware Usage + +```typescript +import { hybridAuth, hybridAuthOptional } from './auth/hybrid-jwt'; + +// Required authentication +app.get('/api/v3/protected', hybridAuth(assignToP), handler); + +// Optional authentication +app.get('/api/v3/public', hybridAuthOptional(assignToP), handler); +``` + +## Security Considerations + +### Token Security + +- RSA-256 signing with 2048-bit keys +- 1-year expiration (no refresh mechanism) +- Conversation-scoped to prevent cross-conversation usage +- No sensitive data in JWT claims + +### Domain Whitelisting + +- Conversations can restrict participation by domain +- Checked via `site_domain_whitelist` table +- Empty whitelist allows all domains + +### XID Whitelisting + +- Conversations can restrict XIDs via `xid_whitelist` +- Only whitelisted XIDs can participate when enabled + +## Testing + +```bash +# Integration tests +npm test -- __tests__/integration/auth-jwt.test.ts +npm test -- __tests__/integration/xid-auth.test.ts +npm test -- __tests__/integration/anonymous-jwt.test.ts +npm test -- __tests__/integration/legacy-cookie.test.ts + +# Unit tests +npm test -- __tests__/unit/xid-jwt.test.ts +npm test -- __tests__/unit/standard-user-jwt.test.ts +``` + +## Migration Status + +✅ **Completed** + +- JWT infrastructure for all participant types +- Hybrid authentication middleware +- Legacy cookie support +- Route validation +- Client SDK updates -### [LEGACY_COOKIE.md](./LEGACY_COOKIE.md) +## Client Integration -Backward compatibility for participants with permanent cookies: +### Storing Tokens -- Migration from cookie to JWT authentication -- Implementation details -- Testing and troubleshooting +```javascript +// After participation/vote/comment +if (response.auth && response.auth.token) { + localStorage.setItem('polis_jwt', response.auth.token); +} +``` -### [auth/README.md](../src/auth/README.md) +### Using Tokens -Quick reference for the authentication module implementation: +```javascript +const token = localStorage.getItem('polis_jwt'); +fetch('/api/v3/votes', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + // ... +}); +``` -- File structure -- Usage examples -- Environment configuration -- Testing instructions +### Handling Conversation Changes -## Quick Start +When switching conversations, clients should: -1. **For developers implementing authentication**: Start with [AUTH_ARCHITECTURE.md](./AUTH_ARCHITECTURE.md) -2. **For XID integration**: See [XID_JWT.md](./XID_JWT.md) -3. **For anonymous participants**: See [ANONYMOUS_JWT.md](./ANONYMOUS_JWT.md) -4. **For legacy cookie support**: See [LEGACY_COOKIE.md](./LEGACY_COOKIE.md) -5. **For code implementation**: Reference [auth/README.md](../src/auth/README.md) +1. Keep existing conversation-scoped tokens +2. Call participationInit for the new conversation +3. Store any new tokens received diff --git a/server/example.env b/server/example.env index 87e7a328de..f748b0885d 100644 --- a/server/example.env +++ b/server/example.env @@ -29,3 +29,10 @@ JWT_PRIVATE_KEY_PATH=./keys/jwt-private.pem JWT_PUBLIC_KEY_PATH=./keys/jwt-public.pem POLIS_JWT_ISSUER=https://pol.is/ POLIS_JWT_AUDIENCE=participants + +# DynamoDB +DYNAMODB_ENDPOINT=http://localhost:8000 + +# Decimal between 0.0 and 1.0. This determines how frequently to +# pull the next comment from selected topics, if available. +TOPICAL_COMMENT_RATIO=0.6 diff --git a/server/postgres/migrations/000012_create_topic_agenda_selections.sql b/server/postgres/migrations/000012_create_topic_agenda_selections.sql new file mode 100644 index 0000000000..5cc1e81cf7 --- /dev/null +++ b/server/postgres/migrations/000012_create_topic_agenda_selections.sql @@ -0,0 +1,47 @@ +-- Migration: Create topic_agenda_selections table for storing user topic selections +-- This table stores archetypal comment selections that persist across Delphi runs + +CREATE TABLE IF NOT EXISTS topic_agenda_selections ( + -- Primary key + zid INTEGER NOT NULL, + pid INTEGER NOT NULL, + + -- Selection data stored as JSONB for flexibility + archetypal_selections JSONB NOT NULL DEFAULT '[]'::jsonb, + + -- Dedicated columns for metadata + delphi_job_id TEXT, + total_selections INTEGER NOT NULL DEFAULT 0, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- Composite primary key on conversation and participant + PRIMARY KEY (zid, pid), + + -- Foreign key constraints + CONSTRAINT fk_conversation + FOREIGN KEY (zid) + REFERENCES conversations(zid) + ON DELETE CASCADE, + + CONSTRAINT fk_participant + FOREIGN KEY (zid, pid) + REFERENCES participants(zid, pid) + ON DELETE CASCADE +); + +-- Create indexes for better query performance +CREATE INDEX idx_topic_agenda_selections_zid ON topic_agenda_selections(zid); +CREATE INDEX idx_topic_agenda_selections_pid ON topic_agenda_selections(pid); +CREATE INDEX idx_topic_agenda_selections_delphi_job_id ON topic_agenda_selections(delphi_job_id); +CREATE INDEX idx_topic_agenda_selections_created_at ON topic_agenda_selections(created_at); + +-- Add comments for documentation +COMMENT ON TABLE topic_agenda_selections IS 'Stores user topic agenda selections as archetypal comments that persist across Delphi runs'; +COMMENT ON COLUMN topic_agenda_selections.zid IS 'Conversation ID (foreign key to conversations)'; +COMMENT ON COLUMN topic_agenda_selections.pid IS 'Participant ID (foreign key to participants)'; +COMMENT ON COLUMN topic_agenda_selections.archetypal_selections IS 'JSON array of selected topics with their archetypal comments'; +COMMENT ON COLUMN topic_agenda_selections.delphi_job_id IS 'ID of the Delphi job that generated the topics'; +COMMENT ON COLUMN topic_agenda_selections.total_selections IS 'Total number of topics selected by the user'; diff --git a/server/src/auth/README.md b/server/src/auth/README.md index d84312cf70..4db500e51c 100644 --- a/server/src/auth/README.md +++ b/server/src/auth/README.md @@ -1,108 +1,57 @@ # Authentication Module -This module handles authentication for the Polis API server, supporting OIDC JWT, XID JWT, and Anonymous JWT. +This module implements JWT-based authentication for the Polis API server. -## Current Architecture +## Quick Reference -The authentication system uses a hybrid approach with three user types: +### File Structure -1. **Standard Users** - OIDC JWT authentication -2. **XID Users** - Custom JWT for external integrations -3. **Anonymous Users** - Custom JWT for anonymous participants - -## File Structure - -- `auth.ts` - Legacy authentication logic and middleware -- `hybrid-jwt.ts` - Unified authentication middleware (OIDC + XID JWT) +- `hybrid-jwt.ts` - Unified authentication middleware +- `jwt-utils.ts` - Core JWT utilities and types - `jwt-middleware.ts` - OIDC JWT validation -- `xid-jwt.ts` - XID JWT signing and validation -- `anonymous-jwt.ts` - Anonymous participant JWT signing and validation -- `legacyCookies.ts` - Legacy permanent cookie support for backward compatibility -- `routes.ts` - Authentication API endpoints -- `password.ts` - Password utilities +- `anonymous-jwt.ts` - Anonymous participant JWT +- `xid-jwt.ts` - XID participant JWT +- `standard-user-jwt.ts` - Standard user participant JWT +- `legacyCookies.ts` - Legacy cookie support - `create-user.ts` - User creation logic +- `routes.ts` - Authentication endpoints - `index.ts` - Module exports -## Usage - -### Hybrid Authentication (Recommended) +### Usage ```typescript -import { createHybridJwtMiddleware } from './auth/hybrid-jwt'; - -// Create middleware instances -const hybridAuth = createHybridJwtMiddleware(assignToP, false); -const hybridAuthOptional = createHybridJwtMiddleware(assignToP, true); +import { hybridAuth, hybridAuthOptional } from './auth/hybrid-jwt'; // Required authentication -app.get('/api/v3/protected', hybridAuth, handler); +app.get('/api/v3/protected', hybridAuth(assignToP), handler); // Optional authentication -app.get('/api/v3/public', hybridAuthOptional, handler); -``` - -### Legacy Authentication (Deprecated -- Removed) - -```typescript -import { auth, authOptional } from './auth'; - -// Still available for backward compatibility -app.get('/api/v3/legacy', auth(assignToP), handler); +app.get('/api/v3/public', hybridAuthOptional(assignToP), handler); ``` -## Authentication Priority +### Authentication Priority -The hybrid middleware checks authentication in this order: +1. XID JWT - External participant tokens +2. Anonymous JWT - Anonymous participant tokens +3. Standard User JWT - OIDC user participant tokens +4. OIDC JWT - Direct OIDC tokens +5. Legacy Cookie - Backward compatibility -1. **OIDC JWT** - Standard user authentication -2. **XID JWT** - External participant authentication -3. **Anonymous JWT** - Anonymous participant authentication -4. **Legacy Cookie** - Permanent cookie (`pc`) lookup for existing participants (transitional) +## Full Documentation -## Environment Variables - -```bash -# OIDC Configuration (for standard users) -AUTH_ISSUER=https://your-tenant.auth0.com/ -AUTH_AUDIENCE=users -JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json - -# In-house JWT Configuration (for XID and anonymous participants) -POLIS_JWT_ISSUER=https://pol.is/ -POLIS_JWT_AUDIENCE=participants -JWT_PRIVATE_KEY_PATH=./keys/jwt-private.pem -JWT_PUBLIC_KEY_PATH=./keys/jwt-public.pem -``` +For complete authentication documentation including flows, token structures, and client integration, see: -## Database Requirements - -OIDC user mapping table (migration `000010_create_oidc_user_mappings.sql`): - -```sql -CREATE TABLE oidc_user_mappings ( - oidc_sub VARCHAR(255) PRIMARY KEY, - uid INTEGER NOT NULL REFERENCES users(uid), - created BIGINT DEFAULT now_as_millis() -); -``` +📖 **[/docs/AUTHENTICATION.md](../../docs/AUTHENTICATION.md)** ## Testing ```bash -# Test OIDC JWT authentication -npm test -- __tests__/integration/auth-jwt.test.ts +# Run all auth tests +npm test -- __tests__/integration/auth +npm test -- __tests__/unit/*jwt* -# Test XID authentication +# Specific test files +npm test -- __tests__/integration/auth-jwt.test.ts npm test -- __tests__/integration/xid-auth.test.ts - -# Test route authentication -npm test -- __tests__/integration/routes-jwt-validation.test.ts -``` - -## Migration Status - -✅ **Completed**: OIDC JWT, XID JWT infrastructure, hybrid middleware, Anonymous JWT, legacy cookie support - -🚧 **In Progress**: Route validation, client updates - -See the [Migration Guide](../../docs/MIGRATION_GUIDE.md) for detailed status and next steps. +npm test -- __tests__/integration/anonymous-jwt.test.ts +``` \ No newline at end of file diff --git a/server/src/auth/attach-auth-token.ts b/server/src/auth/attach-auth-token.ts new file mode 100644 index 0000000000..8ba19e7e13 --- /dev/null +++ b/server/src/auth/attach-auth-token.ts @@ -0,0 +1,163 @@ +/** + * Middleware for automatically attaching auth tokens to JSON responses + * + * This middleware intercepts res.json() calls and automatically adds the auth token + * from req.p.authToken if it exists. This eliminates the need for route handlers + * to manually include the token in their responses. + * + * IMPORTANT: This middleware should be placed AFTER ensureParticipant middleware + * in the middleware chain, as ensureParticipant is responsible for creating + * req.p.authToken when a new JWT needs to be issued. + * + * The middleware will only attach tokens to successful JSON responses (status 200/201) + * and will not modify error responses or responses that already have an auth property. + */ + +import { Response, NextFunction } from "express"; +import { RequestWithP } from "../d"; + +/** + * Middleware that automatically attaches auth tokens to JSON responses + * + * Usage: + * - Apply this middleware AFTER ensureParticipant middleware + * - It will automatically add { auth: { token, token_type, expires_in } } to JSON responses + * - Only affects res.json() calls, not res.send(), res.end(), etc. + * + * @example + * app.post('/api/v3/votes', + * hybridAuthOptional(assignToP), + * ensureParticipant({ createIfMissing: true, issueJWT: true }), + * attachAuthToken(), // Add this after ensureParticipant + * handle_POST_votes + * ); + */ +export function attachAuthToken() { + return function attachAuthTokenMiddleware( + req: RequestWithP, + res: Response, + next: NextFunction + ) { + // Store the original res.json function + const originalJson = res.json.bind(res); + + // Override res.json to inject auth token if present + res.json = function (body: any) { + // Check if we have an auth token to attach + if ( + req.p?.authToken && + body && + typeof body === "object" && + !Array.isArray(body) + ) { + // Only add auth token if the response doesn't already have one + if (!body.auth) { + // Create a new object to avoid mutating the original + body = { + ...body, + auth: req.p.authToken, + }; + } + } + + // Call the original res.json with the potentially modified body + return originalJson(body); + }; + + next(); + }; +} + +/** + * Conditional version that only attaches tokens for specific status codes + * + * @param statusCodes - Array of status codes to attach tokens for (default: [200, 201]) + */ +export function attachAuthTokenConditional(statusCodes: number[] = [200, 201]) { + return function attachAuthTokenConditionalMiddleware( + req: RequestWithP, + res: Response, + next: NextFunction + ) { + const originalJson = res.json.bind(res); + + res.json = function (body: any) { + // Only attach token for successful responses + const currentStatus = res.statusCode; + + if ( + statusCodes.includes(currentStatus) && + req.p?.authToken && + body && + typeof body === "object" && + !Array.isArray(body) && + !body.auth + ) { + body = { + ...body, + auth: req.p.authToken, + }; + } + + return originalJson(body); + }; + + next(); + }; +} + +/** + * Version that can be configured to exclude certain response properties + * + * @param options - Configuration options + */ +export interface AttachAuthTokenOptions { + /** + * Only attach token for these status codes + */ + statusCodes?: number[]; + + /** + * Don't attach token if response has any of these properties + */ + excludeIfHasProperties?: string[]; +} + +export function attachAuthTokenWithOptions( + options: AttachAuthTokenOptions = {} +) { + const { statusCodes = [200, 201], excludeIfHasProperties = [] } = options; + + return function attachAuthTokenWithOptionsMiddleware( + req: RequestWithP, + res: Response, + next: NextFunction + ) { + const originalJson = res.json.bind(res); + + res.json = function (body: any) { + const currentStatus = res.statusCode; + + // Check all conditions for attaching token + const shouldAttach = + statusCodes.includes(currentStatus) && + req.p?.authToken && + body && + typeof body === "object" && + !Array.isArray(body) && + !body.auth && + !excludeIfHasProperties.some((prop) => prop in body); + + if (shouldAttach) { + body = { + ...body, + auth: req.p.authToken, + }; + } + + return originalJson(body); + }; + + next(); + }; +} diff --git a/server/src/auth/ensure-participant.ts b/server/src/auth/ensure-participant.ts new file mode 100644 index 0000000000..0a25f47fe3 --- /dev/null +++ b/server/src/auth/ensure-participant.ts @@ -0,0 +1,580 @@ +/** + * Middleware for ensuring a participant exists for the current request + * + * This middleware handles the complete flow of participant identification and creation: + * 1. Handles JWT conversation mismatches + * 2. Checks legacy cookies + * 3. Creates anonymous users if needed + * 4. Gets or creates participant records + * 5. Issues JWTs for new participants + * + * This consolidates logic that was previously duplicated across multiple routes + * (votes, comments, etc.) into a single reusable middleware. + */ + +import _ from "underscore"; +import { addParticipantAndMetadata } from "../participant"; +import { checkLegacyCookieAndIssueJWT } from "./legacyCookies"; +import { createAnonUser } from "./create-user"; +import { getPidPromise } from "../user"; +import { getZinvite } from "../utils/zinvite"; +import { isDuplicateKey } from "../utils/common"; +import { issueAnonymousJWT } from "./anonymous-jwt"; +import { issueStandardUserJWT } from "./standard-user-jwt"; +import { issueXidJWT } from "./xid-jwt"; +import { RequestWithP } from "../d"; +import { Response, NextFunction } from "express"; +import logger from "../utils/logger"; +import { + createXidRecordByZid, + getConversationInfo, + getXidRecord, + isXidWhitelisted, + getZidFromConversationId, +} from "../conversation"; + +// Validation function for conversation_id (same as in parameter.ts) +function validateConversationId(conversation_id: string): string { + if (!conversation_id || typeof conversation_id !== "string") { + throw new Error("polis_fail_parse_string"); + } + if (conversation_id.length < 1 || conversation_id.length > 100) { + throw new Error("polis_fail_parse_string_length"); + } + return conversation_id; +} + +interface ParticipantCreationResult { + uid: number; + pid: number; + isNewlyCreatedUser: boolean; + isNewlyCreatedParticipant: boolean; + needsNewJWT: boolean; + token?: string; + conversationId?: string; +} + +interface EnsureParticipantOptions { + /** + * Whether to create a new participant if one doesn't exist + * Default: true + */ + createIfMissing?: boolean; + + /** + * Whether to issue a JWT for newly created participants + * Default: true + */ + issueJWT?: boolean; + + /** + * Property name to store the participant info in req.p + * Default: uses existing properties (uid, pid, etc.) + */ + resultProperty?: string; + + /** + * Custom assigner function for setting values on the request + */ + assigner?: (req: RequestWithP, key: string, value: unknown) => void; +} + +/** + * Handle JWT conversation mismatches + * Returns true if the request should be treated as a new participant + */ +async function _handleJWTConversationMismatch( + req: RequestWithP, + zid: number +): Promise { + if (!req.p.jwt_conversation_mismatch) { + return false; + } + + if (req.p.anonymous_participant) { + // Anonymous participant with JWT for different conversation - treat as new + logger.debug( + "Anonymous participant with JWT for different conversation - treating as new" + ); + req.p.uid = undefined; + req.p.pid = undefined; + return true; + } + + if (req.p.xid_participant && req.p.xid) { + // XID participant - apply the 4-case logic + const jwtXid = req.p.jwt_xid; + const requestXid = req.p.xid; + const xidMatches = jwtXid === requestXid; + + // Check if XID exists for current conversation + let xidForCurrentConversation = false; + try { + const xidRecords = await getXidRecord(requestXid, zid); + if (xidRecords && xidRecords.length > 0) { + xidForCurrentConversation = true; + } + } catch (err) { + // XID not found for this conversation + } + + if (xidMatches) { + // Case 2: Token and XID align but are for different conversation + logger.debug( + "Case 2: XID participant with matching JWT/XID for different conversation - treating as anonymous" + ); + req.p.xid = undefined; // Clear XID to treat as anonymous + req.p.uid = undefined; + req.p.pid = undefined; + return true; + } else if (!xidMatches && xidForCurrentConversation) { + // Case 3: Token for different conversation, but XID is for current + logger.debug( + "Case 3: XID participant with mismatched JWT but XID for current conversation - maintaining XID" + ); + req.p.uid = undefined; + req.p.pid = undefined; + return true; + } else { + // Case 4: Token for current conversation, but XID for different + logger.debug( + "Case 4: XID participant with JWT for current conversation but XID for different - treating as anonymous" + ); + req.p.xid = undefined; // Clear XID + // Keep uid/pid from JWT + return false; + } + } + + if (req.p.standard_user_participant) { + // Standard user participants should maintain their identity + // but get a new participant record for the new conversation + req.p.pid = undefined; + return true; + } + + return false; +} + +/** + * Handle user identification and creation + */ +async function _handleUserIdentification( + req: RequestWithP, + zid: number +): Promise { + const uid = req.p.uid; + + if (uid !== undefined) { + return uid; + } + + if (req.p.xid) { + // Handle XID users - look up or create their UID + const existingXidRecords = await getXidRecord(req.p.xid, zid); + + if (existingXidRecords && existingXidRecords.length > 0) { + // XID user already exists + return existingXidRecords[0].uid; + } + + // XID user doesn't exist, need to create one + const conv = await getConversationInfo(zid); + if (conv.use_xid_whitelist) { + const isWhitelisted = await isXidWhitelisted(conv.owner, req.p.xid); + if (!isWhitelisted) { + throw new Error("polis_err_xid_not_whitelisted"); + } + } + + // Create new anonymous user for this XID + const newUid = await createAnonUser(); + + // Create XID record linking the XID to the new user + await createXidRecordByZid( + zid, + newUid, + req.p.xid, + undefined, + undefined, + undefined + ); + + return newUid; + } + + // Create anonymous user + const newUid = await createAnonUser(); + req.p.uid = newUid; // Set uid in request for subsequent middleware + return newUid; +} + +/** + * Get or create participant for the given user and conversation + */ +async function _getOrCreateParticipant( + zid: number, + uid: number, + existingPid: number | undefined, + req: RequestWithP +): Promise<{ pid: number; isNewlyCreated: boolean }> { + if (existingPid !== undefined && existingPid >= 0) { + return { pid: existingPid, isNewlyCreated: false }; + } + + // Check if participant already exists + const foundPid = await getPidPromise(zid, uid, true); + + if (foundPid !== -1) { + return { pid: foundPid, isNewlyCreated: false }; + } + + // Create new participant with constraint violation protection + try { + const rows = await addParticipantAndMetadata(zid, uid, req); + return { pid: rows[0].pid, isNewlyCreated: true }; + } catch (createError) { + // Handle race condition where another request created the participant + if (isDuplicateKey(createError)) { + const retryPid = await getPidPromise(zid, uid, true); + if (retryPid !== -1) { + return { pid: retryPid, isNewlyCreated: false }; + } + } + throw createError; + } +} + +/** + * Issue JWT token for the participant if needed + */ +async function _issueJWTIfNeeded( + req: RequestWithP, + uid: number, + pid: number, + zid: number, + isNewlyCreated: boolean, + needsNewJWT: boolean +): Promise<{ token?: string; conversationId?: string }> { + // Only issue JWT for: + // 1. Newly created participants + // 2. Participants that need a new JWT (conversation mismatch) + // 3. Legacy cookie users who need migration + // AND when they don't already have a valid JWT + const shouldIssueJWT = + (isNewlyCreated || needsNewJWT) && + (!req.headers?.authorization || req.p.jwt_conversation_mismatch); + + if (!shouldIssueJWT) { + logger.debug("JWT not needed", { + isNewlyCreated, + needsNewJWT, + hasAuthHeader: !!req.headers?.authorization, + jwt_conversation_mismatch: req.p.jwt_conversation_mismatch, + }); + return {}; + } + + logger.debug("Determining if JWT should be issued", { + uid, + pid, + zid, + isNewlyCreated, + needsNewJWT, + hasAuthHeader: !!req.headers?.authorization, + }); + + try { + const conversationId = (await getZinvite(zid)) as string; + + if (!conversationId) { + throw new Error(`Could not find conversation_id for zid ${zid}`); + } + + logger.debug("Got conversation ID for JWT", { conversationId, zid }); + + // Determine which type of JWT to issue + let token; + let tokenType; + + if (req.p.oidc_sub) { + // Standard user with OIDC authentication + token = issueStandardUserJWT(req.p.oidc_sub, conversationId, uid, pid); + tokenType = "StandardUser"; + } else if (req.p.xid) { + // XID participant + token = issueXidJWT(req.p.xid, conversationId, uid, pid); + tokenType = "XID"; + } else { + // Anonymous participant + token = issueAnonymousJWT(conversationId, uid, pid); + tokenType = "Anonymous"; + } + + logger.debug("JWT issued successfully", { + tokenType, + uid, + pid, + conversationId, + oidc_sub: req.p.oidc_sub, + }); + + return { token, conversationId }; + } catch (error) { + logger.error("Failed to issue JWT:", error); + return {}; // Continue without JWT - maintains backward compatibility + } +} + +/** + * Main function to ensure participant exists + */ +async function _ensureParticipantInternal( + req: RequestWithP, + options: EnsureParticipantOptions = {} +): Promise { + const { createIfMissing = true, issueJWT = true, assigner } = options; + + // Try to get zid from conversation_id if not already present + let zid = req.p.zid; + if (!zid && req.p.conversation_id) { + // Validate conversation_id first + const validatedConversationId = validateConversationId( + req.p.conversation_id + ); + + // Resolve zid from validated conversation_id + zid = await getZidFromConversationId(validatedConversationId); + req.p.zid = zid; // Update the request with the resolved zid + } + + if (!zid) { + throw new Error("polis_err_missing_zid"); + } + + let uid = req.p.uid; + let pid = req.p.pid; + let isNewlyCreatedUser = false; + let isNewlyCreatedParticipant = false; + let needsNewJWT = false; + let legacyCookieToken: string | undefined; + + // Handle JWT conversation mismatches + const treatedAsNew = await _handleJWTConversationMismatch(req, zid); + if (treatedAsNew) { + needsNewJWT = true; + } + + // Check for legacy cookie before creating new user + if (uid === undefined && !req.p.jwt_conversation_mismatch) { + const conversationId = req.p.conversation_id || (await getZinvite(zid)); + if (conversationId) { + const legacyResult = await checkLegacyCookieAndIssueJWT( + req, + zid, + conversationId as string, + req.p.xid + ); + if (legacyResult.uid !== undefined && legacyResult.pid !== undefined) { + uid = legacyResult.uid; + pid = legacyResult.pid; + needsNewJWT = legacyResult.needsNewJwt; + legacyCookieToken = legacyResult.token; + logger.info("Using existing participant from legacy cookie", { + uid, + pid, + }); + } + } + } + + // Handle user identification + // For XID users, we always need to look up the UID even if not creating + if (uid === undefined) { + if (req.p.xid) { + // For XID users, try to look up existing UID + const existingXidRecords = await getXidRecord(req.p.xid, zid); + if (existingXidRecords && existingXidRecords.length > 0) { + uid = existingXidRecords[0].uid; + } else if (createIfMissing) { + // Only create new XID user if createIfMissing is true + uid = await _handleUserIdentification(req, zid); + isNewlyCreatedUser = true; + } + } else if (createIfMissing) { + // For non-XID users, only create if createIfMissing is true + uid = await _handleUserIdentification(req, zid); + isNewlyCreatedUser = true; + } + } + + if (uid === undefined) { + throw new Error("polis_err_user_not_found"); + } + + // Get or create participant if needed + if (createIfMissing) { + const participantResult = await _getOrCreateParticipant(zid, uid, pid, req); + pid = participantResult.pid; + isNewlyCreatedParticipant = participantResult.isNewlyCreated; + } else if (pid === undefined) { + // Just look up existing participant + pid = await getPidPromise(zid, uid, true); + if (pid === -1) { + throw new Error("polis_err_participant_not_found"); + } + } + + // Issue JWT if needed + let token = legacyCookieToken; + let conversationId: string | undefined; + + if (issueJWT && !legacyCookieToken) { + const jwtResult = await _issueJWTIfNeeded( + req, + uid, + pid, + zid, + isNewlyCreatedParticipant || isNewlyCreatedUser, + needsNewJWT + ); + token = jwtResult.token; + conversationId = jwtResult.conversationId; + } else if (legacyCookieToken) { + conversationId = + req.p.conversation_id || ((await getZinvite(zid)) as string); + } + + // Update request with final values + req.p.uid = uid; + req.p.pid = pid; + req.p.zid = zid; + + if (assigner) { + assigner(req, "uid", uid); + assigner(req, "pid", pid); + assigner(req, "zid", zid); + } + + return { + uid, + pid, + isNewlyCreatedUser, + isNewlyCreatedParticipant, + needsNewJWT, + token, + conversationId, + }; +} + +/** + * Express middleware factory for ensuring participant exists + * + * @param options Configuration options for the middleware + * @returns Express middleware function + */ +export function ensureParticipant(options: EnsureParticipantOptions = {}) { + return async function ensureParticipantMiddleware( + req: RequestWithP, + res: Response, + next: NextFunction + ) { + try { + const result = await _ensureParticipantInternal(req, options); + + // Store the result for use in the route handler + req.p = req.p || {}; + req.p.participantInfo = result; + + // If a JWT was issued, we can optionally attach it to the request + // The route handler can decide whether to include it in the response + if (result.token) { + req.p.authToken = { + token: result.token, + token_type: "Bearer", + expires_in: 365 * 24 * 60 * 60, // 1 year + }; + } + + next(); + } catch (error) { + logger.error("Error in ensureParticipant middleware", error); + + // Pass specific errors to the error handler + if (error instanceof Error && error.message?.includes("polis_err")) { + next(error); + } else { + next(new Error("polis_err_participant_creation")); + } + } + }; +} + +/** + * Optional version that doesn't fail if participant can't be created + */ +export function ensureParticipantOptional( + options: EnsureParticipantOptions = {} +) { + return async function ensureParticipantOptionalMiddleware( + req: RequestWithP, + res: Response, + next: NextFunction + ) { + try { + const result = await _ensureParticipantInternal(req, { + ...options, + createIfMissing: false, + }); + + req.p = req.p || {}; + req.p.participantInfo = result; + + if (result.token) { + req.p.authToken = { + token: result.token, + token_type: "Bearer", + expires_in: 365 * 24 * 60 * 60, + }; + } + + next(); + } catch (error) { + // For optional middleware, we continue even if participant isn't found + logger.debug("Participant not found (optional)", error); + req.p = req.p || {}; + req.p.participantInfo = null; + next(); + } + }; +} + +/** + * Middleware that only creates participant if they're taking an action + * Useful for routes like participationInit that shouldn't create participants + */ +export function ensureParticipantOnAction( + options: EnsureParticipantOptions = {} +) { + return async function ensureParticipantOnActionMiddleware( + req: RequestWithP, + res: Response, + next: NextFunction + ) { + // Only create participant if this is an action (POST/PUT/DELETE) + const shouldCreate = ["POST", "PUT", "DELETE", "PATCH"].includes( + req.method + ); + + const finalOptions = { + ...options, + createIfMissing: shouldCreate, + }; + + if (shouldCreate) { + return ensureParticipant(finalOptions)(req, res, next); + } else { + return ensureParticipantOptional(finalOptions)(req, res, next); + } + }; +} diff --git a/server/src/auth/index.ts b/server/src/auth/index.ts index 3a5f42306a..d33ec41835 100644 --- a/server/src/auth/index.ts +++ b/server/src/auth/index.ts @@ -46,6 +46,13 @@ export { generateToken, generateTokenP } from "./generate-token"; // Hybrid JWT middleware export { hybridAuth, hybridAuthOptional } from "./hybrid-jwt"; +// Auth token attachment middleware +export { + attachAuthToken, + attachAuthTokenConditional, + attachAuthTokenWithOptions, +} from "./attach-auth-token"; + // JWT middleware (for direct use if needed) export { extractUserFromJWT, @@ -92,3 +99,10 @@ export { xidJwtValidation, xidJwtValidationOptional, } from "./xid-jwt"; + +// Participant management middleware +export { + ensureParticipant, + ensureParticipantOptional, + ensureParticipantOnAction, +} from "./ensure-participant"; diff --git a/server/src/comment.ts b/server/src/comment.ts index f340cd0742..e14dab0558 100644 --- a/server/src/comment.ts +++ b/server/src/comment.ts @@ -1,5 +1,4 @@ import _ from "underscore"; - // eslint-disable-next-line @typescript-eslint/no-var-requires const { Translate } = require("@google-cloud/translate").v2; @@ -11,7 +10,7 @@ import pg from "./db/pg-query"; import SQL from "./db/sql"; import Utils from "./utils/common"; -type Row = { +export type CommentRow = { tid: number; disagree_count: number; agree_count: number; @@ -31,34 +30,44 @@ type Row = { zid?: number; mod?: number; active?: boolean; + randomN?: number; +}; + +export type CommentTranslationRow = { + zid: number; + tid: number; + txt: string; + lang: string; + src: number; + modified?: any; }; type Docs = { - rows: Row[]; + rows: CommentRow[]; }; const useTranslateApi: boolean = Config.shouldUseTranslationAPI; const translateClient = useTranslateApi ? new Translate() : null; -function getComment(zid: number, tid: number): Promise { +function getComment(zid: number, tid: number): Promise { return pg .queryP("select * from comments where zid = ($1) and tid = ($2);", [ zid, tid, ]) - .then((rows: Row[]) => { + .then((rows: CommentRow[]) => { return (rows && rows[0]) || null; }); } -function getComments(o: GetCommentsParams): Promise { +function getComments(o: GetCommentsParams): Promise { const commentListPromise = o.moderation ? _getCommentsForModerationList(o as any) : _getCommentsList(o as any); const convPromise = getConversationInfo(o.zid); return Promise.all([convPromise, commentListPromise]) - .then(function (a: [any, Row[]]) { - let rows: Row[] = a[1]; + .then(function (a: [any, CommentRow[]]) { + let rows: CommentRow[] = a[1]; const cols = [ "txt", "tid", @@ -80,8 +89,8 @@ function getComments(o: GetCommentsParams): Promise { cols.push("pass_count"); // in moderation queries, we join in the vote count cols.push("count"); // in moderation queries, we join in the vote count } - rows = rows.map(function (row: Row): Row { - const x = _.pick(row, cols) as Row; + rows = rows.map(function (row: CommentRow): CommentRow { + const x = _.pick(row, cols) as CommentRow; if (!_.isUndefined(x.count)) { x.count = Number(x.count); } @@ -89,7 +98,7 @@ function getComments(o: GetCommentsParams): Promise { }); return rows; }) - .then(function (comments: Row[]): Row[] { + .then(function (comments: CommentRow[]): CommentRow[] { comments.forEach(function (c: { uid?: any }) { delete c.uid; }); @@ -104,7 +113,7 @@ function _getCommentsForModerationList(o: { strict_moderation: any; mod: any; mod_gt: any; -}): Promise { +}): Promise { let strictCheck: Promise = Promise.resolve(null); const include_voting_patterns = o.include_voting_patterns; @@ -118,7 +127,7 @@ function _getCommentsForModerationList(o: { }); } - return strictCheck.then((strict_moderation): Promise => { + return strictCheck.then((strict_moderation): Promise => { let modClause = ""; const params = [o.zid]; if (!_.isUndefined(o.mod)) { @@ -147,7 +156,7 @@ function _getCommentsForModerationList(o: { "_getCommentsForModerationList", "select * from comments where comments.zid = ($1)" + modClause, params - ) as Promise; + ) as Promise; } return pg @@ -157,9 +166,9 @@ function _getCommentsForModerationList(o: { modClause, params ) - .then((rows: Row[]) => { + .then((rows: CommentRow[]) => { // each comment will have up to three rows. merge those into one with agree/disagree/pass counts. - const adp: { [key: string]: Row } = {}; + const adp: { [key: string]: CommentRow } = {}; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const o = (adp[row.tid] = adp[row.tid] || { @@ -204,10 +213,13 @@ function _getCommentsList(o: { moderation: any; random: any; limit: any; -}): Promise { +}): Promise { return MPromise( "_getCommentsList", - function (resolve: (rows: Row[]) => void, reject: (arg0: any) => void) { + function ( + resolve: (rows: CommentRow[]) => void, + reject: (arg0: any) => void + ) { getConversationInfo(o.zid).then(function (conv: ConversationInfo) { let q = SQL.sql_comments .select(SQL.sql_comments.star()) @@ -271,7 +283,7 @@ function _getCommentsList(o: { }); }); } - ) as Promise; + ) as Promise; } function getNumberOfCommentsRemaining(zid: number, pid: number): Promise { @@ -291,7 +303,7 @@ function translateAndStoreComment( tid: number, txt: any, lang: string -): Promise { +): Promise { if (useTranslateApi) { return translateString(txt, lang).then((results: any[]) => { const translation = results[0]; @@ -305,7 +317,7 @@ function translateAndStoreComment( "returning *;", [zid, tid, translation, lang, src] ) - .then((rows: Row[]) => { + .then((rows: CommentTranslationRow[]) => { return rows[0]; }); }); @@ -335,21 +347,11 @@ function detectLanguage( } export { - getComment, - getComments, - _getCommentsForModerationList, - _getCommentsList, - getNumberOfCommentsRemaining, - translateAndStoreComment, detectLanguage, -}; - -export default { getComment, getComments, - _getCommentsForModerationList, - _getCommentsList, getNumberOfCommentsRemaining, translateAndStoreComment, - detectLanguage, }; + +// types already exported above via `export type` diff --git a/server/src/config.ts b/server/src/config.ts index acb6a52d6a..a19d2a7cee 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -62,6 +62,15 @@ export default { } }, + getValidTopicalRatio(): number | null { + const raw = process.env.TOPICAL_COMMENT_RATIO; + if (raw === undefined || raw === null || raw === "") return null; + const val = parseFloat(raw); + if (!Number.isFinite(val)) return null; + if (val < 0 || val > 1) return null; + return val; + }, + adminEmailDataExport: process.env.ADMIN_EMAIL_DATA_EXPORT as string, adminEmailDataExportTest: process.env.ADMIN_EMAIL_DATA_EXPORT_TEST as string, adminEmailEmailTest: process.env.ADMIN_EMAIL_EMAIL_TEST as string, diff --git a/server/src/d.ts b/server/src/d.ts index 1c1e5155aa..ca6d639a60 100644 --- a/server/src/d.ts +++ b/server/src/d.ts @@ -62,6 +62,11 @@ export type PidReadyResult = { nextComment?: any; currentPid?: any; shouldMod?: any; + auth?: { + token: string; + token_type: string; + expires_in: number; + }; }; export type CommentOptions = { @@ -73,7 +78,6 @@ type ModerationState = -1 | 0 | 1; export type GetCommentsParams = { zid: number; not_voted_by_pid?: number; - include_social?: any; withoutTids?: any; tid?: number; translations?: any; @@ -255,16 +259,74 @@ export type ParticipantOption = { "base-clusters": any; }; -export type Vote = { - uid?: number; - zid: number; - pid: number; - lang: any; - tid: number; - xid: string; - vote: any; - weight: any; - starred: any; - parent_url: any; - high_priority: any; -}; +// Centralized request type for routes that use the 'p' parameter pattern +export interface RequestWithP extends Omit { + p: { + // User and participant identifiers + uid?: number; + pid?: number; + zid?: number; + xid?: string; + conversation_id?: string; + + // Authentication-related + oidc_sub?: string; + oidcUser?: any; + jwt_conversation_mismatch?: boolean; + jwt_conversation_id?: string; + jwt_xid?: string; + requested_conversation_id?: string; + anonymous_participant?: boolean; + xid_participant?: boolean; + standard_user_participant?: boolean; + + // Participant info (added by middleware) + participantInfo?: { + uid: number; + pid: number; + isNewlyCreatedUser: boolean; + isNewlyCreatedParticipant: boolean; + needsNewJWT: boolean; + token?: string; + conversationId?: string; + } | null; + + // Auth token (added by middleware) + authToken?: { + token: string; + token_type: string; + expires_in: number; + }; + + // Common request parameters + parent_url?: string; + referrer?: string; + answers?: any; + suzinvite?: string; + + // Action-specific parameters + tid?: number; + txt?: string; + vote?: any; + weight?: any; + starred?: any; + high_priority?: any; + is_seed?: boolean; + lang?: string; + + // Allow additional properties + [key: string]: any; + }; + + // Headers with common fields + headers: Headers; + + // HTTP method + method: string; + + // Cookies (optional in Express) + cookies?: { + pc?: string; + [key: string]: string | undefined; + }; +} diff --git a/server/src/nextComment.ts b/server/src/nextComment.ts new file mode 100644 index 0000000000..720d01b4cc --- /dev/null +++ b/server/src/nextComment.ts @@ -0,0 +1,443 @@ +import _ from "underscore"; +import { DynamoDBClient, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; + +import { GetCommentsParams } from "./d"; +import { getPca } from "./utils/pca"; +import Config from "./config"; +import logger from "./utils/logger"; +import pg from "./db/pg-query"; +import { + CommentRow, + CommentTranslationRow, + getComments, + getNumberOfCommentsRemaining, + translateAndStoreComment, +} from "./comment"; + +// DynamoDB client for topic agenda lookups +const dynamoDBConfig: DynamoDBClientConfig = { + region: Config.AWS_REGION || "us-east-1", +}; + +if (Config.dynamoDbEndpoint) { + // Local/test DynamoDB + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - endpoint is allowed on the client config at runtime + dynamoDBConfig.endpoint = Config.dynamoDbEndpoint; + dynamoDBConfig.credentials = { + accessKeyId: "DUMMYIDEXAMPLE", + secretAccessKey: "DUMMYEXAMPLEKEY", + }; +} else if (Config.AWS_ACCESS_KEY_ID && Config.AWS_SECRET_ACCESS_KEY) { + dynamoDBConfig.credentials = { + accessKeyId: Config.AWS_ACCESS_KEY_ID, + secretAccessKey: Config.AWS_SECRET_ACCESS_KEY, + }; +} + +const dynamoClient = new DynamoDBClient(dynamoDBConfig); +const dynamoDocClient = DynamoDBDocumentClient.from(dynamoClient, { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + }, +}); +const DELPHI_COMMENT_HIERARCHICAL_TABLE = + "Delphi_CommentHierarchicalClusterAssignments"; +const DELPHI_TOPIC_NAMES_TABLE = "Delphi_CommentClustersLLMTopicNames"; + +// This very much follows the outline of the random selection above, but factors out the probabilistic logic +// to the selectProbabilistically fn above. +async function getNextPrioritizedComment( + zid: number, + pid: number, + withoutTids?: Array +): Promise { + const params: Partial = { + zid, + not_voted_by_pid: pid, + }; + if (Array.isArray(withoutTids) && withoutTids.length > 0) { + params.withoutTids = withoutTids; + } + + logger.debug("polis_info_getNextPrioritizedComment", { + zid, + pid, + withoutTids, + }); + + const [comments, mathRaw, remainingRows] = (await Promise.all([ + getComments(params as GetCommentsParams), + getPca(zid, 0), + getNumberOfCommentsRemaining(zid, pid), + ])) as [ + CommentRow[], + { asPOJO?: Record } | null, + Array<{ total: number; remaining: number }> + ]; + + if (!comments || comments.length === 0) { + return null; + } + if (!remainingRows || remainingRows.length === 0) { + throw new Error(`polis_err_getNumberOfCommentsRemaining_${zid}_${pid}`); + } + + const math = mathRaw || { asPOJO: {} }; + const commentPriorities = + (math.asPOJO && + (math.asPOJO["comment-priorities"] as Record)) || + {}; + + const totalCount = Number(remainingRows[0].total); + const remainingCount = Number(remainingRows[0].remaining); + + const selectedRow = selectProbabilistically( + comments, + commentPriorities + ) as unknown as GetCommentsParams; + selectedRow.remaining = remainingCount; + selectedRow.total = totalCount; + return selectedRow; +} + +function selectProbabilistically( + comments: CommentRow[], + priorities: Record +): CommentRow { + // Here we go through all of the comments we might select for the user and add their priority values + const lookup = _.reduce( + comments, + ( + o: { lastCount: number; lookup: Array<[number, CommentRow]> }, + comment: CommentRow + ) => { + // If we like, we can use nTotal and nRemaining here to figure out how much we should emphasize the + // priority, potentially. Maybe we end up with different classes of priorities lists for this purpose? + // scaling this value in some way may also be helpful. + const lookup_val = o.lastCount + (priorities[comment.tid] || 1); + o.lookup.push([lookup_val, comment]); + o.lastCount = lookup_val; + return o; + }, + { lastCount: 0, lookup: [] } + ); + // We arrange a random number that should fall somewhere in the range of the lookup_vals + const randomN = Math.random() * lookup.lastCount; + // Return the first one that has a greater lookup; could eventually replace this with something smarter + // that does a bisectional lookup if performance becomes an issue. But I want to keep the implementation + // simple to reason about all other things being equal. + const result = _.find( + lookup.lookup, + (x: [number, CommentRow]) => x[0] > randomN + ); + const c = result?.[1] as CommentRow; + if (c) { + c.randomN = randomN; + } + return c; +} + +/** + * Fetch the set of tids (comment ids) for a participant's current topic agenda. + * - Reads the most recent topic agenda selections from PostgreSQL for the pid+zid pair + * - Extracts unique topic_keys from the stored selections + * - For each topic_key, queries DynamoDB table "Delphi_CommentClustersLLMTopicNames" to get layer_id and cluster_id + * - Then queries "Delphi_CommentHierarchicalClusterAssignments" using the appropriate layerX_cluster_id column + * - Returns a de-duplicated array of tids + * + * If there is no record or selections are empty, returns an empty array. + * If all Dynamo lookups fail, throws an error. + */ +export async function getTidsForParticipantTopicAgenda( + zid: number, + pid: number +): Promise { + // Single row per (zid, pid) due to composite primary key + const rows = (await pg.queryP( + "SELECT archetypal_selections, delphi_job_id FROM topic_agenda_selections WHERE zid = ($1) AND pid = ($2) LIMIT 1;", + [zid, pid] + )) as Array<{ + archetypal_selections: unknown; + delphi_job_id?: string | null; + }>; + + if (!Array.isArray(rows) || rows.length === 0) { + logger.warn("polis_warn_getTidsForParticipantTopicAgenda_no_rows", { + pid, + zid, + }); + return []; + } + + const record = rows[0]; + const selections = + (record?.archetypal_selections as Array<{ topic_key?: string }>) || []; + if (!Array.isArray(selections) || selections.length === 0) { + logger.warn("polis_warn_getTidsForParticipantTopicAgenda_no_selections", { + pid, + zid, + }); + return []; + } + + const uniqueTopicKeys: string[] = Array.from( + new Set( + selections + .map((s: { topic_key?: string }) => s?.topic_key) + .filter( + (k: unknown): k is string => typeof k === "string" && k.length > 0 + ) + ) + ); + + if (uniqueTopicKeys.length === 0) { + logger.warn( + "polis_warn_getTidsForParticipantTopicAgenda_no_unique_topic_keys", + { pid, zid } + ); + return []; + } + + const conversationZid = String(zid); + const tidSet = new Set(); + + // Step 1: Query Delphi_CommentClustersLLMTopicNames to get layer_id and cluster_id for each topic_key + const topicQueries = uniqueTopicKeys.map((topicKey) => + dynamoDocClient.send( + new QueryCommand({ + TableName: DELPHI_TOPIC_NAMES_TABLE, + KeyConditionExpression: "conversation_id = :cid AND topic_key = :tk", + ExpressionAttributeValues: { + ":cid": conversationZid, + ":tk": topicKey, + }, + }) + ) + ); + + const topicResults = await Promise.allSettled(topicQueries); + + // Collect layer_id/cluster_id pairs from topic lookup + type ClusterInfo = { + layer_id: number; + cluster_id: number; + topic_key: string; + }; + const clusterInfos: ClusterInfo[] = []; + + topicResults.forEach((result, idx) => { + if (result.status === "fulfilled") { + const items = result.value?.Items || []; + for (const item of items) { + // layer_id and cluster_id are stored as strings in DynamoDB + const layer_id = Number(item?.layer_id); + const cluster_id = Number(item?.cluster_id); + if (!Number.isNaN(layer_id) && !Number.isNaN(cluster_id)) { + clusterInfos.push({ + layer_id, + cluster_id, + topic_key: uniqueTopicKeys[idx], + }); + } + } + } else { + logger.error("polis_err_topic_names_dynamo_query_failed", { + topicKey: uniqueTopicKeys[idx], + error: result.reason, + }); + } + }); + + if (clusterInfos.length === 0) { + logger.warn("polis_warn_no_cluster_info_found", { zid, pid }); + return []; + } + + // Step 2: Query Delphi_CommentHierarchicalClusterAssignments for each layer_id/cluster_id pair + // We need to build appropriate filter expressions based on layer_id + const hierarchicalQueries = clusterInfos.map((info) => { + const layerColumn = `layer${info.layer_id}_cluster_id`; + + return dynamoDocClient.send( + new QueryCommand({ + TableName: DELPHI_COMMENT_HIERARCHICAL_TABLE, + KeyConditionExpression: "conversation_id = :cid", + FilterExpression: `${layerColumn} = :cluster_id`, + ExpressionAttributeValues: { + ":cid": conversationZid, + ":cluster_id": info.cluster_id, // Keep as number for DynamoDB comparison + }, + }) + ); + }); + + const hierarchicalResults = await Promise.allSettled(hierarchicalQueries); + + let numFulfilled = 0; + hierarchicalResults.forEach((result, idx) => { + if (result.status === "fulfilled") { + numFulfilled += 1; + const items = result.value?.Items || []; + for (const item of items) { + const tid = Number(item?.comment_id); + if (!Number.isNaN(tid)) { + tidSet.add(tid); + } + } + } else { + logger.error("polis_err_hierarchical_dynamo_query_failed", { + cluster_info: clusterInfos[idx], + error: result.reason, + }); + } + }); + + if (numFulfilled === 0) { + throw new Error("polis_err_topic_agenda_no_dynamo_results"); + } + + logger.debug("polis_info_getTidsForParticipantTopicAgenda_results", { + zid, + pid, + tidSet: Array.from(tidSet), + total_tids: tidSet.size, + }); + + return Array.from(tidSet.values()); +} + +/** + * Alternate next-comment pathway that respects a participant's topic agenda selections. + * - Builds the pool of tids via getTidsForParticipantTopicAgenda + * - Filters out comments the participant already voted on and moderated-out comments + * - Picks one at random + * - If no suitable comment is found, falls back to the traditional getNextComment + */ +export async function getNextTopicalComment( + zid: number, + pid: number, + withoutTids?: Array +): Promise { + try { + const tids = await getTidsForParticipantTopicAgenda(zid, pid); + if (!tids || tids.length === 0) { + // No topical pool → fallback to traditional behavior + logger.warn("polis_warn_next_topical_comment_no_tids", { pid, zid }); + return getNextPrioritizedComment(zid, pid, withoutTids); + } + + // Use the shared comments query to apply moderation and not_voted_by_pid filters. + const rows = await getComments({ + zid, + not_voted_by_pid: pid, + tids, + withoutTids, + random: true, + limit: 1, + } as unknown as GetCommentsParams); + + const r = rows && rows[0]; + if (!r) { + // Pool exhausted or filtered out → fallback + logger.warn("polis_warn_next_topical_comment_no_rows", { pid, zid }); + return getNextPrioritizedComment(zid, pid, withoutTids); + } + + const comment: GetCommentsParams = { + zid, + tid: r.tid, + txt: r.txt, + }; + + return comment; + } catch (err) { + logger.error("polis_err_next_topical_comment", err); + // Defensive: on any failure, fallback to traditional behavior + logger.warn("polis_warn_next_topical_comment_error", err); + return getNextPrioritizedComment(zid, pid, withoutTids); + } +} + +function getCommentTranslations( + zid: number, + tid: number +): Promise { + return pg.queryP( + "select * from comment_translations where zid = ($1) and tid = ($2);", + [zid, tid] + ) as Promise; +} + +export async function getNextComment( + zid?: number, + pid?: number, + withoutTids?: Array, + lang?: string +) { + const ratio = Config.getValidTopicalRatio(); + const shouldUseTopical = + typeof ratio === "number" && ratio > 0 && Math.random() < ratio; + + logger.info("polis_info_getNextComment", { + zid, + pid, + withoutTids, + lang, + shouldUseTopical, + }); + + let next: GetCommentsParams | null = null; + if (shouldUseTopical) { + next = await getNextTopicalComment(zid!, pid!, withoutTids); + } else { + next = await getNextPrioritizedComment(zid!, pid!, withoutTids); + } + + // If topical path yielded nothing, try prioritized as a fallback + if (!next && shouldUseTopical) { + logger.warn("polis_warn_next_topical_comment_no_rows_fallback", { + pid, + zid, + }); + next = await getNextPrioritizedComment(zid!, pid!, withoutTids); + } + + if (!next) return next; + + await ensureTranslations(zid!, next, lang); + + return next; +} + +async function ensureTranslations( + zid: number, + next: GetCommentsParams & { translations?: CommentTranslationRow[] }, + lang?: string +): Promise { + if (!lang) { + if (typeof next.translations === "undefined") { + next.translations = []; + } + return; + } + + const firstTwo = lang.slice(0, 2); + const translations = await getCommentTranslations(zid, next.tid!); + next.translations = translations; + + const hasMatch = translations.some((t) => t.lang.startsWith(firstTwo)); + if (!hasMatch) { + const translation = await translateAndStoreComment( + zid, + next.tid as number, + next.txt, + lang + ); + if (translation) { + next.translations.push(translation); + } + } +} diff --git a/server/src/participant.ts b/server/src/participant.ts index bf2f6c9aa0..19e4fdd9d6 100644 --- a/server/src/participant.ts +++ b/server/src/participant.ts @@ -249,7 +249,7 @@ function addParticipantAndMetadata( zid: number, uid?: number, req?: { - p: { parent_url: any }; + p: { parent_url?: any; [key: string]: any }; headers?: { [x: string]: any }; } ) { @@ -358,15 +358,7 @@ function getSocialParticipants( }); } -export { - addExtendedParticipantInfo, - addParticipant, - addParticipantAndMetadata, - getSocialParticipants, - joinConversation, -}; - -export async function getParticipantByPermanentCookie( +async function getParticipantByPermanentCookie( zid: number, permanentCookie: string ): Promise<{ uid: number; pid: number } | null> { @@ -396,3 +388,12 @@ export async function getParticipantByPermanentCookie( ); }); } + +export { + addExtendedParticipantInfo, + addParticipant, + addParticipantAndMetadata, + getParticipantByPermanentCookie, + getSocialParticipants, + joinConversation, +}; diff --git a/server/src/routes/collectiveStatement.ts b/server/src/routes/collectiveStatement.ts new file mode 100644 index 0000000000..4f362e5fee --- /dev/null +++ b/server/src/routes/collectiveStatement.ts @@ -0,0 +1,654 @@ +import { Request, Response } from "express"; +import logger from "../utils/logger"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + PutCommand, + GetCommand, + QueryCommand, +} from "@aws-sdk/lib-dynamodb"; +import { getZidFromReport } from "../utils/parameter"; +import Config from "../config"; +import Anthropic from "@anthropic-ai/sdk"; +import { v4 as uuidv4 } from "uuid"; +import pgQuery from "../db/pg-query"; + +const dynamoDBConfig: any = { + region: Config.AWS_REGION || "us-east-1", +}; + +if (Config.dynamoDbEndpoint) { + dynamoDBConfig.endpoint = Config.dynamoDbEndpoint; + dynamoDBConfig.credentials = { + accessKeyId: "DUMMYIDEXAMPLE", + secretAccessKey: "DUMMYEXAMPLEKEY", + }; +} else if (Config.AWS_ACCESS_KEY_ID && Config.AWS_SECRET_ACCESS_KEY) { + dynamoDBConfig.credentials = { + accessKeyId: Config.AWS_ACCESS_KEY_ID, + secretAccessKey: Config.AWS_SECRET_ACCESS_KEY, + }; +} + +const client = new DynamoDBClient(dynamoDBConfig); +const docClient = DynamoDBDocumentClient.from(client, { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + }, +}); + +const anthropic = Config.anthropicApiKey + ? new Anthropic({ + apiKey: Config.anthropicApiKey, + }) + : null; + +/** + * Generate a collective statement for a topic using Claude + */ +async function generateCollectiveStatement( + zid: number, + topicKey: string, + topicName: string, + commentsData: any +): Promise { + if (!anthropic) { + throw new Error("Anthropic API key not configured"); + } + + // Format comments data for the XML prompt + const formattedComments = commentsData.map((comment: any) => ({ + id: comment.comment_id, + text: comment.comment_text, + agrees: comment.agrees || 0, + disagrees: comment.disagrees || 0, + passes: comment.passes || 0, + total_votes: comment.total_votes || 0, + group_consensus: comment.group_consensus || null, + })); + + // Build the XML prompt + const systemPrompt = `You are a professional facilitator analyzing voting patterns across different participant groups. Your primary focus is on understanding how different groups voted on comments - looking for patterns of agreement within and between groups. Use the voting data to identify shared perspectives and create collective statements that reflect the actual voting consensus.`; + + const userPrompt = ` +Analyze the voting patterns in this topic to write a collective statement. + +IMPORTANT CONTEXT: All comments provided have already been filtered to meet strict criteria: +- Each has ≥80% consensus (normalized group-aware consensus) +- Each has ≥5% participation from EVERY group +- These represent the highest consensus comments from the topic + +Since all comments already meet high consensus thresholds, focus on synthesizing them into a coherent statement rather than filtering further. + +CRITICAL WRITING RULES: +- Write ONLY in first person plural ("We found consensus on...", "We agree...", "We support...") +- NEVER say "of participants", "of those voting", "of those who expressed an opinion" +- DO NOT include percentages or voting statistics in the text +- DO NOT qualify statements with voting data +- Trust that only high-consensus comments are being provided +- Let the citations [number] handle the reference to specific data +- Maintain the collective voice throughout - write as if everyone agrees because this is a candidate statement supported by votes that are transparently available + +GOOD: "We strongly believe cities should include nature in their designs[81]" +BAD: "97% of participants believe cities should include nature[81]" +BAD: "We believe cities should include nature, with 97% agreeing[81]" + +Base your analysis on the voting data, but express the results as collective statements without the statistics. + + + +${topicName} + + + +${JSON.stringify(formattedComments, null, 2)} + + + +- Focus on comments with high agreement rates (more agrees than disagrees) +- Write 2-3 paragraphs that synthesize the consensus views +- Each claim must be supported by specific comment citations +- Be inclusive of different perspectives while highlighting common ground +- Keep the tone constructive and forward-looking + + + + +{ + "id": "collective_statement", + "title": "Collective Statement: ${topicName}", + "paragraphs": [ + { + "id": "string", // e.g. "shared_values" + "title": "string", // e.g. "Our Shared Values" + "sentences": [ + { + "clauses": [ + { + "text": "string", // The actual text content + "citations": [123] // Required: ID of the comment + } + ] + } + ] + } + ] +} + + + +You MUST respond with valid JSON that follows the exact schema above. Each clause must have at least one citation.`; + + try { + const response = await anthropic.messages.create({ + model: "claude-opus-4-20250514", + max_tokens: 3000, + temperature: 0.7, + system: systemPrompt, + messages: [ + { + role: "user", + content: userPrompt, + }, + { + role: "assistant", + content: "{", + }, + ], + }); + + // Parse the JSON response + const responseText = + "{" + + (response.content[0].type === "text" ? response.content[0].text : ""); + + try { + const statementData = JSON.parse(responseText); + + // Return both the structured data and the original comments for citation display + return { + statementData, + commentsData: formattedComments, + }; + } catch (parseError) { + logger.error(`Error parsing Claude response: ${parseError}`); + logger.error(`Response text: ${responseText.substring(0, 500)}...`); + + // Fallback: If JSON parsing fails, return a simple text response + return { + statementData: { + id: "collective_statement", + title: `Collective Statement: ${topicName}`, + paragraphs: [ + { + id: "fallback", + title: "Generated Statement", + sentences: [ + { + clauses: [ + { + text: responseText, + citations: [], + }, + ], + }, + ], + }, + ], + }, + commentsData: formattedComments, + }; + } + } catch (error) { + logger.error(`Error generating collective statement: ${error}`); + throw error; + } +} + +/** + * Handler for POST /api/v3/collectiveStatement + */ +export async function handle_POST_collectiveStatement( + req: Request, + res: Response +) { + logger.info("CollectiveStatement API request received"); + + const { report_id, topic_key, topic_name, qualifying_tids } = req.body; + + if (!report_id || !topic_key || !topic_name) { + return res.status(400).json({ + status: "error", + message: "report_id, topic_key, and topic_name are required", + }); + } + + try { + const zid = await getZidFromReport(report_id); + if (!zid) { + return res.status(404).json({ + status: "error", + message: "Could not find conversation for report_id", + }); + } + + // Generate unique key for this statement + const statementKey = `${zid}#${topic_key}#${uuidv4()}`; + + // Get comments for this topic with voting data + const topicComments = await getCommentsForTopic(zid, topic_key); + + logger.info( + `Found ${topicComments.length} comments for topic ${topic_key}` + ); + + if (topicComments.length === 0) { + return res.json({ + status: "error", + message: "No comments found for this topic", + }); + } + + // Filter comments based on client-side validation + const groupConsensus = req.body.group_consensus; + let filteredComments = topicComments; + + if (qualifying_tids && qualifying_tids.length > 0) { + // Use the pre-filtered qualifying comment IDs from the client + // These have already passed the 0.8 consensus threshold and 5% group participation requirement + const qualifyingSet = new Set(qualifying_tids); + filteredComments = topicComments.filter((comment) => + qualifyingSet.has(comment.comment_id) + ); + + // Add group consensus to filtered comments + filteredComments = filteredComments.map((comment) => ({ + ...comment, + group_consensus: groupConsensus[comment.comment_id] || 0, + })); + + // Sort by group consensus (descending) to prioritize highest consensus comments + filteredComments.sort((a, b) => b.group_consensus - a.group_consensus); + + logger.info( + `Using ${filteredComments.length} pre-qualified comments from client (from ${topicComments.length} total)` + ); + + if (filteredComments.length > 0) { + logger.info( + `Consensus range: ${filteredComments[0].group_consensus.toFixed( + 3 + )} to ${filteredComments[ + filteredComments.length - 1 + ].group_consensus.toFixed(3)}` + ); + } + + // Validate minimum comment requirement (should match client-side threshold) + const MIN_COMMENTS = 3; + if (filteredComments.length < MIN_COMMENTS) { + return res.json({ + status: "error", + message: `Not enough qualifying comments. Need at least ${MIN_COMMENTS} comments with ≥0.8 consensus and ≥5% participation from every group. Only ${filteredComments.length} qualify.`, + }); + } + } else if (groupConsensus && Object.keys(groupConsensus).length > 0) { + // Fallback to old logic if no qualifying_tids provided (for backwards compatibility) + logger.warn(`No qualifying_tids provided, using legacy filtering logic`); + + // Add group consensus to each comment + filteredComments = topicComments.map((comment) => ({ + ...comment, + group_consensus: groupConsensus[comment.comment_id] || 0, + })); + + // Filter out comments with less than 20 votes + filteredComments = filteredComments.filter((c) => c.total_votes >= 20); + + // Sort by group consensus (descending) + filteredComments.sort((a, b) => b.group_consensus - a.group_consensus); + + // Take top quartile + const quartileSize = Math.ceil(filteredComments.length / 4); + filteredComments = filteredComments.slice(0, quartileSize); + + logger.info( + `Filtered from ${topicComments.length} to ${filteredComments.length} comments (min 20 votes, top quartile by consensus)` + ); + } + + // Generate the collective statement + const result = await generateCollectiveStatement( + zid, + topic_key, + topic_name, + filteredComments + ); + + // Store in DynamoDB + const item = { + zid_topic_jobid: statementKey, + zid: zid.toString(), + topic_key: topic_key, + topic_name: topic_name, + statement_data: JSON.stringify(result.statementData), + comments_data: JSON.stringify(result.commentsData), + created_at: new Date().toISOString(), + model: "claude-opus-4-20250514", + }; + + await docClient.send( + new PutCommand({ + TableName: "Delphi_CollectiveStatement", + Item: item, + }) + ); + + return res.json({ + status: "success", + statementData: result.statementData, + commentsData: result.commentsData, + id: statementKey, + }); + } catch (err: any) { + logger.error(`Error in handle_POST_collectiveStatement: ${err.message}`); + logger.error(`Error stack: ${err.stack}`); + + return res.status(500).json({ + status: "error", + message: "Error generating collective statement", + error: err.message, + }); + } +} + +/** + * Handler for GET /api/v3/collectiveStatement + * Can get a single statement by ID or all statements for a report + */ +export async function handle_GET_collectiveStatement( + req: Request, + res: Response +) { + const { statement_id, report_id } = req.query; + + // If report_id is provided, get all statements for that conversation + if (report_id) { + try { + const zid = await getZidFromReport(report_id as string); + if (!zid) { + return res.status(404).json({ + status: "error", + message: "Could not find conversation for report_id", + }); + } + + // Query all items where zid matches + const params = { + TableName: "Delphi_CollectiveStatement", + IndexName: "zid-created_at-index", + KeyConditionExpression: "zid = :zid", + ExpressionAttributeValues: { + ":zid": zid.toString(), + }, + }; + + const statements: any[] = []; + let lastEvaluatedKey; + + do { + const command: any = { + ...params, + ExclusiveStartKey: lastEvaluatedKey, + }; + + const data = await docClient.send(new QueryCommand(command)); + if (data.Items) { + statements.push(...data.Items); + } + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + // Deduplicate by layer_cluster - keep only the most recent statement for each topic + const deduplicated = new Map(); + statements.forEach((stmt) => { + // Extract layer_cluster from topic_key (e.g., "0_5" or from topic_name like "0_5: Topic Name") + let layerCluster = null; + + // Try to extract from topic_key first + if (stmt.topic_key) { + // Handle both formats: "uuid#0#5" or "0_5" + if (stmt.topic_key.includes("#")) { + const parts = stmt.topic_key.split("#"); + if (parts.length >= 3) { + layerCluster = `${parts[1]}_${parts[2]}`; + } + } else if (stmt.topic_key.includes("_")) { + layerCluster = stmt.topic_key; + } + } + + // Fallback: try to extract from topic_name (e.g., "0_5: Topic Name") + if (!layerCluster && stmt.topic_name) { + const match = stmt.topic_name.match(/^(\d+_\d+):/); + if (match) { + layerCluster = match[1]; + } + } + + if (layerCluster) { + // Keep the most recent statement for this layer_cluster + if ( + !deduplicated.has(layerCluster) || + new Date(stmt.created_at) > + new Date(deduplicated.get(layerCluster).created_at) + ) { + deduplicated.set(layerCluster, stmt); + } + } else { + // If we can't extract layer_cluster, keep the statement with full key + deduplicated.set(stmt.zid_topic_jobid, stmt); + } + }); + + const uniqueStatements = Array.from(deduplicated.values()); + + // Parse the JSON data in each unique statement + const parsedStatements = uniqueStatements.map((stmt) => ({ + ...stmt, + statement_data: stmt.statement_data + ? JSON.parse(stmt.statement_data) + : null, + comments_data: stmt.comments_data + ? JSON.parse(stmt.comments_data) + : null, + })); + + return res.json({ + status: "success", + statements: parsedStatements, + count: parsedStatements.length, + }); + } catch (err: any) { + logger.error(`Error getting statements for report: ${err.message}`); + return res.status(500).json({ + status: "error", + message: "Error retrieving collective statements", + error: err.message, + }); + } + } + + // Original single statement logic + if (!statement_id) { + return res.status(400).json({ + status: "error", + message: "statement_id or report_id is required", + }); + } + + try { + const result = await docClient.send( + new GetCommand({ + TableName: "Delphi_CollectiveStatement", + Key: { + zid_topic_jobid: statement_id as string, + }, + }) + ); + + if (!result.Item) { + return res.status(404).json({ + status: "error", + message: "Statement not found", + }); + } + + return res.json({ + status: "success", + statement: result.Item, + }); + } catch (err: any) { + logger.error(`Error in handle_GET_collectiveStatement: ${err.message}`); + + return res.status(500).json({ + status: "error", + message: "Error retrieving collective statement", + error: err.message, + }); + } +} + +// Helper function to get comments for a specific topic +async function getCommentsForTopic( + zid: number, + topicKey: string +): Promise { + try { + // First, get comment IDs assigned to this topic from DynamoDB + const conversation_id = zid.toString(); + + // Parse topic key to get layer and cluster + let layer: number, cluster: number; + + if (topicKey.includes("#")) { + // New format: uuid#layer#cluster + const parts = topicKey.split("#"); + if (parts.length >= 3) { + layer = parseInt(parts[1]); + cluster = parseInt(parts[2]); + } else { + throw new Error(`Invalid topic key format: ${topicKey}`); + } + } else if (topicKey.includes("_")) { + // Old format: layer0_5 + const parts = topicKey.split("_"); + if (parts.length >= 2 && parts[0].startsWith("layer")) { + layer = parseInt(parts[0].replace("layer", "")); + cluster = parseInt(parts[1]); + } else { + throw new Error(`Invalid topic key format: ${topicKey}`); + } + } else { + throw new Error(`Invalid topic key format: ${topicKey}`); + } + + // Query DynamoDB for comment assignments + const assignmentsParams = { + TableName: "Delphi_CommentHierarchicalClusterAssignments", + KeyConditionExpression: "conversation_id = :cid", + ExpressionAttributeValues: { + ":cid": conversation_id, + }, + }; + + const allAssignments: any[] = []; + let lastEvaluatedKey; + + do { + const params: any = { + ...assignmentsParams, + ExclusiveStartKey: lastEvaluatedKey, + }; + + const data = await docClient.send(new QueryCommand(params)); + if (data.Items) { + allAssignments.push(...data.Items); + } + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + // Log first assignment to see structure + if (allAssignments.length > 0) { + logger.info( + `Sample assignment structure: ${JSON.stringify(allAssignments[0])}` + ); + } + + // Filter comments for this specific topic + const commentIds: number[] = []; + allAssignments.forEach((assignment) => { + const clusterId = assignment[`layer${layer}_cluster_id`]; + // Convert to number for comparison since cluster is a number + if (clusterId !== undefined && parseInt(clusterId) === cluster) { + commentIds.push(parseInt(assignment.comment_id)); + } + }); + + logger.info( + `Topic ${topicKey} - Layer: ${layer}, Cluster: ${cluster}, Found ${commentIds.length} comment assignments` + ); + + // Debug: Log the comment IDs found + if (commentIds.length > 0) { + logger.info( + `Comment IDs for topic ${topicKey}: ${JSON.stringify( + commentIds.slice(0, 20) + )}` + ); + } + + if (commentIds.length === 0) { + return []; + } + + // Get full comment data with voting information + const commentsQuery = ` + SELECT + c.tid as comment_id, + c.txt as comment_text, + COALESCE(COUNT(DISTINCT v.pid), 0) as total_votes, + COALESCE(SUM(CASE WHEN v.vote = 1 THEN 1 ELSE 0 END), 0) as agrees, + COALESCE(SUM(CASE WHEN v.vote = -1 THEN 1 ELSE 0 END), 0) as disagrees, + COALESCE(SUM(CASE WHEN v.vote = 0 THEN 1 ELSE 0 END), 0) as passes + FROM comments c + LEFT JOIN votes_latest_unique v ON c.tid = v.tid AND c.zid = v.zid + WHERE c.zid = $1 AND c.tid = ANY($2::int[]) + GROUP BY c.tid, c.txt + ORDER BY total_votes DESC + `; + + const commentsData = (await pgQuery.queryP(commentsQuery, [ + zid, + commentIds, + ])) as any[]; + + // Debug: Log the results + logger.info( + `SQL query returned ${commentsData.length} comments for topic ${topicKey}` + ); + if (commentsData.length !== commentIds.length) { + logger.warn( + `Mismatch: Found ${commentIds.length} comment IDs in DynamoDB but only ${commentsData.length} in PostgreSQL` + ); + } + + // Return comments with basic voting data + // Group-level analysis would require participant_group_associations table which doesn't exist yet + return commentsData; + } catch (error) { + logger.error(`Error getting comments for topic: ${error}`); + throw error; + } +} diff --git a/server/src/routes/commentMod.ts b/server/src/routes/commentMod.ts new file mode 100644 index 0000000000..1c8178a403 --- /dev/null +++ b/server/src/routes/commentMod.ts @@ -0,0 +1,238 @@ +import { failJson } from "../utils/fail"; +import { getNextComment } from "../nextComment"; +import { isDuplicateKey } from "../utils/common"; +import { ParticipantCommentModerationResult } from "../d"; +import pg from "../db/pg-query"; +import { + addNoMoreCommentsRecord, + addStar, + finishOne, + safeTimestampToMillis, + updateConversationModifiedTime, + updateLastInteractionTimeForConversation, +} from "../server-helpers"; + +function handle_POST_ptptCommentMod( + req: { + p: { + zid: number; + pid: number; + uid?: number; + tid: number; + as_abusive: any; + as_factual: any; + as_feeling: any; + as_important: any; + as_notfact: any; + as_notgoodidea: any; + as_notmyfeeling: any; + as_offtopic: any; + as_spam: any; + unsure: any; + lang: string; + }; + }, + res: any +) { + const zid = req.p.zid; + const pid = req.p.pid; + const uid = req.p.uid; + + return pg + .queryP( + "insert into crowd_mod (" + + "zid, " + + "pid, " + + "tid, " + + "as_abusive, " + + "as_factual, " + + "as_feeling, " + + "as_important, " + + "as_notfact, " + + "as_notgoodidea, " + + "as_notmyfeeling, " + + "as_offtopic, " + + "as_spam, " + + "as_unsure) values (" + + "$1, " + + "$2, " + + "$3, " + + "$4, " + + "$5, " + + "$6, " + + "$7, " + + "$8, " + + "$9, " + + "$10, " + + "$11, " + + "$12, " + + "$13);", + [ + req.p.zid, + req.p.pid, + req.p.tid, + req.p.as_abusive, + req.p.as_factual, + req.p.as_feeling, + req.p.as_important, + req.p.as_notfact, + req.p.as_notgoodidea, + req.p.as_notmyfeeling, + req.p.as_offtopic, + req.p.as_spam, + req.p.unsure, + ] + ) + .then((createdTime: any) => { + setTimeout(function () { + updateConversationModifiedTime(req.p.zid, createdTime); + updateLastInteractionTimeForConversation(zid, uid); + }, 100); + }) + .then(function () { + return getNextComment(req.p.zid, pid, [], req.p.lang); + }) + .then(function (nextComment: any) { + const result: ParticipantCommentModerationResult = {}; + if (nextComment) { + result.nextComment = nextComment; + } else { + // no need to wait for this to finish + addNoMoreCommentsRecord(req.p.zid, pid); + } + // PID_FLOW This may be the first time the client gets the pid. + result.currentPid = req.p.pid; + finishOne(res, result); + }) + .catch(function (err: string) { + if (err === "polis_err_ptptCommentMod_duplicate") { + failJson(res, 406, "polis_err_ptptCommentMod_duplicate", err); // TODO allow for changing votes? + } else if (err === "polis_err_conversation_is_closed") { + failJson(res, 403, "polis_err_conversation_is_closed", err); + } else { + failJson(res, 500, "polis_err_ptptCommentMod", err); + } + }); +} + +function handle_POST_upvotes( + req: { p: { uid?: number; zid: number } }, + res: { + status: (arg0: number) => { + (): any; + new (): any; + json: { (arg0: {}): void; new (): any }; + }; + } +) { + const uid = req.p.uid; + const zid = req.p.zid; + + pg.queryP("select * from upvotes where uid = ($1) and zid = ($2);", [ + uid, + zid, + ]).then( + function (rows: string | any[]) { + if (rows && rows.length) { + failJson(res, 403, "polis_err_upvote_already_upvoted"); + } else { + pg.queryP("insert into upvotes (uid, zid) VALUES ($1, $2);", [ + uid, + zid, + ]).then( + function () { + pg.queryP( + "update conversations set upvotes = (select count(*) from upvotes where zid = ($1)) where zid = ($1);", + [zid] + ).then( + function () { + res.status(200).json({}); + }, + function (err: any) { + failJson(res, 500, "polis_err_upvote_update", err); + } + ); + }, + function (err: any) { + failJson(res, 500, "polis_err_upvote_insert", err); + } + ); + } + }, + function (err: any) { + failJson(res, 500, "polis_err_upvote_check", err); + } + ); +} + +function handle_POST_stars( + req: { p: { zid: number; tid: number; pid: number; starred: any } }, + res: { + status: (arg0: number) => { + (): any; + new (): any; + json: { (arg0: {}): void; new (): any }; + }; + } +) { + addStar(req.p.zid, req.p.tid, req.p.pid, req.p.starred) + .then(function (result: { rows: { created: any }[] }) { + const createdTimeMillis = safeTimestampToMillis(result.rows[0].created); + setTimeout(function () { + updateConversationModifiedTime(req.p.zid, createdTimeMillis); + }, 100); + res.status(200).json({}); // TODO don't stop after the first one, map the inserts to deferreds. + }) + .catch(function (err: any) { + if (err) { + if (isDuplicateKey(err)) { + failJson(res, 406, "polis_err_vote_duplicate", err); // TODO allow for changing votes? + } else { + failJson(res, 500, "polis_err_vote", err); + } + } + }); +} + +function handle_POST_trashes( + req: { p: { pid: number; zid: number; tid: number; trashed: any } }, + res: { + status: (arg0: number) => { + (): any; + new (): any; + json: { (arg0: {}): void; new (): any }; + }; + } +) { + const query = + "INSERT INTO trashes (pid, zid, tid, trashed, created) VALUES ($1, $2, $3, $4, default);"; + const params = [req.p.pid, req.p.zid, req.p.tid, req.p.trashed]; + pg.query( + query, + params, + function (err: any, result: { rows: { created: any }[] }) { + if (err) { + if (isDuplicateKey(err)) { + failJson(res, 406, "polis_err_vote_duplicate", err); // TODO allow for changing votes? + } else { + failJson(res, 500, "polis_err_vote", err); + } + return; + } + + const createdTimeMillis = safeTimestampToMillis(result.rows[0].created); + setTimeout(function () { + updateConversationModifiedTime(req.p.zid, createdTimeMillis); + }, 100); + + res.status(200).json({}); // TODO don't stop after the first one, map the inserts to deferreds. + } + ); +} + +export { + handle_POST_ptptCommentMod, + handle_POST_upvotes, + handle_POST_stars, + handle_POST_trashes, +}; diff --git a/server/src/routes/comments.ts b/server/src/routes/comments.ts index 3088e24454..17090b4a31 100644 --- a/server/src/routes/comments.ts +++ b/server/src/routes/comments.ts @@ -4,40 +4,33 @@ import { parse } from "csv-parse/sync"; import badwords from "badwords/object"; import { addParticipant } from "../participant"; -import { CommentOptions, GetCommentsParams } from "../d"; -import { createAnonUser, issueAnonymousJWT, issueXidJWT } from "../auth"; -import { checkLegacyCookieAndIssueJWT } from "../auth/legacyCookies"; -import { detectLanguage, getComment, getComments } from "../comment"; +import { CommentOptions, GetCommentsParams, RequestWithP } from "../d"; import { failJson } from "../utils/fail"; -import { getPidPromise, getXidStuff } from "../user"; +import { getConversationInfo } from "../conversation"; +import { getNextComment } from "../nextComment"; +import { getPidPromise } from "../user"; import { getZinvite } from "../utils/zinvite"; +import { isModerator, isSpam, polisTypes } from "../utils/common"; import { MPromise } from "../utils/metered"; -import { translateAndStoreComment } from "../comment"; import { votesPost } from "./votes"; import Config from "../config"; import logger from "../utils/logger"; import pg from "../db/pg-query"; import { - createXidRecordByZid, - getConversationInfo, - getXidRecord, -} from "../conversation"; + detectLanguage, + getComment, + getComments, + translateAndStoreComment, +} from "../comment"; import { finishArray, finishOne, - getNextComment, safeTimestampToMillis, sendEmailByUid, updateConversationModifiedTime, updateLastInteractionTimeForConversation, updateVoteCount, } from "../server-helpers"; -import { - isModerator, - isSpam, - polisTypes, - isDuplicateKey, -} from "../utils/common"; /* this is a concept and can be generalized to other handlers */ interface PolisRequestParams { @@ -53,7 +46,6 @@ interface PolisRequestParams { lang?: string; not_voted_by_pid?: any; without?: any; - include_social?: any; conversation_id?: string; jwt_conversation_mismatch?: boolean; jwt_conversation_id?: string; @@ -121,94 +113,71 @@ function hasBadWords(txt: string) { return false; } -function commentExists(zid: number, txt: any) { - return pg - .queryP("select zid from comments where zid = ($1) and txt = ($2);", [ - zid, - txt, - ]) - .then(function (rows: string | any[]) { - return rows && rows.length; - }); +async function commentExists(zid: number, txt: string): Promise { + const rows = (await pg.queryP( + "select zid from comments where zid = ($1) and txt = ($2);", + [zid, txt] + )) as Array<{ zid: number }>; + return Array.isArray(rows) && rows.length > 0; } -function handle_GET_comments_translations( +async function handle_GET_comments_translations( req: { p: { zid: number; tid: number; lang: string } }, - res: { - status: (arg0: number) => { - (): any; - new (): any; - json: { (arg0: any): void; new (): any }; - }; - } -): void { - const zid = req.p.zid; - const tid = req.p.tid; - const firstTwoCharsOfLang = req.p.lang.substr(0, 2); + res: { status: (code: number) => { json: (data: unknown) => void } } +): Promise { + try { + const { zid, tid, lang } = req.p; + const firstTwoCharsOfLang = lang.slice(0, 2); - getComment(zid, tid) - .then((comment: { txt?: any } | null) => { - if (!comment || !comment.txt) { - res.status(404).json({ error: "Comment not found" }); - return; - } - return pg - .queryP( - "select * from comment_translations where zid = ($1) and tid = ($2) and lang LIKE '$3%';", - [zid, tid, firstTwoCharsOfLang] - ) - .then((existingTranslations: any) => { - if (existingTranslations) { - return existingTranslations; - } - return translateAndStoreComment(zid, tid, comment.txt, req.p.lang); - }) - .then((rows: any) => { - res.status(200).json(rows || []); - }); - }) - .catch((err: any) => { - failJson(res, 500, "polis_err_get_comments_translations", err); - }); + const comment = await getComment(zid, tid); + if (!comment || !comment.txt) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + const existingTranslations = await pg.queryP( + "select * from comment_translations where zid = ($1) and tid = ($2) and lang LIKE ($3 || '%');", + [zid, tid, firstTwoCharsOfLang] + ); + + const rows = + (existingTranslations as unknown as any[])?.length > 0 + ? existingTranslations + : await translateAndStoreComment(zid, tid, comment.txt, lang); + + res.status(200).json(rows || []); + } catch (err) { + failJson(res as any, 500, "polis_err_get_comments_translations", err); + } } -function handle_GET_comments( - req: { - headers?: Headers; - p: { rid: any; zid: number; uid?: number }; - }, - res: any -): void { - // The function is designed to work with partial parameters, where most fields are optional - getComments(req.p as GetCommentsParams) - .then(function (comments: any[]) { - if (req.p.rid) { - return pg - .queryP( - "select tid, selection from report_comment_selections where rid = ($1);", - [req.p.rid] - ) - .then((selections: any) => { - const tidToSelection = _.indexBy(selections, "tid"); - comments = comments.map( - (c: { includeInReport: any; tid: number }) => { - c.includeInReport = - tidToSelection[c.tid] && tidToSelection[c.tid].selection > 0; - return c; - } - ); - return comments; - }); - } else { - return comments; - } - }) - .then(function (comments: any[]) { - finishArray(res, comments); - }) - .catch(function (err: any) { - failJson(res, 500, "polis_err_get_comments", err); - }); +async function handle_GET_comments(req: RequestWithP, res: any): Promise { + try { + // The function is designed to work with partial parameters, where most fields are optional + let comments = (await getComments(req.p as GetCommentsParams)) as any[]; + if (req.p.rid) { + const selections = (await pg.queryP( + "select tid, selection from report_comment_selections where rid = ($1);", + [req.p.rid] + )) as Array<{ tid: number; selection: number }>; + + const tidToSelection = selections.reduce< + Record + >((acc, s) => { + acc[s.tid] = { selection: s.selection }; + return acc; + }, {}); + + comments = (comments as any[]).map((c: any) => { + c.includeInReport = + tidToSelection[c.tid] && tidToSelection[c.tid].selection > 0; + return c; + }); + } + finishArray(res, comments); + } catch (err) { + failJson(res, 500, "polis_err_get_comments", err); + } } function addNotificationTask(zid: number): Promise { @@ -218,30 +187,12 @@ function addNotificationTask(zid: number): Promise { ); } -// Helper interfaces for handle_POST_comments -interface CommentValidationResult { - isValid: boolean; - errorCode?: string; - statusCode?: number; -} - interface CommentModerationResult { active: boolean; mod: number; classifications: string[]; } -interface CommentCreationContext { - zid: number; - uid: number; - pid: number; - txt: string; - xid?: string; - conversation: any; - is_moderator: boolean; - is_seed?: boolean; -} - // Extract IP address from request function getIpAddress(req: PolisRequest): string | undefined { return (req.headers["x-forwarded-for"] || @@ -250,115 +201,6 @@ function getIpAddress(req: PolisRequest): string | undefined { req.connection?.socket?.remoteAddress) as string | undefined; } -// Validate comment input parameters -function validateCommentInput(req: PolisRequest): CommentValidationResult { - const { txt } = req.p; - - if (!txt || txt === "") { - return { - isValid: false, - errorCode: "polis_err_param_missing_txt", - statusCode: 400, - }; - } - - return { isValid: true }; -} - -// Get or create participant ID for the comment -async function resolveParticipantId( - zid: number, - uid: number, - xid?: string, - initialPid?: number -): Promise<{ - pid: number; - shouldCreateXidRecord: boolean; - newlyCreated: boolean; -}> { - let shouldCreateXidRecord = false; - let newlyCreated = false; - - // Handle XID user lookup - if (xid) { - const xidUser = await getXidStuff(xid, zid); - shouldCreateXidRecord = - xidUser === "noXidRecord" || - (typeof xidUser === "object" && xidUser.pid === -1); - - if (typeof xidUser === "object" && !shouldCreateXidRecord) { - return { - pid: xidUser.pid, - shouldCreateXidRecord: false, - newlyCreated: false, - }; - } - } - - // Create XID record if needed - if (shouldCreateXidRecord && xid) { - await createXidRecordByZid(zid, uid, xid, null, null, null); - } - - // Get or create participant - if (_.isUndefined(initialPid) || Number(initialPid) === -1) { - const existingPid = await getPidPromise(zid, uid, true); - - if (existingPid !== -1) { - return { - pid: existingPid, - shouldCreateXidRecord, - newlyCreated: false, - }; - } - - // Create new participant with retry logic - try { - const rows = await addParticipant(zid, uid); - logger.debug("addParticipant returned", { - zid, - uid, - rows, - rowsLength: rows?.length, - }); - - if (!rows || !Array.isArray(rows) || rows.length === 0) { - throw new Error(`Failed to create participant - empty result`); - } - - const ptpt = rows[0]; - if (!ptpt || typeof ptpt.pid === "undefined" || ptpt.pid === null) { - throw new Error(`Failed to create participant - invalid data`); - } - - newlyCreated = true; - return { - pid: Number(ptpt.pid), - shouldCreateXidRecord, - newlyCreated, - }; - } catch (createError: any) { - if (isDuplicateKey(createError)) { - const retryPid = await getPidPromise(zid, uid, true); - if (retryPid !== -1) { - return { - pid: retryPid, - shouldCreateXidRecord, - newlyCreated: false, - }; - } - } - throw createError; - } - } - - return { - pid: Number(initialPid), - shouldCreateXidRecord, - newlyCreated: false, - }; -} - function moderateCommentQuery( zid: number, tid: number, @@ -459,493 +301,126 @@ async function moderateComment( return { active, mod, classifications }; } -// Insert comment with retry logic -async function insertComment( - context: CommentCreationContext, - active: boolean, - mod: number, - lang: string, - lang_confidence: number -): Promise { - const { zid, uid, pid, txt, is_seed } = context; - const velocity = 1; - let retryCount = 0; - const maxRetries = 3; - let currentPid = pid; - - while (retryCount < maxRetries) { - try { - // Validate participant exists before insert - const participantExists = (await pg.queryP_readOnly( - "SELECT 1 FROM participants WHERE zid = $1 AND pid = $2 LIMIT 1", - [zid, currentPid] - )) as any[]; - - if (participantExists.length === 0) { - logger.warn( - `Participant ${currentPid} does not exist for conversation ${zid}, recreating` - ); - const result = await resolveParticipantId(zid, uid); - currentPid = result.pid; - } - - const insertedComment = await pg.queryP( - `INSERT INTO COMMENTS - (pid, zid, txt, velocity, active, mod, uid, anon, is_seed, created, tid, lang, lang_confidence) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, default, null, $10, $11) - RETURNING *;`, - [ - currentPid, - zid, - txt, - velocity, - active, - mod, - uid, - false, // anon is deprecated and not used anywhere - is_seed || false, - lang, - lang_confidence, - ] - ); - - return { comment: insertedComment[0], finalPid: currentPid }; - } catch (insertError: any) { - retryCount++; - - if ( - insertError.code === "23503" && - insertError.constraint === "comments_zid_pid_fkey" - ) { - logger.warn( - `Comment insertion failed due to missing participant, retry ${retryCount}/${maxRetries}` - ); - - if (retryCount >= maxRetries) { - throw new Error( - `Failed to insert comment after ${maxRetries} retries due to participant race condition` - ); - } - - await new Promise((resolve) => setTimeout(resolve, 100 * retryCount)); - } else { - throw insertError; - } - } - } - - throw new Error("Failed to insert comment after max retries"); -} - -// Handle voting on comment creation (seed comments default to pass) -async function handleCommentVote( - req: PolisRequest, - uid: number, - pid: number, - zid: number, - tid: number, - xid: string | undefined, - vote: number | undefined -): Promise { - // Handle seed comment default vote - const shouldDefaultVote = - req.p.is_seed && _.isUndefined(vote) && Number(zid) <= 17037; - const finalVote = shouldDefaultVote ? 0 : vote; - - // Cast vote if specified - if (!_.isUndefined(finalVote)) { - const voteResult = await votesPost( - uid, - pid, - zid, - tid, - xid, - finalVote, - 0, - false - ); - if (voteResult?.vote?.created) { - return voteResult.vote.created; - } - } - - return undefined; -} - -// Handle post-insertion tasks -async function handlePostInsertionTasks( - zid: number, - uid: number, - pid: number, - tid: number, - xid: string | undefined, - vote: number | undefined, - conversation: any, - req: PolisRequest, - needsModeration: boolean -): Promise { - let createdTime = new Date(); - - // Handle moderation notifications - if (needsModeration || conversation.strict_moderation) { - try { - const n = await getNumberOfCommentsWithModerationStatus( - zid, - polisTypes.mod.unmoderated - ); - if (n !== 0) { - const users = (await pg.queryP_readOnly( - "SELECT * FROM users WHERE site_id = (SELECT site_id FROM page_ids WHERE zid = $1) UNION SELECT * FROM users WHERE uid = $2;", - [zid, conversation.owner] - )) as any[]; - const uids = users.map((user: { uid: string }) => user.uid); - uids.forEach((uid: string) => - sendCommentModerationEmail(req, Number(uid), zid, n) - ); - } - } catch (err) { - logger.error("polis_err_getting_modstatus_comment_count", err); - } - } else { - addNotificationTask(zid); - } - - // Handle voting - try { - const voteCreatedTime = await handleCommentVote( - req, - uid, - pid, - zid, - tid, - xid, - vote - ); - if (voteCreatedTime) { - const voteCreatedTimeMillis = safeTimestampToMillis(voteCreatedTime); - createdTime = new Date(voteCreatedTimeMillis); - } - } catch (err) { - throw new Error("polis_err_vote_on_create"); - } - - // Schedule async updates - setTimeout(() => { - updateConversationModifiedTime(zid, createdTime); - updateLastInteractionTimeForConversation(zid, uid); - if ( - !_.isUndefined(vote) || - (req.p.is_seed && _.isUndefined(vote) && Number(zid) <= 17037) - ) { - updateVoteCount(zid, pid); - } - }, 100); -} - -// Build response with optional JWT -function buildCommentResponse( - tid: number, - currentPid: number, - newlyCreatedParticipant: boolean, - newlyCreatedUser: boolean, - uid: number | undefined, - finalPid: number, - xid: string | undefined, - conversation_id: string | undefined, - needsNewJwt: boolean = false -): any { - const response: any = { - tid, - currentPid, - }; - - // Issue JWT for new participants/users OR when conversation mismatch requires new JWT - if ( - (newlyCreatedParticipant || newlyCreatedUser || needsNewJwt) && - uid !== undefined && - finalPid !== undefined && - conversation_id - ) { - try { - const token = xid - ? issueXidJWT(xid, conversation_id, Number(uid), finalPid) - : issueAnonymousJWT(conversation_id, Number(uid), finalPid); - - response.auth = { - token, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }; - - logger.debug( - `${ - xid ? "XID" : "Anonymous" - } JWT issued successfully for comment author${ - needsNewJwt ? " (conversation mismatch)" : "" - }` - ); - } catch (error) { - logger.error("Failed to issue JWT on comment creation:", error); - } - } - - return response; -} - -async function handle_POST_comments( - req: PolisRequest, - res: Response & { json: (data: any) => void } -): Promise { - const { zid, xid, txt, pid: initialPid, is_seed, conversation_id } = req.p; - let { uid, vote } = req.p; - - let pid = initialPid; - let currentPid = pid; - let finalPid = initialPid; // Declare at function level for error handling access - let newlyCreatedParticipant = false; - let newlyCreatedUser = false; - let needsNewJwt = false; // Track if we need to issue a new JWT due to conversation mismatch - - const validationResult = validateCommentInput(req); - if (!validationResult.isValid) { - failJson( - res, - validationResult.statusCode || 500, - validationResult.errorCode || "polis_err_post_comment_invalid_input" - ); - return; - } - - // Handle JWT conversation mismatches - if (req.p.jwt_conversation_mismatch) { - needsNewJwt = true; - - if (req.p.anonymous_participant) { - // Anonymous participant with JWT for different conversation - treat as new - logger.debug( - "Anonymous participant commenting with JWT for different conversation - treating as new" - ); - uid = undefined; - pid = undefined; - } else if (req.p.xid_participant && xid) { - // XID participant - apply the same 4-case logic as participationInit and votes - const jwtXid = req.p.jwt_xid; - const requestXid = xid; - const xidMatches = jwtXid === requestXid; - - // Check if XID exists for current conversation - let xidForCurrentConversation = false; - try { - const xidRecords = await getXidRecord(requestXid, zid!); - if (xidRecords && xidRecords.length > 0) { - xidForCurrentConversation = true; - } - } catch (err) { - // XID not found for this conversation - } - - if (xidMatches) { - // Case 2: Token and XID align but are for different conversation - logger.debug( - "Case 2: XID participant commenting with matching JWT/XID for different conversation - treating as anonymous" - ); - req.p.xid = undefined; // Clear XID to treat as anonymous - uid = undefined; - pid = undefined; - } else if (!xidMatches && xidForCurrentConversation) { - // Case 3: Token for different conversation, but XID is for current - logger.debug( - "Case 3: XID participant commenting with mismatched JWT but XID for current conversation - maintaining XID" - ); - uid = undefined; - pid = undefined; - // XID will be resolved below - } else { - // Case 4: Token for current conversation, but XID for different - logger.debug( - "Case 4: XID participant commenting with JWT for current conversation but XID for different - treating as anonymous" - ); - req.p.xid = undefined; // Clear XID - // Keep uid/pid from JWT - } - } - } - - // Check for legacy cookie before creating new user - let legacyCookieToken: string | undefined; - if (uid === undefined && !req.p.jwt_conversation_mismatch) { - const legacyResult = await checkLegacyCookieAndIssueJWT( - req, - zid!, - conversation_id, - xid - ); - if (legacyResult.uid !== undefined && legacyResult.pid !== undefined) { - uid = legacyResult.uid; - pid = legacyResult.pid; - currentPid = pid; - needsNewJwt = legacyResult.needsNewJwt; - legacyCookieToken = legacyResult.token; - logger.info("Using existing participant from legacy cookie", { - uid, - pid, - }); - } - } - - // Create anonymous user if uid is not provided - // This allows anonymous participants to submit comments as their first action - if (uid === undefined && !xid) { - try { - uid = await createAnonUser(); - newlyCreatedUser = true; - } catch (err) { - logger.error("Failed to create anonymous user for comment", err); - failJson(res, 500, "polis_err_comment_anonymous_user_creation"); - return; - } - } +/** + * Simplified comment handler - all participant management is handled by middleware + */ +async function handle_POST_comments(req: RequestWithP, res: any) { + const { zid, uid, pid, txt, vote, is_seed, xid } = req.p; try { - // Early participant ID resolution for XID users - if (xid) { - const xidUser = await getXidStuff(xid, zid!); - const shouldCreateXidRecord = - xidUser === "noXidRecord" || - (typeof xidUser === "object" && xidUser.pid === -1); - - if (typeof xidUser === "object" && !shouldCreateXidRecord) { - uid = xidUser.uid; - pid = xidUser.pid; - currentPid = pid; - } else { - // Create anonymous user for XID if uid is not available - if (uid === undefined) { - uid = await createAnonUser(); - newlyCreatedUser = true; - } - if (shouldCreateXidRecord) { - await createXidRecordByZid(zid!, uid!, xid, null, null, null); - } - } - } - - // Resolve participant ID - const participantResult = await resolveParticipantId(zid!, uid!, xid, pid); - finalPid = participantResult.pid; - currentPid = finalPid; - newlyCreatedParticipant = participantResult.newlyCreated; - - // Run all validation checks in parallel - const [conv, is_moderator, commentExistsAlready] = await Promise.all([ - getConversationInfo(zid!), - isModerator(zid!, uid!), - commentExists(zid!, txt), - ]); - - const conversation = conv; - - if (finalPid && typeof finalPid === "number" && finalPid < 0) { - failJson(res, 500, "polis_err_post_comment_bad_pid"); + // 1. Validate input + if (!txt || txt === "") { + failJson(res, 400, "polis_err_param_missing_txt"); return; } - if (commentExistsAlready) { + // 2. Check for duplicates + const exists = await commentExists(zid, txt); + if (exists) { failJson(res, 409, "polis_err_post_comment_duplicate"); return; } + // 3. Get conversation info and check moderation status + const [conversation, is_moderator] = await Promise.all([ + getConversationInfo(zid), + isModerator(zid, uid), + ]); + if (!conversation.is_active) { failJson(res, 403, "polis_err_conversation_is_closed"); return; } - const { active, mod, classifications } = await moderateComment( - txt!, + // 4. Moderate the comment + const { active, mod } = await moderateComment( + txt, conversation, is_moderator, - is_seed, - req + is_seed ); - const [detections] = await Promise.all([detectLanguage(txt!)]); - + // 5. Detect language + const detections = await detectLanguage(txt); const detection = Array.isArray(detections) ? detections[0] : detections; const lang = detection.language; const lang_confidence = detection.confidence; - const insertResult = await insertComment( - { - zid: zid!, - uid: uid!, - pid: finalPid, - txt: txt!, - xid, - conversation, - is_moderator, - is_seed, - }, - active, - mod, - lang, - lang_confidence + // 6. Insert the comment + const insertedComment = await pg.queryP( + `INSERT INTO COMMENTS + (pid, zid, txt, velocity, active, mod, uid, anon, is_seed, created, tid, lang, lang_confidence) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, default, null, $10, $11) + RETURNING *;`, + [ + pid, + zid, + txt, + 1, // velocity + active, + mod, + uid, + false, // anon + is_seed || false, + lang, + lang_confidence, + ] ); - const comment = insertResult.comment; + const comment = insertedComment[0]; const tid = comment.tid; - finalPid = insertResult.finalPid; // Update finalPid in case it changed during insert - const needsModeration = - classifications.length > 0 || conversation.strict_moderation; + // 7. Handle voting on the comment if specified + const shouldDefaultVote = req.p.is_seed && _.isUndefined(vote); + const finalVote = shouldDefaultVote ? 0 : vote; - // Ensure finalPid is a valid number (it should be at this point) - if (typeof finalPid !== "number" || finalPid < 0) { - failJson(res, 500, "polis_err_post_comment_invalid_pid"); - return; + if (!_.isUndefined(finalVote)) { + await votesPost(uid, pid, zid, tid, xid, finalVote, 0, false); } - try { - await handlePostInsertionTasks( - zid!, - uid!, - finalPid, - tid, - xid, - vote, - conversation, - req, - needsModeration - ); - } catch (err: any) { - if (err.message === "polis_err_vote_on_create") { - failJson(res, 500, "polis_err_vote_on_create", err); - return; + // 8. Handle moderation notifications + const needsModeration = !active || conversation.strict_moderation; + + if (needsModeration || conversation.strict_moderation) { + try { + const n = await getNumberOfCommentsWithModerationStatus( + zid, + polisTypes.mod.unmoderated + ); + if (n !== 0) { + const users = (await pg.queryP_readOnly( + "SELECT * FROM users WHERE site_id = (SELECT site_id FROM page_ids WHERE zid = $1) UNION SELECT * FROM users WHERE uid = $2;", + [zid, conversation.owner] + )) as any[]; + const uids = users.map((user: { uid: string }) => user.uid); + uids.forEach((uid: string) => + sendCommentModerationEmail(req, Number(uid), zid, n) + ); + } + } catch (err) { + logger.error("polis_err_getting_modstatus_comment_count", err); } - // Log but don't fail for other post-insertion errors - logger.error("Error in post-insertion tasks", err); + } else { + addNotificationTask(zid); } - const response = buildCommentResponse( + // 9. Schedule async updates + const createdTimeMillis = safeTimestampToMillis(comment.created); + setTimeout(() => { + updateConversationModifiedTime(zid, new Date(createdTimeMillis)); + updateLastInteractionTimeForConversation(zid, uid); + if (!_.isUndefined(finalVote)) { + updateVoteCount(zid, pid); + } + }, 100); + + // 10. Build response + const response: any = { tid, - currentPid, - newlyCreatedParticipant, - newlyCreatedUser, - uid, - finalPid, - xid, - conversation_id, - needsNewJwt - ); + currentPid: pid, + }; - // Override auth with legacy cookie token if available - if (legacyCookieToken && needsNewJwt) { - response.auth = { - token: legacyCookieToken, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }; - logger.debug("Using JWT from legacy cookie lookup for comment response"); - } + // 11. Auth token will be automatically included by attachAuthToken middleware res.json(response); } catch (err: any) { @@ -953,7 +428,7 @@ async function handle_POST_comments( logger.error("Comment creation failed", { zid, uid, - pid: finalPid || initialPid, + pid, error: err.message, code: err.code, constraint: err.constraint, @@ -972,7 +447,7 @@ async function handle_POST_comments( { zid, uid, - pid: finalPid || initialPid, + pid, error: err.message, constraint: err.constraint, } @@ -991,7 +466,7 @@ async function handle_POST_comments( logger.error("Comment insertion failed after retries", { zid, uid, - pid: finalPid || initialPid, + pid, error: err.message, }); failJson( @@ -1005,7 +480,7 @@ async function handle_POST_comments( logger.error("Foreign key constraint violation in comment creation", { zid, uid, - pid: finalPid || initialPid, + pid, error: err.message, constraint: err.constraint, }); @@ -1015,7 +490,7 @@ async function handle_POST_comments( logger.error("Unexpected error in comment creation", { zid, uid, - pid: finalPid || initialPid, + pid, error: err.message, code: err.code, stack: err.stack, @@ -1080,69 +555,50 @@ function handle_PUT_comments( }); } -function handle_GET_nextComment( +async function handle_GET_nextComment( req: PolisRequest, - res: { - status: (arg0: number) => { - (): any; - new (): any; - json: { (arg0: {}): void; new (): any }; - }; - } -): void { + res: { status: (code: number) => { json: (data: unknown) => void } } +): Promise { if (req.timedout) { return; } - /* - NOTE: I tried to speed up this query by adding db indexes, and by removing queries like - getConversationInfo and finishOne. They didn't help much, at least under current load, which is - negligible. pg:diagnose isn't complaining about indexes. - I think the direction to go as far as optimizing this is to asyncronously build up a synced in-ram list - of next comments for each participant, for currently active conversations. - (this would probably be a math-poller-esque process on another hostclass) - Along with this would be to cache in ram info about moderation status of each comment so we can filter - before returning a comment. - */ - - getNextComment( - req.p.zid, - req.p.not_voted_by_pid, - req.p.without, - req.p.include_social, - req.p.lang - ) - .then( - function (c: GetCommentsParams | null) { - if (req.timedout) { - return; - } - if (c) { - if (!_.isUndefined(req.p.not_voted_by_pid)) { - c.currentPid = req.p.not_voted_by_pid; - } - finishOne(res, c); - } else { - const o: CommentOptions = {}; - if (!_.isUndefined(req.p.not_voted_by_pid)) { - o.currentPid = req.p.not_voted_by_pid; - } - res.status(200).json(o); - } - }, - function (err: any) { - if (req.timedout) { - return; - } - failJson(res, 500, "polis_err_get_next_comment2", err); + logger.info("polis_info_handle_GET_nextComment", { + zid: req.p.zid, + not_voted_by_pid: req.p.not_voted_by_pid, + without: req.p.without, + lang: req.p.lang, + pid: req.p.pid, + }); + + const pid = req.p.pid || req.p.not_voted_by_pid; + + try { + const next = await getNextComment( + req.p.zid, + pid, + req.p.without, + req.p.lang + ); + + if (req.timedout) return; + + if (next) { + if (!_.isUndefined(pid)) { + next.currentPid = pid; } - ) - .catch(function (err: any) { - if (req.timedout) { - return; + finishOne(res as any, next); + } else { + const response: CommentOptions = {}; + if (!_.isUndefined(pid)) { + response.currentPid = pid; } - failJson(res, 500, "polis_err_get_next_comment", err); - }); + res.status(200).json(response); + } + } catch (err) { + if (req.timedout) return; + failJson(res as any, 500, "polis_err_get_next_comment", err); + } } // TODO: Use dynamic url domain diff --git a/server/src/routes/delphi/topicAgenda.ts b/server/src/routes/delphi/topicAgenda.ts index 0469b12b9a..fbb4892171 100644 --- a/server/src/routes/delphi/topicAgenda.ts +++ b/server/src/routes/delphi/topicAgenda.ts @@ -1,19 +1,14 @@ -import { Request, Response } from "express"; -import logger from "../../utils/logger"; +import _ from "underscore"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { - DynamoDBDocumentClient, - PutCommand, - GetCommand, - UpdateCommand, - DeleteCommand, -} from "@aws-sdk/lib-dynamodb"; +import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; +import { Response } from "express"; + +import { RequestWithP } from "../../d"; import Config from "../../config"; -import p from "../../db/pg-query"; -import { getZidFromConversationId } from "../../conversation"; -import { getPidPromise } from "../../user"; +import logger from "../../utils/logger"; +import pgQuery from "../../db/pg-query"; -// DynamoDB configuration (reuse pattern from other Delphi routes) +// DynamoDB configuration for job queries only const dynamoDBConfig: any = { region: Config.AWS_REGION || "us-east-1", }; @@ -24,44 +19,53 @@ if (Config.dynamoDbEndpoint) { accessKeyId: "DUMMYIDEXAMPLE", secretAccessKey: "DUMMYEXAMPLEKEY", }; -} else { - if (Config.AWS_ACCESS_KEY_ID && Config.AWS_SECRET_ACCESS_KEY) { - dynamoDBConfig.credentials = { - accessKeyId: Config.AWS_ACCESS_KEY_ID, - secretAccessKey: Config.AWS_SECRET_ACCESS_KEY, - }; - } +} else if (Config.AWS_ACCESS_KEY_ID && Config.AWS_SECRET_ACCESS_KEY) { + dynamoDBConfig.credentials = { + accessKeyId: Config.AWS_ACCESS_KEY_ID, + secretAccessKey: Config.AWS_SECRET_ACCESS_KEY, + }; } -const client = new DynamoDBClient(dynamoDBConfig); -const docClient = DynamoDBDocumentClient.from(client, { +const dynamoClient = new DynamoDBClient(dynamoDBConfig); +const docClient = DynamoDBDocumentClient.from(dynamoClient, { marshallOptions: { convertEmptyValues: true, removeUndefinedValues: true, }, }); -const TABLE_NAME = "Delphi_TopicAgendaSelections"; - /** * Get the current Delphi job ID for a conversation */ async function getCurrentDelphiJobId(zid: string): Promise { try { - const query = ` - SELECT job_id - FROM delphi_jobs - WHERE conversation_id = $1 - AND status = 'completed' - ORDER BY created_at DESC - LIMIT 1 - `; - const result = (await p.queryP(query, [zid])) as { - rows: Array<{ job_id: string }>; + // Query the ConversationIndex GSI to find completed jobs for this conversation + const queryParams = { + TableName: "Delphi_JobQueue", + IndexName: "ConversationIndex", + KeyConditionExpression: "conversation_id = :zid", + FilterExpression: "#status = :status", + ExpressionAttributeNames: { + "#status": "status", // Use expression attribute name since 'status' might be reserved + }, + ExpressionAttributeValues: { + ":zid": zid.toString(), + ":status": "COMPLETED", + }, + ScanIndexForward: false, // Sort by created_at DESC + Limit: 1, }; - return result.rows.length > 0 ? result.rows[0].job_id : null; - } catch (error) { - logger.error("Error getting current Delphi job ID", error); + + const result = await docClient.send(new QueryCommand(queryParams)); + + if (result.Items && result.Items.length > 0) { + const jobId = result.Items[0].job_id; + return jobId; + } + + return null; + } catch (error: any) { + logger.error("Error getting current Delphi job ID from DynamoDB", error); return null; } } @@ -71,73 +75,62 @@ async function getCurrentDelphiJobId(zid: string): Promise { * Save topic agenda selections for a user */ export async function handle_POST_topicAgenda_selections( - req: Request & { user?: any }, + req: RequestWithP, res: Response ) { try { - const { conversation_id, selections } = req.body; + const { selections } = req.body; - if (!conversation_id || !selections) { + if (!selections) { return res.status(400).json({ status: "error", - message: "conversation_id and selections are required", - }); - } - - if (!req.user || !req.user.uid) { - return res.status(401).json({ - status: "error", - message: "Authentication required", + message: "selections are required", }); } - // Convert conversation_id to zid - const zid = await getZidFromConversationId(conversation_id); - const zidStr = zid.toString(); - - // Get participant ID - const pid = await getPidPromise(Number(zidStr), req.user.uid); - const pidStr = pid.toString(); + // The middleware ensures we have a participant + const zid = req.p.zid!; + const pid = req.p.pid!; // Get current Delphi job ID - const jobId = await getCurrentDelphiJobId(zidStr); - - // Prepare DynamoDB item - const item = { - conversation_id: zidStr, - participant_id: pidStr, - archetypal_selections: selections, - metadata: { - job_id: jobId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - version: 1, - total_selections: selections.length, - }, - }; - - // Save to DynamoDB - const putParams = { - TableName: TABLE_NAME, - Item: item, - }; + const jobId = await getCurrentDelphiJobId(zid.toString()); - await docClient.send(new PutCommand(putParams)); + // Use UPSERT (INSERT ... ON CONFLICT UPDATE) to handle both new and existing records + const query = ` + INSERT INTO topic_agenda_selections (zid, pid, archetypal_selections, delphi_job_id, total_selections, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (zid, pid) + DO UPDATE SET + archetypal_selections = EXCLUDED.archetypal_selections, + delphi_job_id = EXCLUDED.delphi_job_id, + total_selections = EXCLUDED.total_selections, + updated_at = CURRENT_TIMESTAMP + RETURNING zid, pid, total_selections + `; - logger.info( - `Saved topic agenda selections for user ${pidStr} in conversation ${zidStr}` - ); + const result = await pgQuery.queryP(query, [ + zid, + pid, + JSON.stringify(selections), + jobId, + selections.length, + ]); - res.json({ + const response: any = { status: "success", message: "Topic agenda selections saved successfully", data: { - conversation_id: zidStr, - participant_id: pidStr, - selections_count: selections.length, + conversation_id: zid.toString(), + participant_id: pid.toString(), + selections_count: + (result as any)[0]?.total_selections || selections.length, job_id: jobId, }, - }); + }; + + // Auth token will be automatically included by attachAuthToken middleware + + res.json(response); } catch (error) { logger.error("Error saving topic agenda selections", error); res.status(500).json({ @@ -152,46 +145,41 @@ export async function handle_POST_topicAgenda_selections( * Retrieve topic agenda selections for a user */ export async function handle_GET_topicAgenda_selections( - req: Request & { user?: any }, + req: RequestWithP, res: Response ) { try { - const conversation_id = req.query.conversation_id as string; - - if (!conversation_id) { - return res.status(400).json({ - status: "error", - message: "conversation_id is required", - }); - } - - if (!req.user || !req.user.uid) { - return res.status(401).json({ - status: "error", - message: "Authentication required", + // Check if we have a participant (user is authenticated and has a participant record) + if (_.isUndefined(req.p.pid) || req.p.pid < 0) { + // No participant record - return empty response + return res.json({ + status: "success", + message: "No selections found", + data: null, }); } - // Convert conversation_id to zid - const zid = await getZidFromConversationId(conversation_id); - const zidStr = zid.toString(); + const zid = req.p.zid!; + const pid = req.p.pid; - // Get participant ID - const pid = await getPidPromise(Number(zidStr), req.user.uid); - const pidStr = pid.toString(); - - // Retrieve from DynamoDB - const getParams = { - TableName: TABLE_NAME, - Key: { - conversation_id: zidStr, - participant_id: pidStr, - }, - }; + // Retrieve from PostgreSQL + const query = ` + SELECT + zid as conversation_id, + pid as participant_id, + archetypal_selections, + delphi_job_id, + total_selections, + created_at, + updated_at + FROM topic_agenda_selections + WHERE zid = $1 AND pid = $2 + `; - const result = await docClient.send(new GetCommand(getParams)); + const result = await pgQuery.queryP(query, [zid, pid]); + const rows = result as any[]; - if (!result.Item) { + if (!rows || rows.length === 0) { return res.json({ status: "success", message: "No selections found", @@ -199,13 +187,19 @@ export async function handle_GET_topicAgenda_selections( }); } - logger.info( - `Retrieved topic agenda selections for user ${pidStr} in conversation ${zidStr}` - ); + const row = rows[0]; res.json({ status: "success", - data: result.Item, + data: { + conversation_id: row.conversation_id.toString(), + participant_id: row.participant_id.toString(), + archetypal_selections: row.archetypal_selections, + delphi_job_id: row.delphi_job_id, + total_selections: row.total_selections, + created_at: row.created_at, + updated_at: row.updated_at, + }, }); } catch (error) { logger.error("Error retrieving topic agenda selections", error); @@ -221,75 +215,94 @@ export async function handle_GET_topicAgenda_selections( * Update topic agenda selections for a user */ export async function handle_PUT_topicAgenda_selections( - req: Request & { user?: any }, + req: RequestWithP, res: Response ) { try { - const { conversation_id, selections } = req.body; + const { selections } = req.body; - if (!conversation_id || !selections) { + if (!selections) { return res.status(400).json({ status: "error", - message: "conversation_id and selections are required", + message: "selections are required", }); } - if (!req.user || !req.user.uid) { + // Check if we have a participant record + if (_.isUndefined(req.p.pid) || req.p.pid < 0) { return res.status(401).json({ status: "error", message: "Authentication required", }); } - // Convert conversation_id to zid - const zid = await getZidFromConversationId(conversation_id); - const zidStr = zid.toString(); - - // Get participant ID - const pid = await getPidPromise(Number(zidStr), req.user.uid); - const pidStr = pid.toString(); + const zid = req.p.zid!; + const pid = req.p.pid; // Get current Delphi job ID - const jobId = await getCurrentDelphiJobId(zidStr); - - // Update in DynamoDB - const updateParams = { - TableName: TABLE_NAME, - Key: { - conversation_id: zidStr, - participant_id: pidStr, - }, - UpdateExpression: - "SET archetypal_selections = :selections, metadata = :metadata", - ExpressionAttributeValues: { - ":selections": selections, - ":metadata": { + const jobId = await getCurrentDelphiJobId(zid.toString()); + + // Update the record + const updateQuery = ` + UPDATE topic_agenda_selections + SET + archetypal_selections = $3, + delphi_job_id = $4, + total_selections = $5, + updated_at = CURRENT_TIMESTAMP + WHERE zid = $1 AND pid = $2 + RETURNING zid, pid, total_selections + `; + + const result = await pgQuery.queryP(updateQuery, [ + zid, + pid, + JSON.stringify(selections), + jobId, + selections.length, + ]); + const rows = result as any[]; + + if (rows.length === 0) { + // Record doesn't exist, create it instead + const insertQuery = ` + INSERT INTO topic_agenda_selections (zid, pid, archetypal_selections, delphi_job_id, total_selections, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING zid, pid, total_selections + `; + + const insertResult = await pgQuery.queryP(insertQuery, [ + zid, + pid, + JSON.stringify(selections), + jobId, + selections.length, + ]); + const insertRows = insertResult as any[]; + + res.json({ + status: "success", + message: "Topic agenda selections created successfully", + data: { + conversation_id: zid.toString(), + participant_id: pid.toString(), + selections_count: + insertRows[0]?.total_selections || selections.length, job_id: jobId, - created_at: new Date().toISOString(), // Keep original creation time if exists - updated_at: new Date().toISOString(), - version: 1, - total_selections: selections.length, }, - }, - ReturnValues: "ALL_NEW" as const, - }; - - await docClient.send(new UpdateCommand(updateParams)); - - logger.info( - `Updated topic agenda selections for user ${pidStr} in conversation ${zidStr}` - ); - - res.json({ - status: "success", - message: "Topic agenda selections updated successfully", - data: { - conversation_id: zidStr, - participant_id: pidStr, - selections_count: selections.length, - job_id: jobId, - }, - }); + }); + } else { + res.json({ + status: "success", + message: "Topic agenda selections updated successfully", + data: { + conversation_id: zid.toString(), + participant_id: pid.toString(), + selections_count: rows[0]?.total_selections || selections.length, + job_id: jobId, + }, + }); + } } catch (error) { logger.error("Error updating topic agenda selections", error); res.status(500).json({ @@ -304,48 +317,37 @@ export async function handle_PUT_topicAgenda_selections( * Delete topic agenda selections for a user */ export async function handle_DELETE_topicAgenda_selections( - req: Request & { user?: any }, + req: RequestWithP, res: Response ) { try { - const conversation_id = req.query.conversation_id as string; - - if (!conversation_id) { - return res.status(400).json({ - status: "error", - message: "conversation_id is required", - }); - } - - if (!req.user || !req.user.uid) { + // Check if we have a participant record + if (_.isUndefined(req.p.pid) || req.p.pid < 0) { return res.status(401).json({ status: "error", message: "Authentication required", }); } - // Convert conversation_id to zid - const zid = await getZidFromConversationId(conversation_id); - const zidStr = zid.toString(); + const zid = req.p.zid!; + const pid = req.p.pid; - // Get participant ID - const pid = await getPidPromise(Number(zidStr), req.user.uid); - const pidStr = pid.toString(); - - // Delete from DynamoDB - const deleteParams = { - TableName: TABLE_NAME, - Key: { - conversation_id: zidStr, - participant_id: pidStr, - }, - }; + // Delete from PostgreSQL + const deleteQuery = ` + DELETE FROM topic_agenda_selections + WHERE zid = $1 AND pid = $2 + RETURNING zid, pid + `; - await docClient.send(new DeleteCommand(deleteParams)); + const result = await pgQuery.queryP(deleteQuery, [zid, pid]); + const rows = result as any[]; - logger.info( - `Deleted topic agenda selections for user ${pidStr} in conversation ${zidStr}` - ); + if (rows.length === 0) { + return res.json({ + status: "success", + message: "No selections to delete", + }); + } res.json({ status: "success", diff --git a/server/src/routes/delphi/topicMod.ts b/server/src/routes/delphi/topicMod.ts index 23227f78e0..90a355e79f 100644 --- a/server/src/routes/delphi/topicMod.ts +++ b/server/src/routes/delphi/topicMod.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import logger from "../../utils/logger"; -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBClient, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, QueryCommand, @@ -9,10 +9,9 @@ import { } from "@aws-sdk/lib-dynamodb"; import Config from "../../config"; import p from "../../db/pg-query"; -import { getZidFromConversationId } from "../../conversation"; // DynamoDB configuration (reuse from topics.ts) -const dynamoDBConfig: any = { +const dynamoDBConfig: DynamoDBClientConfig = { region: Config.AWS_REGION || "us-east-1", }; @@ -45,25 +44,8 @@ const docClient = DynamoDBDocumentClient.from(client, { */ export async function handle_GET_topicMod_topics(req: Request, res: Response) { try { - const conversation_id = req.query.conversation_id as string; const job_id = req.query.job_id as string; - - if (!conversation_id) { - return res.json({ - status: "error", - message: "conversation_id is required", - }); - } - - // Get zid from conversation_id (which could be a zinvite) - const zid = await getZidFromConversationId(conversation_id); - if (!zid) { - return res.json({ - status: "error", - message: "Could not find conversation for conversation_id", - }); - } - + const zid = req.p.zid as number; const conversation_zid = zid.toString(); logger.info(`Fetching TopicMod topics for zid: ${conversation_zid}`); @@ -105,7 +87,7 @@ export async function handle_GET_topicMod_topics(req: Request, res: Response) { let moderationData; try { moderationData = await docClient.send(new QueryCommand(moderationParams)); - } catch (err: any) { + } catch (err: unknown) { // Moderation table might not exist yet - that's okay logger.info("Moderation status table not found, using default status"); moderationData = { Items: [] }; @@ -123,27 +105,45 @@ export async function handle_GET_topicMod_topics(req: Request, res: Response) { }); // Combine topics with moderation status - const topicsWithStatus = topicsData.Items.map((topic) => { - const moderation = moderationMap.get(topic.topic_key) || { - status: "pending", - moderator: null, - moderated_at: null, - comment_count: 0, - }; + type ModerationInfo = { + status: string; + moderator: unknown; + moderated_at: unknown; + comment_count: number; + }; + type TopicWithStatus = { + topic_name: string; + model_name?: string; + created_at?: unknown; + topic_key: string; + layer_id?: string; + cluster_id?: string; + moderation: ModerationInfo; + }; - return { - topic_name: topic.topic_name, - model_name: topic.model_name, - created_at: topic.created_at, - topic_key: topic.topic_key, - layer_id: topic.layer_id, - cluster_id: topic.cluster_id, - moderation: moderation, - }; - }); + const topicsWithStatus: TopicWithStatus[] = topicsData.Items.map( + (topic) => { + const moderation = moderationMap.get(topic.topic_key) || { + status: "pending", + moderator: null, + moderated_at: null, + comment_count: 0, + }; + + return { + topic_name: topic.topic_name, + model_name: topic.model_name, + created_at: topic.created_at, + topic_key: topic.topic_key, + layer_id: topic.layer_id, + cluster_id: topic.cluster_id, + moderation: moderation, + }; + } + ); // Group by layer for hierarchical display - const topicsByLayer: Record = {}; + const topicsByLayer: Record = {}; topicsWithStatus.forEach((topic) => { const layerId = topic.layer_id || "0"; if (!topicsByLayer[layerId]) { @@ -165,12 +165,13 @@ export async function handle_GET_topicMod_topics(req: Request, res: Response) { topics_by_layer: topicsByLayer, total_topics: topicsWithStatus.length, }); - } catch (err: any) { - logger.error(`Error in handle_GET_topicMod_topics: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error in handle_GET_topicMod_topics: ${message}`); return res.json({ status: "error", message: "Error retrieving topics", - error: err.message, + error: message, }); } } @@ -184,24 +185,16 @@ export async function handle_GET_topicMod_comments( res: Response ) { try { - const conversation_id = req.query.conversation_id as string; const topic_key = req.params.topicKey; - if (!conversation_id || !topic_key) { + if (!topic_key) { return res.json({ status: "error", - message: "conversation_id and topic_key are required", - }); - } - - const zid = await getZidFromConversationId(conversation_id); - if (!zid) { - return res.json({ - status: "error", - message: "Could not find conversation for conversation_id", + message: "topic_key is required", }); } + const zid = req.p.zid as number; const comment_conversation_id = zid.toString(); logger.info( `Fetching comments for topic ${topic_key} in conversation ${comment_conversation_id}` @@ -244,12 +237,13 @@ export async function handle_GET_topicMod_comments( comments: comments, total_comments: comments.length, }); - } catch (err: any) { - logger.error(`Error in handle_GET_topicMod_comments: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error in handle_GET_topicMod_comments: ${message}`); return res.json({ status: "error", message: "Error retrieving comments", - error: err.message, + error: message, }); } } @@ -263,18 +257,12 @@ export async function handle_POST_topicMod_moderate( res: Response ) { try { - const { - conversation_id, - topic_key, - comment_ids, - action, - moderator, - } = req.body; - - if (!conversation_id || !action || !moderator) { + const { topic_key, comment_ids, action, moderator } = req.body; + + if (!action || !moderator) { return res.json({ status: "error", - message: "conversation_id, action, and moderator are required", + message: "action and moderator are required", }); } @@ -285,14 +273,7 @@ export async function handle_POST_topicMod_moderate( }); } - const zid = await getZidFromConversationId(conversation_id); - if (!zid) { - return res.json({ - status: "error", - message: "Could not find conversation for conversation_id", - }); - } - + const zid = req.p.zid as number; const moderate_conversation_id = zid.toString(); const now = new Date().toISOString(); @@ -319,8 +300,13 @@ export async function handle_POST_topicMod_moderate( try { await docClient.send(new UpdateCommand(topicParams)); - } catch (err: any) { - if (err.name === "ResourceNotFoundException") { + } catch (err: unknown) { + if ( + err && + typeof err === "object" && + "name" in err && + (err as { name?: string }).name === "ResourceNotFoundException" + ) { // Create the record if it doesn't exist const putParams = { TableName: "Delphi_TopicModerationStatus", @@ -393,12 +379,13 @@ export async function handle_POST_topicMod_moderate( message: `Moderation action '${action}' applied successfully`, moderated_at: now, }); - } catch (err: any) { - logger.error(`Error in handle_POST_topicMod_moderate: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error in handle_POST_topicMod_moderate: ${message}`); return res.json({ status: "error", message: "Error applying moderation action", - error: err.message, + error: message, }); } } @@ -412,24 +399,8 @@ export async function handle_GET_topicMod_proximity( res: Response ) { try { - const conversation_id = req.query.conversation_id as string; const layer_id = (req.query.layer_id as string) || "all"; - - if (!conversation_id) { - return res.json({ - status: "error", - message: "conversation_id is required", - }); - } - - const zid = await getZidFromConversationId(conversation_id); - if (!zid) { - return res.json({ - status: "error", - message: "Could not find conversation for conversation_id", - }); - } - + const zid = req.p.zid as number; const proximity_conversation_id = zid.toString(); logger.info( `Fetching proximity data for conversation ${proximity_conversation_id}, layer ${layer_id}` @@ -475,8 +446,9 @@ export async function handle_GET_topicMod_proximity( logger.info( `Found ${clusterData.Items?.length || 0} cluster assignments` ); - } catch (err: any) { - logger.error(`Error fetching cluster assignments: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error fetching cluster assignments: ${message}`); clusterData = { Items: [] }; } @@ -687,12 +659,13 @@ export async function handle_GET_topicMod_proximity( proximity_data: proximityData, total_points: proximityData.length, }); - } catch (err: any) { - logger.error(`Error in handle_GET_topicMod_proximity: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error in handle_GET_topicMod_proximity: ${message}`); return res.json({ status: "error", message: "Error retrieving proximity data", - error: err.message, + error: message, }); } } @@ -706,23 +679,7 @@ export async function handle_GET_topicMod_hierarchy( res: Response ) { try { - const conversation_id = req.query.conversation_id as string; - - if (!conversation_id) { - return res.json({ - status: "error", - message: "conversation_id is required", - }); - } - - const zid = await getZidFromConversationId(conversation_id); - if (!zid) { - return res.json({ - status: "error", - message: "Could not find conversation for conversation_id", - }); - } - + const zid = req.p.zid as number; const hierarchy_conversation_id = zid.toString(); logger.info( `Fetching hierarchy data for conversation ${hierarchy_conversation_id}` @@ -882,12 +839,13 @@ export async function handle_GET_topicMod_hierarchy( totalClusters: clusters.length, layers: Array.from(layers.keys()).sort(), }); - } catch (err: any) { - logger.error(`Error in handle_GET_topicMod_hierarchy: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error in handle_GET_topicMod_hierarchy: ${message}`); return res.json({ status: "error", message: "Error retrieving hierarchy data", - error: err.message, + error: message, }); } } @@ -898,23 +856,7 @@ export async function handle_GET_topicMod_hierarchy( */ export async function handle_GET_topicMod_stats(req: Request, res: Response) { try { - const conversation_id = req.query.conversation_id as string; - - if (!conversation_id) { - return res.json({ - status: "error", - message: "conversation_id is required", - }); - } - - const zid = await getZidFromConversationId(conversation_id); - if (!zid) { - return res.json({ - status: "error", - message: "Could not find conversation for conversation_id", - }); - } - + const zid = req.p.zid as number; const stats_conversation_id = zid.toString(); logger.info( `Fetching moderation stats for conversation ${stats_conversation_id}` @@ -932,8 +874,13 @@ export async function handle_GET_topicMod_stats(req: Request, res: Response) { let data; try { data = await docClient.send(new QueryCommand(params)); - } catch (err: any) { - if (err.name === "ResourceNotFoundException") { + } catch (err: unknown) { + if ( + err && + typeof err === "object" && + "name" in err && + (err as { name?: string }).name === "ResourceNotFoundException" + ) { // No moderation data yet return res.json({ status: "success", @@ -972,12 +919,13 @@ export async function handle_GET_topicMod_stats(req: Request, res: Response) { message: "Moderation statistics retrieved successfully", stats: stats, }); - } catch (err: any) { - logger.error(`Error in handle_GET_topicMod_stats: ${err.message}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Error in handle_GET_topicMod_stats: ${message}`); return res.json({ status: "error", message: "Error retrieving moderation statistics", - error: err.message, + error: message, }); } } diff --git a/server/src/routes/math.ts b/server/src/routes/math.ts index b5072a2186..7df69d2db4 100644 --- a/server/src/routes/math.ts +++ b/server/src/routes/math.ts @@ -28,7 +28,7 @@ function handle_GET_math_pca( // Cache the knowledge of whether there are any pca results for a given zid. // Needed to determine whether to return a 404 or a 304. // zid -> boolean -const pcaResultsExistForZid = {}; +const pcaResultsExistForZid: Record = {}; function handle_GET_math_pca2( req: { p: { zid: number; math_tick: any; ifNoneMatch: any } }, diff --git a/server/src/routes/participantsOfInterest.ts b/server/src/routes/participantsOfInterest.ts new file mode 100644 index 0000000000..78e241420f --- /dev/null +++ b/server/src/routes/participantsOfInterest.ts @@ -0,0 +1,145 @@ +import _ from "underscore"; +import { failJson } from "../utils/fail"; +import { getConversationInfo } from "../conversation"; +import { isModerator, isPolisDev } from "../utils/common"; +import { pullXInfoIntoSubObjects } from "../server-helpers"; +import pg from "../db/pg-query"; + +function getSocialParticipantsForMod_timed( + zid?: number, + limit?: any, + mod?: any, + convOwner?: any +) { + return getSocialParticipantsForMod + .apply(null, [zid, limit, mod, convOwner]) + .then(function (results: any) { + return results; + }); +} + +function getSocialParticipantsForMod( + zid: number, + limit: any, + mod: any, + owner: any +) { + let modClause = ""; + const params = [zid, limit, owner]; + if (!_.isUndefined(mod)) { + modClause = " and mod = ($4)"; + params.push(mod); + } + + const q = + "with " + + "p as (select uid, pid, mod from participants where zid = ($1) " + + modClause + + "), " + // and vote_count >= 1 + "final_set as (select * from p limit ($2)), " + + "xids_subset as (select * from xids where owner = ($3) and x_profile_image_url is not null), " + + "all_rows as (select " + + // "final_set.priority, " + + "final_set.mod, " + + "xids_subset.x_profile_image_url as x_profile_image_url, " + + "xids_subset.xid as xid, " + + "xids_subset.x_name as x_name, " + + "final_set.pid " + + "from final_set " + + "left join xids_subset on final_set.uid = xids_subset.uid " + + ") " + + "select * from all_rows where (xid is not null) " + + ";"; + return pg.queryP(q, params); +} + +function removeNullOrUndefinedProperties(o: { [x: string]: any }) { + for (const k in o) { + const v = o[k]; + if (v === null || v === undefined) { + delete o[k]; + } + } + return o; +} + +function handle_PUT_ptptois( + req: { p: { zid: number; uid?: number; pid: number; mod: any } }, + res: { + status: (arg0: number) => { + (): any; + new (): any; + json: { (arg0: {}): void; new (): any }; + }; + } +) { + const zid = req.p.zid; + const uid = req.p.uid; + const pid = req.p.pid; + const mod = req.p.mod; + isModerator(zid, uid) + .then(function (isMod: any) { + if (!isMod) { + failJson(res, 403, "polis_err_ptptoi_permissions_123"); + return; + } + return pg + .queryP( + "update participants set mod = ($3) where zid = ($1) and pid = ($2);", + [zid, pid, mod] + ) + .then(function () { + res.status(200).json({}); + }); + }) + .catch(function (err: any) { + failJson(res, 500, "polis_err_ptptoi_misc_234", err); + }); +} + +function handle_GET_ptptois( + req: { + p: { zid: number; mod: any; uid?: number; conversation_id: string }; + }, + res: { + status: (arg0: number) => { + (): any; + new (): any; + json: { (arg0: any): void; new (): any }; + }; + } +) { + const zid = req.p.zid; + const mod = req.p.mod; + const uid = req.p.uid; + const limit = 99999; + + const convPromise = getConversationInfo(req.p.zid); + const socialPtptsPromise = convPromise.then((conv: { owner: any }) => { + return getSocialParticipantsForMod_timed(zid, limit, mod, conv.owner); + }); + + Promise.all([socialPtptsPromise, getConversationInfo(zid)]) + .then(function (a: any[]) { + let ptptois = a[0]; + const conv = a[1]; + const isOwner = uid === conv.owner; + const isAllowed = isOwner || isPolisDev(req.p.uid) || conv.is_data_open; + if (isAllowed) { + ptptois = ptptois.map(pullXInfoIntoSubObjects); + ptptois = ptptois.map(removeNullOrUndefinedProperties); + ptptois = ptptois.map(function (p: { conversation_id: any }) { + p.conversation_id = req.p.conversation_id; + return p; + }); + } else { + ptptois = []; + } + res.status(200).json(ptptois); + }) + .catch(function (err: any) { + failJson(res, 500, "polis_err_ptptoi_misc", err); + }); +} + +export { handle_GET_ptptois, handle_PUT_ptptois }; diff --git a/server/src/routes/participation.ts b/server/src/routes/participation.ts index 64131f1d9d..355ae54d27 100644 --- a/server/src/routes/participation.ts +++ b/server/src/routes/participation.ts @@ -2,30 +2,23 @@ import _ from "underscore"; import { addExtendedParticipantInfo, joinConversation } from "../participant"; import { failJson } from "../utils/fail"; import { getConversationInfo, getXidRecord } from "../conversation"; +import { getNextComment } from "../nextComment"; import { getPca } from "../utils/pca"; -import { getPid, getUser, getPidPromise } from "../user"; +import { getPid, getUser } from "../user"; import { getVotesForSingleParticipant } from "./votes"; import { getXids } from "./math"; import { isConversationOwner, isOwner } from "../utils/common"; -import { issueAnonymousJWT, issueStandardUserJWT, issueXidJWT } from "../auth"; import { MPromise } from "../utils/metered"; import { sql_participants_extended } from "../db/sql"; import { userHasAnsweredZeQuestions } from "../server-helpers"; import logger from "../utils/logger"; import pg from "../db/pg-query"; -import { - ParticipantFields, - ParticipantInfo, - ExpressResponse, - Headers, -} from "../d"; +import { ParticipantFields, ParticipantInfo, ExpressResponse } from "../d"; import { doFamousQuery, updateLastInteractionTimeForConversation, - getNextComment, getOneConversation, } from "../server-helpers"; -import { checkLegacyCookieAndIssueJWT } from "../auth/legacyCookies"; // basic defaultdict implementation function DD(this: any, f: () => { votes: number; comments: number }) { @@ -293,6 +286,9 @@ function handle_GET_participation( async function handle_GET_participationInit( req: { p: { + participantInfo: any; + ptptoiLimit: number; + authToken: any; anonymous_participant?: boolean; oidc_sub?: string; oidcUser?: any; @@ -309,6 +305,7 @@ async function handle_GET_participationInit( xid_participant?: boolean; xid: string; zid: number; + includePCA: boolean; }; headers?: Headers; }, @@ -316,6 +313,7 @@ async function handle_GET_participationInit( ) { try { logger.debug(`handle_GET_participationInit ${JSON.stringify(req.p)}`); + // Handle language preference const acceptLanguage = req?.headers?.["accept-language"] || @@ -350,99 +348,28 @@ async function handle_GET_participationInit( return res.status(200).json(response); } - // Handle JWT conversation mismatches for anonymous participants - if (req.p.jwt_conversation_mismatch && req.p.anonymous_participant) { - logger.debug( - "Anonymous participant with JWT for different conversation - treating as new participant" - ); - // Clear the uid/pid from the mismatched JWT - req.p.uid = undefined; - req.p.pid = -1; - } - - // Handle JWT conversation mismatches for XID participants - if (req.p.jwt_conversation_mismatch && req.p.xid_participant && req.p.xid) { - // Determine which case we're in - const jwtXid = req.p.jwt_xid; - const requestXid = req.p.xid; - - // Check if XID in request matches XID in JWT - const xidMatches = jwtXid === requestXid; - - // Get XID record for the requested conversation - let xidForRequestedConversation = false; - try { - const xidRecords = await getXidRecord(requestXid, req.p.zid); - if (xidRecords && xidRecords.length > 0) { - xidForRequestedConversation = true; - } - } catch (err) { - // XID not found for this conversation - } - - if (xidMatches) { - // Case 2: Token and XID align but are for a different conversation - logger.debug( - "Case 2: XID JWT and request XID match but for different conversation - treating as anonymous" - ); - req.p.xid = ""; // Clear XID to treat as anonymous - req.p.uid = undefined; - req.p.pid = -1; - } else if (!xidMatches && xidForRequestedConversation) { - // Case 3: Token is for different conversation, but XID is for current conversation - logger.debug( - "Case 3: JWT for different conversation but XID is for current conversation - maintaining XID" - ); - // Clear JWT-based uid/pid, will be resolved from XID below - req.p.uid = undefined; - req.p.pid = -1; - } else { - // Case 4: Token is for current conversation, but XID is for another conversation - logger.debug( - "Case 4: JWT for current conversation but XID for different conversation - treating as anonymous" - ); - req.p.xid = ""; // Clear XID to treat as anonymous - // Keep the uid/pid from the JWT since it's for the current conversation - } - } - - // Check for legacy cookie before proceeding - let legacyCookieToken: string | undefined; - if ( - req.p.uid === undefined && - !req.p.jwt_conversation_mismatch && - req.p.conversation_id - ) { - const legacyResult = await checkLegacyCookieAndIssueJWT( - req, - req.p.zid, - req.p.conversation_id, - req.p.xid - ); - if (legacyResult.uid !== undefined && legacyResult.pid !== undefined) { - req.p.uid = legacyResult.uid; - req.p.pid = legacyResult.pid; - legacyCookieToken = legacyResult.token; - logger.info( - "Using existing participant from legacy cookie in participationInit", - { - uid: legacyResult.uid, - pid: legacyResult.pid, - } - ); - } - } - - // For XID users, resolve XID to UID first - let effectiveUidForUser = req.p.uid; - if (req.p.xid && !req.p.uid) { + // The middleware has already handled: + // - JWT conversation mismatches + // - Legacy cookie checks + // - XID to UID resolution + // - JWT issuance + + // Get participant info from middleware + const participantInfo = req.p.participantInfo; + const uid = participantInfo?.uid; + const pid = participantInfo?.pid ?? -1; + + // For XID users who might not have a participant record yet, + // we still need to resolve the XID for user info + let effectiveUidForUser = uid; + if (req.p.xid && !uid) { try { const xidRecords = await getXidRecord(req.p.xid, req.p.zid); if (xidRecords && xidRecords.length > 0) { effectiveUidForUser = xidRecords[0].uid; } } catch (err) { - logger.warn("Error looking up XID record for user resolution:", err); + logger.debug("XID not found for user resolution:", err); } } @@ -459,30 +386,16 @@ async function handle_GET_participationInit( response.user = user; response.ptpt = ptpt; response.conversation = conv; - response.pca = pcaData?.asPOJO ? pcaData : null; - - // Determine the correct pid for this user - let effectivePid = req.p.pid; - let effectiveUid: number | undefined; - - if (req.p.xid && typeof user === "object" && "xInfo" in user) { - // Handle XID users - const userWithXInfo = user as any; - if (userWithXInfo.xInfo?.uid !== undefined) { - effectiveUid = userWithXInfo.xInfo.uid; - try { - const actualPid = await getPidPromise(req.p.zid, effectiveUid); - if (actualPid >= 0) { - effectivePid = actualPid; - } - } catch (err) { - logger.warn("Error getting pid for XID user", err); - } - } else if (userWithXInfo.uid !== undefined && userWithXInfo.pid >= 0) { - effectiveUid = userWithXInfo.uid; - effectivePid = userWithXInfo.pid; - } - } else if (req.p.uid && typeof user === "object" && "pid" in user) { + + if (req.p.includePCA !== false) { + response.pca = pcaData?.asPOJO ? pcaData : null; + } + + // Determine the effective pid for fetching votes and comments + let effectivePid = pid; + + // If we have a user object with pid info, use it + if (typeof user === "object" && "pid" in user) { const userWithPid = user as any; if (userWithPid.pid >= 0) { effectivePid = userWithPid.pid; @@ -495,12 +408,12 @@ async function handle_GET_participationInit( pid: effectivePid, zid: req.p.zid, }), - getNextComment(req.p.zid, effectivePid, [], true, req.p.lang), + getNextComment(req.p.zid, effectivePid, [], req.p.lang), doFamousQuery({ - uid: req.p.uid, + uid: uid, zid: req.p.zid, math_tick: response.pca?.math_tick || 0, - ptptoiLimit: 30, + ptptoiLimit: req.p.ptptoiLimit || 30, }), ]); @@ -508,91 +421,11 @@ async function handle_GET_participationInit( response.nextComment = nextComment; response.famous = famous || {}; - // Issue JWT based on user type - if (legacyCookieToken) { - // Use the JWT from legacy cookie lookup - response.auth = { - token: legacyCookieToken, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }; - logger.debug("Using JWT from legacy cookie lookup in participationInit"); - } else if ( - req.p.oidc_sub && - effectiveUid !== undefined && - effectivePid >= 0 - ) { - // Issue JWT for standard users (OIDC authenticated) - try { - const token = issueStandardUserJWT( - req.p.oidc_sub, - req.p.conversation_id, - effectiveUid, - effectivePid - ); - - response.auth = { - token: token, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }; - - logger.debug("Standard user JWT issued successfully", { - oidc_sub: req.p.oidc_sub, - uid: effectiveUid, - pid: effectivePid, - }); - } catch (error) { - logger.error("Failed to issue standard user JWT:", error); - } - } else if (req.p.xid && effectiveUid !== undefined && effectivePid >= 0) { - // Issue JWT for XID users - try { - const token = issueXidJWT( - req.p.xid, - req.p.conversation_id, - effectiveUid, - effectivePid - ); - - response.auth = { - token: token, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }; - - logger.debug("XID JWT issued successfully", { - xid: req.p.xid, - uid: effectiveUid, - pid: effectivePid, - }); - } catch (error) { - logger.error("Failed to issue XID JWT:", error); - } - } else if (!req.p.xid && effectiveUid !== undefined && effectivePid >= 0) { - // Issue JWT for anonymous users if they already exist - try { - const token = issueAnonymousJWT( - req.p.conversation_id, - effectiveUid, - effectivePid - ); - - response.auth = { - token: token, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }; - - logger.debug("Anonymous JWT issued successfully", { - uid: effectiveUid, - pid: effectivePid, - }); - } catch (error) { - logger.error("Failed to issue anonymous JWT:", error); - } + // Include JWT from middleware if one was issued + if (req.p.authToken) { + response.auth = req.p.authToken; + logger.debug("Including JWT from middleware in response"); } - // Note: New anonymous participants get JWTs on first action (like voting), not on participationInit // Clean up sensitive data if (response.conversation) { diff --git a/server/src/routes/participation/topicPrioritize.ts b/server/src/routes/participation/topicPrioritize.ts index 28ef257e61..0cafa299ee 100644 --- a/server/src/routes/participation/topicPrioritize.ts +++ b/server/src/routes/participation/topicPrioritize.ts @@ -1,5 +1,4 @@ import { Request, Response } from "express"; -import { getZidFromConversationId } from "../../conversation"; import logger from "../../utils/logger"; import p from "../../db/pg-query"; @@ -15,37 +14,22 @@ export async function handle_GET_participation_topicPrioritize( req: Request, res: Response ) { - const { conversation_id } = req.query; - - if (!conversation_id) { - return res.status(400).json({ - status: "error", - message: "conversation_id is required", - }); - } - try { - // Get the numeric zid from the zinvite - const zid = await getZidFromConversationId(conversation_id as string); - - if (!zid) { - return res.status(404).json({ - status: "error", - message: "Conversation not found", - has_report: false, - has_delphi_data: false, - }); - } + const conversation_id = req.p.conversation_id as string; + const zid = req.p.zid as number; // Check if there's a report for this conversation const reportQuery = "SELECT report_id, created FROM reports WHERE zid = $1 ORDER BY created DESC LIMIT 1"; - const reportResult = (await p.queryP(reportQuery, [zid])) as any[]; + const reportResult = (await p.queryP(reportQuery, [zid])) as Array<{ + report_id: number; + created: string | number | Date; + }>; if (!reportResult || reportResult.length === 0) { return res.json({ status: "success", - conversation_id: zid, + conversation_id, has_report: false, has_delphi_data: false, message: "No report available for this conversation", @@ -58,7 +42,7 @@ export async function handle_GET_participation_topicPrioritize( // In the future, we could check DynamoDB for actual Delphi data existence return res.json({ status: "success", - conversation_id: zid, + conversation_id, report_id: report.report_id, has_report: true, has_delphi_data: true, // Optimistic - the client will verify when fetching diff --git a/server/src/routes/topicStats.ts b/server/src/routes/topicStats.ts new file mode 100644 index 0000000000..70ffa3a14c --- /dev/null +++ b/server/src/routes/topicStats.ts @@ -0,0 +1,219 @@ +import { Request, Response } from "express"; +import logger from "../utils/logger"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; +import { getZidFromReport } from "../utils/parameter"; +import Config from "../config"; + +const dynamoDBConfig: any = { + region: Config.AWS_REGION || "us-east-1", +}; + +if (Config.dynamoDbEndpoint) { + dynamoDBConfig.endpoint = Config.dynamoDbEndpoint; + dynamoDBConfig.credentials = { + accessKeyId: "DUMMYIDEXAMPLE", + secretAccessKey: "DUMMYEXAMPLEKEY", + }; +} else if (Config.AWS_ACCESS_KEY_ID && Config.AWS_SECRET_ACCESS_KEY) { + dynamoDBConfig.credentials = { + accessKeyId: Config.AWS_ACCESS_KEY_ID, + secretAccessKey: Config.AWS_SECRET_ACCESS_KEY, + }; +} + +const client = new DynamoDBClient(dynamoDBConfig); +const docClient = DynamoDBDocumentClient.from(client, { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + }, +}); + +interface TopicMetrics { + comment_count: number; + comment_tids: number[]; // List of comment IDs for client-side calculations +} + + +/** + * Return basic topic info - all calculations done client-side + */ +async function calculateTopicMetrics( + zid: number, + commentIds: number[] +): Promise { + return { + comment_count: commentIds.length, + comment_tids: commentIds, + }; +} + +/** + * Handler for /api/v3/topicStats endpoint + */ +export async function handle_GET_topicStats(req: Request, res: Response) { + logger.info("TopicStats API request received"); + + const report_id = req.query.report_id as string; + if (!report_id) { + return res.status(400).json({ + status: "error", + message: "report_id is required", + }); + } + + try { + const zid = await getZidFromReport(report_id); + if (!zid) { + return res.status(404).json({ + status: "error", + message: "Could not find conversation for report_id", + report_id: report_id, + }); + } + + const conversation_id = zid.toString(); + logger.info(`Fetching topic stats for conversation_id: ${conversation_id}`); + + // Get all topics first + const topicsTable = "Delphi_CommentClustersLLMTopicNames"; + const topicsParams = { + TableName: topicsTable, + KeyConditionExpression: "conversation_id = :cid", + ExpressionAttributeValues: { + ":cid": conversation_id, + }, + }; + + const topicsData = await docClient.send(new QueryCommand(topicsParams)); + if (!topicsData.Items || topicsData.Items.length === 0) { + return res.json({ + status: "success", + message: "No topics found for this conversation", + stats: {}, + }); + } + + // Create mapping of layer_cluster to topic + const clusterToTopic: Record = {}; + topicsData.Items.forEach((topic) => { + const topicKey = topic.topic_key; + + // Handle both formats: + // Old format: 'layer0_5' -> layer=0, cluster=5 + // New format: 'uuid#0#5' -> layer=0, cluster=5 + + if (topicKey.includes('#')) { + // New format with job UUID + const parts = topicKey.split('#'); + if (parts.length >= 3) { + const layer = parseInt(parts[1]); + const cluster = parseInt(parts[2]); + clusterToTopic[`${layer}_${cluster}`] = topic; + } + } else if (topicKey.includes('_')) { + // Old format + const parts = topicKey.split('_'); + if (parts.length >= 2 && parts[0].startsWith('layer')) { + const layer = parseInt(parts[0].replace('layer', '')); + const cluster = parseInt(parts[1]); + clusterToTopic[`${layer}_${cluster}`] = topic; + } + } + }); + + // Query all comment assignments from DynamoDB + const assignmentsParams = { + TableName: "Delphi_CommentHierarchicalClusterAssignments", + KeyConditionExpression: "conversation_id = :cid", + ExpressionAttributeValues: { + ":cid": conversation_id, + }, + }; + + const allAssignments: any[] = []; + let lastEvaluatedKey; + + do { + const params: any = { + ...assignmentsParams, + ExclusiveStartKey: lastEvaluatedKey, + }; + + const data = await docClient.send(new QueryCommand(params)); + if (data.Items) { + allAssignments.push(...data.Items); + } + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + if (allAssignments.length === 0) { + return res.json({ + status: "success", + message: "No comment assignments found", + stats: {}, + }); + } + + // Group comments by topic_key + const commentsByTopic: Record> = {}; + + // Initialize all topics + topicsData.Items.forEach((topic) => { + commentsByTopic[topic.topic_key] = new Set(); + }); + + // Map comments to topics based on cluster assignments + allAssignments.forEach((assignment) => { + const commentId = parseInt(assignment.comment_id); + + // Check each layer - dynamically determine max layers from the data + // Look for all layer fields in the assignment + const layerFields = Object.keys(assignment).filter(key => key.match(/^layer\d+_cluster_id$/)); + const maxLayer = Math.max(...layerFields.map(field => parseInt(field.match(/layer(\d+)/)[1]))); + + for (let layer = 0; layer <= maxLayer; layer++) { + const clusterId = assignment[`layer${layer}_cluster_id`]; + if (clusterId !== undefined && clusterId !== -1) { + const topicLookupKey = `${layer}_${clusterId}`; + const topic = clusterToTopic[topicLookupKey]; + if (topic) { + commentsByTopic[topic.topic_key].add(commentId); + } + } + } + }); + + + // Calculate metrics for each topic + const topicStats: Record = {}; + + for (const [topicKey, commentIdSet] of Object.entries(commentsByTopic)) { + const commentIds = Array.from(commentIdSet); + const metrics = await calculateTopicMetrics(zid, commentIds); + topicStats[topicKey] = metrics; + } + + return res.json({ + status: "success", + message: "Topic statistics retrieved successfully", + report_id, + stats: topicStats, + total_topics: Object.keys(topicStats).length, + }); + } catch (err: any) { + logger.error(`Error in handle_GET_topicStats: ${err.message}`); + logger.error(`Error stack: ${err.stack}`); + + return res.status(500).json({ + status: "error", + message: "Error retrieving topic statistics", + error_details: { + name: err.name, + message: err.message, + }, + report_id, + }); + } +} \ No newline at end of file diff --git a/server/src/routes/votes.ts b/server/src/routes/votes.ts index f96e3e7f55..548296a39a 100644 --- a/server/src/routes/votes.ts +++ b/server/src/routes/votes.ts @@ -1,33 +1,19 @@ import _ from "underscore"; -import { addParticipantAndMetadata } from "../participant"; +import { ConversationInfo, PidReadyResult, RequestWithP } from "../d"; import { failJson } from "../utils/fail"; -import { getPid, getPidPromise } from "../user"; -import { getZinvite } from "../utils/zinvite"; +import { getNextComment } from "../nextComment"; +import { getPid } from "../user"; import { isDuplicateKey, polisTypes } from "../utils/common"; -import { PidReadyResult, Vote, ConversationInfo } from "../d"; +import { isXidWhitelisted } from "../conversation"; import logger from "../utils/logger"; import pg from "../db/pg-query"; import SQL from "../db/sql"; -import { - createAnonUser, - issueAnonymousJWT, - issueStandardUserJWT, - issueXidJWT, -} from "../auth"; -import { checkLegacyCookieAndIssueJWT } from "../auth/legacyCookies"; -import { - isXidWhitelisted, - getConversationInfo, - getXidRecord, - createXidRecordByZid, -} from "../conversation"; import { addNoMoreCommentsRecord, addStar, doFamousQuery, finishArray, finishOne, - getNextComment, safeTimestampToMillis, updateConversationModifiedTime, updateLastInteractionTimeForConversation, @@ -40,20 +26,6 @@ interface VoteResult { conv: ConversationInfo; vote: any; } -interface VoteRequest { - p: Vote & { - anonymous_participant?: boolean; - oidc_sub?: string; - oidcUser?: any; - jwt_conversation_id?: string; - jwt_conversation_mismatch?: boolean; - jwt_xid?: string; - requested_conversation_id?: string; - standard_user_participant?: boolean; - xid_participant?: boolean; - }; - headers?: { [x: string]: any }; -} interface VoteGetRequest { p: { @@ -213,304 +185,54 @@ async function handle_GET_votes(req: VoteGetRequest, res: any) { } /** - * Handle user identification and creation for votes - * Returns the final UID to use for the participant - */ -async function handleUserIdentification(req: VoteRequest): Promise { - const uid = req.p.uid; - - if (uid !== undefined) { - return uid; - } - - if (req.p.xid) { - // Handle XID users - look up or create their UID - const existingXidRecords = await getXidRecord(req.p.xid, req.p.zid); - - if (existingXidRecords && existingXidRecords.length > 0) { - // XID user already exists - return existingXidRecords[0].uid; - } - - // XID user doesn't exist, need to create one - const conv = await getConversationInfo(req.p.zid); - if (conv.use_xid_whitelist) { - const isWhitelisted = await isXidWhitelisted(conv.owner, req.p.xid); - if (!isWhitelisted) { - throw new Error("polis_err_xid_not_whitelisted"); - } - } - - // Create new anonymous user for this XID - const newUid = await createAnonUser(); - - // Create XID record linking the XID to the new user - await createXidRecordByZid( - req.p.zid, - newUid, - req.p.xid, - undefined, - undefined, - undefined - ); - - return newUid; - } - - // Create anonymous user - const newUid = await createAnonUser(); - req.p.uid = newUid; // Set uid in request for subsequent middleware - return newUid; -} - -/** - * Get or create participant for the given user and conversation - * Returns the participant ID and whether it was newly created - */ -async function getOrCreateParticipant( - zid: number, - uid: number, - existingPid: number | undefined, - req: VoteRequest -): Promise<{ pid: number; isNewlyCreated: boolean }> { - if (existingPid !== undefined) { - return { pid: existingPid, isNewlyCreated: false }; - } - - // Check if participant already exists - const foundPid = await getPidPromise(zid, uid); - - if (foundPid !== -1) { - return { pid: foundPid, isNewlyCreated: false }; - } - - // Create new participant with constraint violation protection - try { - const rows = await addParticipantAndMetadata(zid, uid, req); - return { pid: rows[0].pid, isNewlyCreated: true }; - } catch (createError) { - // Handle race condition where another request created the participant - if (isDuplicateKey(createError)) { - const retryPid = await getPidPromise(zid, uid); - if (retryPid !== -1) { - return { pid: retryPid, isNewlyCreated: false }; - } - } - throw createError; - } -} - -/** - * Issue JWT token for the participant if needed + * Simplified vote handler - all participant management is handled by middleware */ -async function issueJWTIfNeeded( - req: VoteRequest, - uid: number, - pid: number, - zid: number, - isNewlyCreated: boolean -): Promise { - logger.debug("issueJWTIfNeeded called", { - uid, - pid, +async function handle_POST_votes(req: RequestWithP, res: any) { + const { zid, - isNewlyCreated, - hasAuthHeader: !!req.headers?.authorization, - xid: req.p.xid, - oidc_sub: req.p.oidc_sub, - standard_user_participant: req.p.standard_user_participant, - }); - - // Skip JWT issuance only if participant already has a valid JWT - if (req.headers?.authorization) { - logger.debug("Skipping JWT issuance - participant already has auth header"); - return {}; // Already has JWT - } - - // Issue JWT for: - // 1. Newly created participants - // 2. Existing participants who don't have a JWT yet (no auth header) - logger.debug("Proceeding with JWT issuance - participant needs JWT", { - isNewlyCreated, - hasAuthHeader: !!req.headers?.authorization, - }); - - try { - const conversationId = (await getZinvite(zid)) as string; - - if (!conversationId) { - throw new Error(`Could not find conversation_id for zid ${zid}`); - } - - logger.debug("Got conversation ID for JWT", { conversationId, zid }); - - // Determine which type of JWT to issue - let token; - let tokenType; - - if (req.p.oidc_sub) { - // Standard user with OIDC authentication - token = issueStandardUserJWT(req.p.oidc_sub, conversationId, uid, pid); - tokenType = "StandardUser"; - } else if (req.p.xid) { - // XID participant - token = issueXidJWT(req.p.xid, conversationId, uid, pid); - tokenType = "XID"; - } else { - // Anonymous participant - token = issueAnonymousJWT(conversationId, uid, pid); - tokenType = "Anonymous"; - } - - logger.debug("JWT issued successfully", { - tokenType, - uid, - pid, - conversationId, - oidc_sub: req.p.oidc_sub, - }); - - return { - auth: { - token: token, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year in seconds - }, - }; - } catch (error) { - logger.error("Failed to issue JWT on vote:", error); - return {}; // Continue without JWT - maintains backward compatibility - } -} - -async function handle_POST_votes(req: VoteRequest, res: any) { - const zid = req.p.zid; - let pid = req.p.pid; + pid, + uid, + tid, + vote, + weight, + high_priority, + starred, + lang, + xid, + } = req.p; try { - // Handle JWT conversation mismatches - if (req.p.jwt_conversation_mismatch) { - if (req.p.anonymous_participant) { - // Anonymous participant with JWT for different conversation - treat as new - logger.debug( - "Anonymous participant voting with JWT for different conversation - treating as new" - ); - req.p.uid = undefined; - req.p.pid = undefined; - } else if (req.p.xid_participant && req.p.xid) { - // XID participant - apply the same 4-case logic as participationInit - const jwtXid = req.p.jwt_xid; - const requestXid = req.p.xid; - const xidMatches = jwtXid === requestXid; - - // Check if XID exists for current conversation - let xidForCurrentConversation = false; - try { - const xidRecords = await getXidRecord(requestXid, zid); - if (xidRecords && xidRecords.length > 0) { - xidForCurrentConversation = true; - } - } catch (err) { - // XID not found for this conversation - } - - if (xidMatches) { - // Case 2: Token and XID align but are for different conversation - logger.debug( - "Case 2: XID participant voting with matching JWT/XID for different conversation - treating as anonymous" - ); - req.p.xid = undefined; // Clear XID to treat as anonymous - req.p.uid = undefined; - req.p.pid = undefined; - } else if (!xidMatches && xidForCurrentConversation) { - // Case 3: Token for different conversation, but XID is for current - logger.debug( - "Case 3: XID participant voting with mismatched JWT but XID for current conversation - maintaining XID" - ); - req.p.uid = undefined; - req.p.pid = undefined; - // XID will be resolved below - } else { - // Case 4: Token for current conversation, but XID for different - logger.debug( - "Case 4: XID participant voting with JWT for current conversation but XID for different - treating as anonymous" - ); - req.p.xid = undefined; // Clear XID - // Keep uid/pid from JWT - } - } - } - - // Check for legacy cookie before creating new user - let legacyCookieToken: string | undefined; - let isLegacyCookieUser = false; - if (req.p.uid === undefined && !req.p.jwt_conversation_mismatch) { - // Get conversation_id for the legacy cookie check - const conversationId = await getZinvite(zid); - if (conversationId) { - const legacyResult = await checkLegacyCookieAndIssueJWT( - req, - zid, - conversationId as string, - req.p.xid - ); - if (legacyResult.uid !== undefined && legacyResult.pid !== undefined) { - req.p.uid = legacyResult.uid; - req.p.pid = legacyResult.pid; - pid = legacyResult.pid; - legacyCookieToken = legacyResult.token; - isLegacyCookieUser = true; - logger.info("Using existing participant from legacy cookie", { - uid: legacyResult.uid, - pid: legacyResult.pid, - }); - } - } - } - - // 1. Handle user identification and creation - const finalUid = await handleUserIdentification(req); - - // 2. Get or create participant - const { pid: participantId, isNewlyCreated } = await getOrCreateParticipant( - zid, - finalUid, - req.p.pid, - req - ); - pid = participantId; - - // 3. Submit the vote + // 1. Submit the vote - that's all we need to do now! const voteResult = await votesPost( - finalUid, + uid, pid, zid, - req.p.tid, - req.p.xid, - req.p.vote, - req.p.weight, - req.p.high_priority + tid, + xid, + vote, + weight, + high_priority ); - const vote = voteResult.vote; - const createdTimeMillis = safeTimestampToMillis(vote.created); + const voteRecord = voteResult.vote; + const createdTimeMillis = safeTimestampToMillis(voteRecord.created); - // 4. Async updates (don't wait for them) + // 2. Async updates (don't wait for them) setTimeout(() => { updateConversationModifiedTime(zid, createdTimeMillis); - updateLastInteractionTimeForConversation(zid, finalUid); + updateLastInteractionTimeForConversation(zid, uid); updateVoteCount(zid, pid); }, 100); - // 5. Handle star if present - if (!_.isUndefined(req.p.starred)) { - await addStar(zid, req.p.tid, pid, req.p.starred, createdTimeMillis); + // 3. Handle star if present + if (!_.isUndefined(starred)) { + await addStar(zid, tid, pid, starred, createdTimeMillis); } - // 6. Get next comment - const nextComment = await getNextComment(zid, pid, [], true, req.p.lang); + // 4. Get next comment + const nextComment = await getNextComment(zid, pid, [], lang); - // 7. Build result + // 5. Build result const result: PidReadyResult = {}; if (nextComment) { result.nextComment = nextComment; @@ -519,10 +241,9 @@ async function handle_POST_votes(req: VoteRequest, res: any) { addNoMoreCommentsRecord(zid, pid); } - // PID_FLOW This may be the first time the client gets the pid. result.currentPid = pid; - // 8. Handle moderation options + // 6. Handle moderation options if (result.shouldMod) { result.modOptions = {}; if (req.p.vote === polisTypes.reactions.pull) { @@ -540,29 +261,7 @@ async function handle_POST_votes(req: VoteRequest, res: any) { } } - // 9. Issue JWT if needed - let authResult; - if (isLegacyCookieUser && legacyCookieToken) { - // Use the JWT token from legacy cookie lookup - authResult = { - auth: { - token: legacyCookieToken, - token_type: "Bearer", - expires_in: 365 * 24 * 60 * 60, // 1 year - }, - }; - logger.debug("Using JWT from legacy cookie lookup"); - } else { - // Issue new JWT if needed - authResult = await issueJWTIfNeeded( - req, - finalUid, - pid, - zid, - isNewlyCreated - ); - } - Object.assign(result, authResult); + // 7. Auth token will be automatically included by attachAuthToken middleware finishOne(res, result); } catch (err) { @@ -595,9 +294,9 @@ async function handle_GET_votes_famous(req: { p: any }, res: any) { export { getVotesForSingleParticipant, - votesPost, handle_GET_votes_famous, handle_GET_votes_me, handle_GET_votes, handle_POST_votes, + votesPost, }; diff --git a/server/src/server-helpers.ts b/server/src/server-helpers.ts index 055fe10858..ef8d15bf88 100644 --- a/server/src/server-helpers.ts +++ b/server/src/server-helpers.ts @@ -1,7 +1,7 @@ import _ from "underscore"; import LruCache from "lru-cache"; -import { GetCommentsParams, UserInfo } from "./d"; +import { UserInfo } from "./d"; import { generateToken } from "./auth"; import { getBidsForPids } from "./routes/math"; import { getConversationHasMetadata } from "./routes/metadata"; @@ -18,11 +18,6 @@ import Config from "./config"; import { failJson } from "./utils/fail"; import logger from "./utils/logger"; import pg from "./db/pg-query"; -import { - getComments, - getNumberOfCommentsRemaining, - translateAndStoreComment, -} from "./comment"; // TODO consider "p2a24a2dadadu15" format const votesForZidPidCache = new LruCache({ @@ -472,51 +467,6 @@ function addStar( return pg.queryP(query, params); } -function getNextComment( - zid?: number, - pid?: number, - withoutTids?: any, - include_social?: boolean, - lang?: string -) { - logger.debug( - `getNextComment ${JSON.stringify({ - zid, - pid, - withoutTids, - include_social, - lang, - })}` - ); - return getNextPrioritizedComment(zid, pid, withoutTids, include_social).then( - (c: GetCommentsParams) => { - if (lang && c) { - const firstTwoCharsOfLang = lang.substr(0, 2); - return getCommentTranslations(zid, c.tid).then((translations: any) => { - c.translations = translations; - const hasMatch = _.some(translations, (t: { lang: string }) => { - return t.lang.startsWith(firstTwoCharsOfLang); - }); - if (!hasMatch) { - return translateAndStoreComment(zid, c.tid, c.txt, lang).then( - (translation: any) => { - if (translation) { - c.translations.push(translation); - } - return c; - } - ); - } - return c; - }); - } else if (c) { - c.translations = []; - } - return c; - } - ); -} - // NOTE: only call this in response to a vote. Don't call this from a poll, like /api/v3/nextComment function addNoMoreCommentsRecord(zid: number, pid: number) { return pg.queryP( @@ -587,69 +537,6 @@ function getVotesForZidPidsWithTimestampCheck( }); } -// This very much follows the outline of the random selection above, but factors out the probabilistic logic -// to the selectProbabilistically fn above. -function getNextPrioritizedComment( - zid: number, - pid: number, - withoutTids?: string | any[], - include_social?: any -): Promise { - logger.debug( - `getNextPrioritizedComment ${JSON.stringify({ - zid, - pid, - withoutTids, - include_social, - })}` - ); - const params: Partial = { - zid: zid, - not_voted_by_pid: pid, - include_social: include_social, - }; - if (!_.isUndefined(withoutTids) && withoutTids.length) { - params.withoutTids = withoutTids; - } - // What should we set timestamp to below in getPca? Is 0 ok? What triggers updates? - return Promise.all([ - getComments(params as GetCommentsParams), - getPca(zid, 0), - getNumberOfCommentsRemaining(zid, pid), - ]).then((results: any[]) => { - const comments = results[0]; - const math = results[1]; - const numberOfCommentsRemainingRows = results[2]; - - if (!comments || !comments.length) { - return null; - } else if ( - !numberOfCommentsRemainingRows || - !numberOfCommentsRemainingRows.length - ) { - throw new Error( - "polis_err_getNumberOfCommentsRemaining_" + zid + "_" + pid - ); - } - const commentPriorities = math - ? math.asPOJO["comment-priorities"] || {} - : {}; - const nTotal = Number(numberOfCommentsRemainingRows[0].total); - const nRemaining = Number(numberOfCommentsRemainingRows[0].remaining); - const c = selectProbabilistically(comments, commentPriorities); - c.remaining = nRemaining; - c.total = nTotal; - return c; - }); -} - -function getCommentTranslations(zid: number, tid: number): Promise { - return pg.queryP( - "select * from comment_translations where zid = ($1) and tid = ($2);", - [zid, tid] - ) as Promise; -} - function getVotesForZidPidWithTimestampCheck( zid: number, pid: number, @@ -741,40 +628,6 @@ function cacheVotesForZidPidWithTimestamp( votesForZidPidCache.set(key, val); } -function selectProbabilistically( - comments: any[], - priorities: { [key: string]: number } -): any { - // Here we go through all of the comments we might select for the user and add their priority values - const lookup = _.reduce( - comments, - ( - o: { lastCount: number; lookup: [number, any][] }, - comment: { tid: string | number } - ) => { - // If we like, we can use nTotal and nRemaining here to figure out how much we should emphasize the - // priority, potentially. Maybe we end up with different classes of priorities lists for this purpose? - // scaling this value in some way may also be helpful. - const lookup_val = o.lastCount + (priorities[comment.tid] || 1); - o.lookup.push([lookup_val, comment]); - o.lastCount = lookup_val; - return o; - }, - { lastCount: 0, lookup: [] } - ); - // We arrange a random number that should fall somewhere in the range of the lookup_vals - const randomN = Math.random() * lookup.lastCount; - // Return the first one that has a greater lookup; could eventually replace this with something smarter - // that does a bisectional lookup if performance becomes an issue. But I want to keep the implementation - // simple to reason about all other things being equal. - const result = _.find(lookup.lookup, (x: [number, any]) => x[0] > randomN); - const c = result?.[1]; - if (c) { - c.randomN = randomN; - } - return c; -} - function sendEmailByUid(uid?: any, subject?: string, body?: string | number) { return getUserInfoForUid2(uid).then(function (userInfo: UserInfo) { return sendTextEmail( @@ -901,7 +754,6 @@ export { finishArray, finishOne, generateSUZinvites, - getNextComment, getOneConversation, pullXInfoIntoSubObjects, safeTimestampToMillis, diff --git a/server/src/server.ts b/server/src/server.ts index a8f5bb8981..c150d25d84 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -15,26 +15,14 @@ import { failJson } from "./utils/fail"; import { fetchAndCacheLatestPcaData } from "./utils/pca"; import { getPidsForGid } from "./utils/participants"; import { fetchIndex, makeFileFetcher } from "./utils/file-fetcher"; -import { - addNoMoreCommentsRecord, - addStar, - browserSupportsPushState, - finishOne, - getNextComment, - pullXInfoIntoSubObjects, - safeTimestampToMillis, - updateConversationModifiedTime, - updateLastInteractionTimeForConversation, -} from "./server-helpers"; +import { browserSupportsPushState } from "./server-helpers"; import { DetectLanguageResult, - ParticipantCommentModerationResult, UserType, ExpressRequest, ExpressResponse, } from "./d"; -import { getConversationInfo } from "./conversation"; -import Comment from "./comment"; +import { detectLanguage } from "./comment"; import logger from "./utils/logger"; import { emailTeam, @@ -42,7 +30,6 @@ import { sendTextEmail, sendTextEmailWithBackup, } from "./email/senders"; -import { isDuplicateKey, isModerator, isPolisDev } from "./utils/common"; AWS.config.update({ region: Config.awsRegion }); const devMode = Config.isDevMode; @@ -83,8 +70,6 @@ function haltOnTimeout(req: { timedout: any }, res: any, next: () => void) { } function initializePolisHelpers() { - const detectLanguage = Comment.detectLanguage; - if (Config.backfillCommentLangDetection) { pg.queryP( "select tid, txt, zid from comments where lang is null;", @@ -231,9 +216,7 @@ function initializePolisHelpers() { }; }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: {}): any; new (): any }; @@ -296,9 +279,7 @@ function initializePolisHelpers() { res: { writeHead: (arg0: number) => void; json: (arg0: { status: number }) => void; - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: { codes: any }): void; new (): any }; @@ -358,9 +339,7 @@ function initializePolisHelpers() { function handle_POST_zinvites( req: { p: { short_url: any; zid: number; uid?: number } }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: { zinvite: string }): void; new (): any }; @@ -504,9 +483,7 @@ Email verified! You can close this tab or hit the back button. function handle_GET_dummyButton( req: { p: { button: string; uid: string } }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; end: { (): void; new (): any }; @@ -533,9 +510,7 @@ Email verified! You can close this tab or hit the back button. function handle_POST_tutorial( req: { p: { uid?: any; step: any } }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: {}): void; new (): any }; @@ -553,230 +528,6 @@ Email verified! You can close this tab or hit the back button. }); } - function handle_POST_ptptCommentMod( - req: { - p: { - zid: number; - pid: number; - uid?: number; - tid: number; - as_abusive: any; - as_factual: any; - as_feeling: any; - as_important: any; - as_notfact: any; - as_notgoodidea: any; - as_notmyfeeling: any; - as_offtopic: any; - as_spam: any; - unsure: any; - lang: string; - }; - }, - res: any - ) { - const zid = req.p.zid; - const pid = req.p.pid; - const uid = req.p.uid; - - return pg - .queryP( - "insert into crowd_mod (" + - "zid, " + - "pid, " + - "tid, " + - "as_abusive, " + - "as_factual, " + - "as_feeling, " + - "as_important, " + - "as_notfact, " + - "as_notgoodidea, " + - "as_notmyfeeling, " + - "as_offtopic, " + - "as_spam, " + - "as_unsure) values (" + - "$1, " + - "$2, " + - "$3, " + - "$4, " + - "$5, " + - "$6, " + - "$7, " + - "$8, " + - "$9, " + - "$10, " + - "$11, " + - "$12, " + - "$13);", - [ - req.p.zid, - req.p.pid, - req.p.tid, - req.p.as_abusive, - req.p.as_factual, - req.p.as_feeling, - req.p.as_important, - req.p.as_notfact, - req.p.as_notgoodidea, - req.p.as_notmyfeeling, - req.p.as_offtopic, - req.p.as_spam, - req.p.unsure, - ] - ) - .then((createdTime: any) => { - setTimeout(function () { - updateConversationModifiedTime(req.p.zid, createdTime); - updateLastInteractionTimeForConversation(zid, uid); - }, 100); - }) - .then(function () { - return getNextComment(req.p.zid, pid, [], true, req.p.lang); // TODO req.p.lang is probably not defined - }) - .then(function (nextComment: any) { - const result: ParticipantCommentModerationResult = {}; - if (nextComment) { - result.nextComment = nextComment; - } else { - // no need to wait for this to finish - addNoMoreCommentsRecord(req.p.zid, pid); - } - // PID_FLOW This may be the first time the client gets the pid. - result.currentPid = req.p.pid; - finishOne(res, result); - }) - .catch(function (err: string) { - if (err === "polis_err_ptptCommentMod_duplicate") { - failJson(res, 406, "polis_err_ptptCommentMod_duplicate", err); // TODO allow for changing votes? - } else if (err === "polis_err_conversation_is_closed") { - failJson(res, 403, "polis_err_conversation_is_closed", err); - } else { - failJson(res, 500, "polis_err_ptptCommentMod", err); - } - }); - } - - function handle_POST_upvotes( - req: { p: { uid?: number; zid: number } }, - res: { - status: ( - arg0: number - ) => { - (): any; - new (): any; - json: { (arg0: {}): void; new (): any }; - }; - } - ) { - const uid = req.p.uid; - const zid = req.p.zid; - - pg.queryP("select * from upvotes where uid = ($1) and zid = ($2);", [ - uid, - zid, - ]).then( - function (rows: string | any[]) { - if (rows && rows.length) { - failJson(res, 403, "polis_err_upvote_already_upvoted"); - } else { - pg.queryP("insert into upvotes (uid, zid) VALUES ($1, $2);", [ - uid, - zid, - ]).then( - function () { - pg.queryP( - "update conversations set upvotes = (select count(*) from upvotes where zid = ($1)) where zid = ($1);", - [zid] - ).then( - function () { - res.status(200).json({}); - }, - function (err: any) { - failJson(res, 500, "polis_err_upvote_update", err); - } - ); - }, - function (err: any) { - failJson(res, 500, "polis_err_upvote_insert", err); - } - ); - } - }, - function (err: any) { - failJson(res, 500, "polis_err_upvote_check", err); - } - ); - } - - function handle_POST_stars( - req: { p: { zid: number; tid: number; pid: number; starred: any } }, - res: { - status: ( - arg0: number - ) => { - (): any; - new (): any; - json: { (arg0: {}): void; new (): any }; - }; - } - ) { - addStar(req.p.zid, req.p.tid, req.p.pid, req.p.starred) - .then(function (result: { rows: { created: any }[] }) { - const createdTimeMillis = safeTimestampToMillis(result.rows[0].created); - setTimeout(function () { - updateConversationModifiedTime(req.p.zid, createdTimeMillis); - }, 100); - res.status(200).json({}); // TODO don't stop after the first one, map the inserts to deferreds. - }) - .catch(function (err: any) { - if (err) { - if (isDuplicateKey(err)) { - failJson(res, 406, "polis_err_vote_duplicate", err); // TODO allow for changing votes? - } else { - failJson(res, 500, "polis_err_vote", err); - } - } - }); - } - - function handle_POST_trashes( - req: { p: { pid: number; zid: number; tid: number; trashed: any } }, - res: { - status: ( - arg0: number - ) => { - (): any; - new (): any; - json: { (arg0: {}): void; new (): any }; - }; - } - ) { - const query = - "INSERT INTO trashes (pid, zid, tid, trashed, created) VALUES ($1, $2, $3, $4, default);"; - const params = [req.p.pid, req.p.zid, req.p.tid, req.p.trashed]; - pg.query( - query, - params, - function (err: any, result: { rows: { created: any }[] }) { - if (err) { - if (isDuplicateKey(err)) { - failJson(res, 406, "polis_err_vote_duplicate", err); // TODO allow for changing votes? - } else { - failJson(res, 500, "polis_err_vote", err); - } - return; - } - - const createdTimeMillis = safeTimestampToMillis(result.rows[0].created); - setTimeout(function () { - updateConversationModifiedTime(req.p.zid, createdTimeMillis); - }, 100); - - res.status(200).json({}); // TODO don't stop after the first one, map the inserts to deferreds. - } - ); - } - function handle_GET_contexts(req: ExpressRequest, res: ExpressResponse) { pg.queryP_readOnly( "select name from contexts where is_public = TRUE order by name;", @@ -901,9 +652,7 @@ Email verified! You can close this tab or hit the back button. }; }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: {}): void; new (): any }; @@ -942,54 +691,6 @@ Thanks for using Polis! }); } - function getSocialParticipantsForMod_timed( - zid?: number, - limit?: any, - mod?: any, - convOwner?: any - ) { - return getSocialParticipantsForMod - .apply(null, [zid, limit, mod, convOwner]) - .then(function (results: any) { - return results; - }); - } - - function getSocialParticipantsForMod( - zid: number, - limit: any, - mod: any, - owner: any - ) { - let modClause = ""; - const params = [zid, limit, owner]; - if (!_.isUndefined(mod)) { - modClause = " and mod = ($4)"; - params.push(mod); - } - - const q = - "with " + - "p as (select uid, pid, mod from participants where zid = ($1) " + - modClause + - "), " + // and vote_count >= 1 - "final_set as (select * from p limit ($2)), " + - "xids_subset as (select * from xids where owner = ($3) and x_profile_image_url is not null), " + - "all_rows as (select " + - // "final_set.priority, " + - "final_set.mod, " + - "xids_subset.x_profile_image_url as x_profile_image_url, " + - "xids_subset.xid as xid, " + - "xids_subset.x_name as x_name, " + - "final_set.pid " + - "from final_set " + - "left join xids_subset on final_set.uid = xids_subset.uid " + - ") " + - "select * from all_rows where (xid is not null) " + - ";"; - return pg.queryP(q, params); - } - function getLocationsForParticipants(zid: number) { return pg.queryP_readOnly( "select * from participant_locations where zid = ($1);", @@ -1000,9 +701,7 @@ Thanks for using Polis! function handle_GET_locations( req: { p: { zid: number; gid: any } }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: any): void; new (): any }; @@ -1033,97 +732,6 @@ Thanks for using Polis! failJson(res, 500, "polis_err_locations_01", err); }); } - function removeNullOrUndefinedProperties(o: { [x: string]: any }) { - for (const k in o) { - const v = o[k]; - if (v === null || v === undefined) { - delete o[k]; - } - } - return o; - } - - function handle_PUT_ptptois( - req: { p: { zid: number; uid?: number; pid: number; mod: any } }, - res: { - status: ( - arg0: number - ) => { - (): any; - new (): any; - json: { (arg0: {}): void; new (): any }; - }; - } - ) { - const zid = req.p.zid; - const uid = req.p.uid; - const pid = req.p.pid; - const mod = req.p.mod; - isModerator(zid, uid) - .then(function (isMod: any) { - if (!isMod) { - failJson(res, 403, "polis_err_ptptoi_permissions_123"); - return; - } - return pg - .queryP( - "update participants set mod = ($3) where zid = ($1) and pid = ($2);", - [zid, pid, mod] - ) - .then(function () { - res.status(200).json({}); - }); - }) - .catch(function (err: any) { - failJson(res, 500, "polis_err_ptptoi_misc_234", err); - }); - } - function handle_GET_ptptois( - req: { - p: { zid: number; mod: any; uid?: number; conversation_id: string }; - }, - res: { - status: ( - arg0: number - ) => { - (): any; - new (): any; - json: { (arg0: any): void; new (): any }; - }; - } - ) { - const zid = req.p.zid; - const mod = req.p.mod; - const uid = req.p.uid; - const limit = 99999; - - const convPromise = getConversationInfo(req.p.zid); - const socialPtptsPromise = convPromise.then((conv: { owner: any }) => { - return getSocialParticipantsForMod_timed(zid, limit, mod, conv.owner); - }); - - Promise.all([socialPtptsPromise, getConversationInfo(zid)]) - .then(function (a: any[]) { - let ptptois = a[0]; - const conv = a[1]; - const isOwner = uid === conv.owner; - const isAllowed = isOwner || isPolisDev(req.p.uid) || conv.is_data_open; - if (isAllowed) { - ptptois = ptptois.map(pullXInfoIntoSubObjects); - ptptois = ptptois.map(removeNullOrUndefinedProperties); - ptptois = ptptois.map(function (p: { conversation_id: any }) { - p.conversation_id = req.p.conversation_id; - return p; - }); - } else { - ptptois = []; - } - res.status(200).json(ptptois); - }) - .catch(function (err: any) { - failJson(res, 500, "polis_err_ptptoi_misc", err); - }); - } function doSendEinvite(req: any, email: any) { return generateTokenP(30, false).then(function (einvite: any) { @@ -1141,9 +749,7 @@ Thanks for using Polis! function handle_POST_einvites( req: { p: { email: any } }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: {}): void; new (): any }; @@ -1163,9 +769,7 @@ Thanks for using Polis! function handle_GET_einvites( req: { p: { einvite: any } }, res: { - status: ( - arg0: number - ) => { + status: (arg0: number) => { (): any; new (): any; json: { (arg0: any): void; new (): any }; @@ -1314,7 +918,6 @@ Thanks for using Polis! handle_GET_einvites, handle_GET_locations, handle_GET_perfStats, - handle_GET_ptptois, handle_GET_snapshot, handle_GET_testConnection, handle_GET_testDatabase, @@ -1324,15 +927,10 @@ Thanks for using Polis! handle_POST_contributors, handle_POST_einvites, handle_POST_metrics, - handle_POST_ptptCommentMod, handle_POST_sendCreatedLinkToEmail, handle_POST_sendEmailExportReady, - handle_POST_stars, - handle_POST_trashes, handle_POST_tutorial, - handle_POST_upvotes, handle_POST_zinvites, - handle_PUT_ptptois, }; return returnObject; } // End of initializePolisHelpers diff --git a/server/src/utils/parameter.ts b/server/src/utils/parameter.ts index 3a9ea60039..610e6b4f85 100644 --- a/server/src/utils/parameter.ts +++ b/server/src/utils/parameter.ts @@ -436,7 +436,6 @@ function resolve_pidThing( loggingString = ""; } return function (req: Req, res: any, next: (arg0?: string) => void) { - logger.debug("resolve_pidThing " + loggingString); if (!req.p) { failJson( res, @@ -454,9 +453,6 @@ function resolve_pidThing( // If we already have a valid PID from JWT, preserve it regardless of URL params if (hasValidJwtPid) { - logger.debug( - `resolve_pidThing: preserving JWT-provided ${pidThingStringName}=${jwtProvidedValue} for uid=${req.p.uid}` - ); next(); return; } @@ -470,14 +466,8 @@ function resolve_pidThing( }); if (pidNumber === -1 && req?.p?.zid && req.p.uid) { - logger.debug( - `resolve_pidThing: looking up ${pidThingStringName} for uid=${req.p.uid}, zid=${req.p.zid}` - ); getPidPromise(req.p.zid, req.p.uid) .then(function (pid: number) { - logger.debug( - `resolve_pidThing: found ${pidThingStringName}=${pid} for uid=${req.p.uid}` - ); if (pid >= 0) { assigner(req, pidThingStringName, pid); } diff --git a/server/src/utils/pca.ts b/server/src/utils/pca.ts index 2e2778bc95..7cd4fbc72e 100644 --- a/server/src/utils/pca.ts +++ b/server/src/utils/pca.ts @@ -108,7 +108,6 @@ export function fetchAndCacheLatestPcaData() { if (!rowsArray || !rowsArray.length) { // call again - logger.info("mathpoll done"); setTimeout(pollForLatestPcaData, waitTime()); return; } @@ -151,6 +150,173 @@ export function fetchAndCacheLatestPcaData() { pollForLatestPcaData(); } +/** + * Creates a minimal valid PCA structure for conversations with no votes. + * This allows reports to load and display properly even when there's no voting data. + */ +async function createEmptyPcaStructure( + zid: number +): Promise { + // Fetch comment IDs if they exist + let tids: number[] = []; + let nCmts = 0; + + try { + const commentsQuery = await pg.queryP_readOnly>( + "select tid from comments where zid = ($1) and mod >= 1 order by tid", + [zid] + ); + + if (commentsQuery && Array.isArray(commentsQuery)) { + tids = commentsQuery.map((row: { tid: number }) => row.tid); + nCmts = tids.length; + } + } catch (err) { + logger.error("Error fetching comments for empty PCA structure", err); + } + + return { + "group-clusters": [], + "base-clusters": { + x: [], + y: [], + id: [], + count: [], + members: [], + }, + "group-votes": {}, + "group-aware-consensus": {}, + "user-vote-counts": {}, + "in-conv": [], + "n-cmts": nCmts, + pca: { + comps: [[], []], + center: [0, 0], + "comment-extremity": tids.map(() => 0), // Initialize with zeros for each comment + "comment-projection": {}, + }, + tids: tids, + n: 0, + repness: {}, + consensus: { + agree: [], + disagree: [], + }, + "votes-base": {}, + lastModTimestamp: null, + lastVoteTimestamp: Date.now(), + "comment-priorities": {}, + math_tick: 0, + }; +} + +/** + * Ensures all required PCA fields exist by merging incomplete data with empty structure + * This prevents client failures when PCA data exists but is missing required fields + */ +async function ensureCompletePcaStructure( + zid: number, + existingData?: any +): Promise { + const emptyStructure = await createEmptyPcaStructure(zid); + + if (!existingData) { + return emptyStructure; + } + + // Merge existing data with empty structure, ensuring all required fields exist + const mergedData = { + ...emptyStructure, + ...existingData, + // Ensure nested objects are properly merged + pca: { + ...emptyStructure.pca, + ...existingData.pca, + }, + consensus: { + ...emptyStructure.consensus, + ...existingData.consensus, + }, + "base-clusters": { + ...emptyStructure["base-clusters"], + ...existingData["base-clusters"], + }, + }; + + // Ensure arrays exist even if they're empty + if (!Array.isArray(mergedData["group-clusters"])) { + mergedData["group-clusters"] = emptyStructure["group-clusters"]; + } + if (!Array.isArray(mergedData["in-conv"])) { + mergedData["in-conv"] = emptyStructure["in-conv"]; + } + if (!Array.isArray(mergedData.tids)) { + mergedData.tids = emptyStructure.tids; + } + if (!Array.isArray(mergedData["mod-in"])) { + mergedData["mod-in"] = emptyStructure["mod-in"] || []; + } + if (!Array.isArray(mergedData["mod-out"])) { + mergedData["mod-out"] = emptyStructure["mod-out"] || []; + } + if (!Array.isArray(mergedData["meta-tids"])) { + mergedData["meta-tids"] = emptyStructure["meta-tids"] || []; + } + + // Ensure objects exist even if they're empty + if ( + !mergedData["group-votes"] || + typeof mergedData["group-votes"] !== "object" + ) { + mergedData["group-votes"] = emptyStructure["group-votes"]; + } + if ( + !mergedData["group-aware-consensus"] || + typeof mergedData["group-aware-consensus"] !== "object" + ) { + mergedData["group-aware-consensus"] = + emptyStructure["group-aware-consensus"]; + } + if ( + !mergedData["user-vote-counts"] || + typeof mergedData["user-vote-counts"] !== "object" + ) { + mergedData["user-vote-counts"] = emptyStructure["user-vote-counts"]; + } + if (!mergedData.repness || typeof mergedData.repness !== "object") { + mergedData.repness = emptyStructure.repness; + } + if ( + !mergedData["votes-base"] || + typeof mergedData["votes-base"] !== "object" + ) { + mergedData["votes-base"] = emptyStructure["votes-base"]; + } + if ( + !mergedData["comment-priorities"] || + typeof mergedData["comment-priorities"] !== "object" + ) { + mergedData["comment-priorities"] = emptyStructure["comment-priorities"]; + } + + // Ensure required numeric fields exist + if (typeof mergedData.n !== "number") { + mergedData.n = emptyStructure.n; + } + if (typeof mergedData["n-cmts"] !== "number") { + mergedData["n-cmts"] = emptyStructure["n-cmts"]; + } + if (typeof mergedData.math_tick !== "number") { + mergedData.math_tick = existingData.math_tick || emptyStructure.math_tick; + } + if (typeof mergedData.lastVoteTimestamp !== "number") { + mergedData.lastVoteTimestamp = + existingData.lastVoteTimestamp || emptyStructure.lastVoteTimestamp; + } + + return mergedData; +} + export function getPca( zid?: number, math_tick?: number @@ -204,6 +370,20 @@ export function getPca( math_env: Config.mathEnv, } ); + + // If no PCA data exists and we're asking for the latest (math_tick -1 or undefined), + // return an empty structure instead of undefined to prevent report failures + if (math_tick === -1 || math_tick === undefined) { + logger.info( + "No PCA data found, returning empty structure for zid:", + zid + ); + return ensureCompletePcaStructure(zid).then((completeData) => { + const dataWithZid = { ...completeData, zid: zid }; + return updatePcaCache(zid, dataWithZid); + }); + } + return undefined; } const item = rowsArray[0].data; @@ -226,7 +406,11 @@ export function getPca( processMathObject(item); - return updatePcaCache(zid, item); + // Ensure all required fields exist by merging with empty structure if needed + return ensureCompletePcaStructure(zid, item).then((completeData) => { + const dataWithZid = { ...completeData, zid: zid }; + return updatePcaCache(zid, dataWithZid); + }); }); } diff --git a/test.env b/test.env index 003e25f7f3..10ec552c2f 100644 --- a/test.env +++ b/test.env @@ -49,13 +49,15 @@ POLIS_JWT_AUDIENCE=participants JWT_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ2U2d2JmSXNqKyswYlYKWDdidzY5MUplWUczNWRTaExSWlhSOWdlTmNtUmRVZEkxa3RJQzFxYXBIeUQwMFVsTFdCQmVpekVaZWlINVBoOApqaTJSUE5VcWNlbm5hYXE3S1dmRHd3cDBwK3lZVk83SjR4Sk1RN05UaVlOSzkrMHNHVUhjdVYyS3BSdXRSSE9PCmxraVFjTE9KZkt4ZzRRL0pBdDhiNEpzaTFoSHRDa0NvQXRLOFg1aWRxQVl0RWJlRFpSZlV0a2VHMzZ3bkxqdDUKcEw0VjJYdVdmYkxXc0RlTElqU2x2WFZaclk3YkVsZGl1SVkyQk40bHU2S3daU3FZb3ZHcGloSnNtUi9GekZZNwpTeStySlFwN3BrNERTV3dPViswWEZDcUU3dVh0Z3lQS0dkTlRBVkF5SEppbytneGp6dkdFYVVGVmNodnR5SnpXCjlkWHBvWXgxQWdNQkFBRUNnZ0VBSnlpNHo4RU5rQzh3M0NFeXZoa1F0U3M0dUt6c2dTN1FBMy9UbEZZaXJqSWMKOTZpK2NpbENFVHNZNTQ3bXlldHRmWlR5ZFE2N2hzajRKaEVPejJ3VEJ0dDlES1h3Q1NDQnppNUFaRkNBcjJTMgpWbzhha2lwWWhPd3Nxd3QrcU5Bc2Q0eEJyWWxUMFpVOWVGUTEyMG02STlYUjBDbUZMejRDNWlhcGM2TVdWYmMrClgvalU0M1ZvenAwekRObmxjTDhxcWlhNVB6YWV4dmRzRWluS2ZQYXlVNjVYNWhiNExldXhaMGpzQXo1NUZ1RnAKMG9oeHEvc05PYXFNRUQwNEd6MGJIQUFlcDZQU29ZQTRiM2ZUSjNJaFQrY1pIaDVPcnBwTjVvbjdXOG9CSS9ZSwpYd2djSlc0aEpPSjlaWjNTcHQ5bmtkWWpwcmMvYzdRSTRzZjN5a2lOZ1FLQmdRRFl4WW5iNWxoOXFVdlQrRnQ2ClZKNVR4NW4zdDVsc3FOTEY5c05IWklSQi9mUDlOeENiZnVRSjFpWkdBS0hFQnoxbVZnR0g2bi84Nk9MRFA3Y08KaC81eit6U0UwTGtZYTlCL2x6K0cwUjY2dXFnUC9Ca3U5OUVvMWNWWmFTYTNIc1JHYjRBcElsNXhYYm5ZWkZLSwpHMlR2aWlnWVdVWURVMzRqaENHN3l4bkFZUUtCZ1FDN3JValdyczNqemNKdTg0ODR2KzFQOFlGbFlzQkxyTHozClpYWDlkU2VFbW5JSVhiWWRuR3dXSDdnYkNjN2w1ZEQzcFNXandWNjRTNlFhdzF5VFRKL2pZMTFicFdFRDQvc2sKVnBZekVIRDhhdHhWY0VodXVTUHJTVUIweWJWZUFDdWZYcmJZb1A1VVcrRzliYzI1aUlFZERMSUpnU2pQMzI1bQo0K0ZNU2IwVWxRS0JnRUY3KzFkNDBJYTFtL2llOEZPV09IWXF1REtEUGsvNitDRmhtRUI2OXhGUWZHS2tQZG9iCmpSd0RtM0JPUkpBSmQ5endZbW9DeDBOSnZ5Y29tazVEZXZUaWtFMEhHTU50M29DTTJsNEowblNxYXBiWm0ybjYKNXk2azFZZGJJcHp6S0d2Mnh3cW1IVTIxK0t5TU1mc1NwMzFOaVN0QjNNcW9jRlNlVitwNVFvQkJBb0dBWWw0Swo1V3N0d3c3ZnNDTzhsN1FSQUd2R3RJc3oydllvL1VxU3Mwd3FjRmc3R0tOMWhzT3I2MVNvdkpOQmZFSWVhdzZHClVmUlNYeXBXVXlpK1p2NHNkNVJuSkZma0o1UGcyRVVCNkxYZllvM24rbWdscnRNZlBuMWdhVlluMTZNak9oNzgKbTBLV3kwUjFaMEljSi9paUk4VzVzejJkVkliNWFMaGNLQTRtS3BrQ2dZRUExYmVtdk9MNThrV1FRbURBZG9hSwp1ZU5INXI5Q2Q5My9kSTVJeDhFSFB1UmwrQlAyemo0Z2IzWER2eUE1RHZUQlA5YStPVVJBQzNvM3BYZGlPRktvCmE2anVON2RnaEtwdUU1ZWdmbW9iNEhoNzNubnRDaHZiOVNYVGp3S1B6dHQ2R051OFJ4VUI5UW9Uc3lhY2ZiVzcKaU1kUWFmc1Vlc2tseEI3bGVnMnorWkU9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K JWT_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFudXNHM3lMSS92dEcxVisyOE92ZApTWG1CdCtYVW9TMFdWMGZZSGpYSmtYVkhTTlpMU0F0YW1xUjhnOU5GSlMxZ1FYb3N4R1hvaCtUNGZJNHRrVHpWCktuSHA1Mm1xdXlsbnc4TUtkS2ZzbUZUdXllTVNURU96VTRtRFN2ZnRMQmxCM0xsZGlxVWJyVVJ6anBaSWtIQ3oKaVh5c1lPRVB5UUxmRytDYkl0WVI3UXBBcUFMU3ZGK1luYWdHTFJHM2cyVVgxTFpIaHQrc0p5NDdlYVMrRmRsNwpsbjJ5MXJBM2l5STBwYjExV2EyTzJ4SlhZcmlHTmdUZUpidWlzR1VxbUtMeHFZb1NiSmtmeGN4V08wc3ZxeVVLCmU2Wk9BMGxzRGxmdEZ4UXFoTzdsN1lNanloblRVd0ZRTWh5WXFQb01ZODd4aEdsQlZYSWI3Y2ljMXZYVjZhR00KZFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg== -# Delphi & Friends -LOCAL_SERVICES_DOCKER=true -AWS_ACCESS_KEY_ID=minioadmin -AWS_REGION=us-east-1 -AWS_S3_BUCKET_NAME=polis-delphi -AWS_S3_ENDPOINT=http://minio:9000 -AWS_SECRET_ACCESS_KEY=minioadmin +# DynamoDB DYNAMODB_ENDPOINT=http://host.docker.internal:8000 -OLLAMA_HOST=http://host.docker.internal:11434 -OLLAMA_MODEL=llama3.1:8b +AWS_ACCESS_KEY_ID=DUMMYIDEXAMPLE +AWS_SECRET_ACCESS_KEY=DUMMYEXAMPLEKEY +AWS_REGION=us-east-1 + +###### CLIENT PARTICIPATION ALPHA ###### +INTERNAL_SERVICE_URL=http://host.docker.internal:5000/api/v3 +PUBLIC_SERVICE_URL=http://localhost/api/v3 +# Decimal between 0.0 and 1.0. This determines how frequently to +# pull the next comment from selected topics, if available. +TOPICAL_COMMENT_RATIO=1.0