From 170b147abba2c94181e3703429a30cbdb6fd524f Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 6 Aug 2025 14:52:17 -0500 Subject: [PATCH 01/71] PROD DEPLOY 8/6/25 (#2116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add ignore, add analysis check. * gpu test * benchmark pngs * move math/python_conversion to delphi dir * setup docker for delphi * update path naming python_converstion -> delph * corrections to postgres schema * Add PostgreSQL integration tests for biodiversity dataset * Add AWS Lambda function for biodiversity dataset processing * Add optimized math functionality to delphi branch and fix PostgreSQL integration tests * Remove incorrectly added math/python_conversion files - all code should be in delphi/polismath * Cleanup Pakistan workflow and write to DynamoDB. * Add Pakistan test for current implementation. * Add requirements. * umap narrative * move docs. * nuke test gpu direction * add evoc to gitigonre a * merge req txt * delete other req txt * remove notebooks * unified create dynamo tables * add fastapi to reqs * add missing group votes initialization * error handinling in db connect * experimental dockerization * concise readme * HAS TO BE ROOT USER!!! * run_delphi talks to dynamo internally :) * all dependencies in req.txt, and c++ in dockerfile. * run command * let's run the umap pipeline directly, wait on orchestrator * dynamo config for docker * docker endpoint for dynamo config throughout, working * ignore python init * remove enhanced table that was deprecated * add curl to dockerfile * docker compose includes ollama * run delphi script checks for ollama avail, defaults to ll3.18b * ollama generalization vs remote llm, working locally * strip quotes * claude generating report narratives. * ensure datamapplot is in scope — docker fix * run visualization after other steps, copy result out to disk * info, not debug from boto * debug model provider * generate all topics for layer 0 * generate a static plot * ignore png and svg image output from static plots * Abstract docker-compose configuration. * Integrate PCA/K-Means/Rep. pipeline. * 702 color by consesus / divisive * add 702 to run pipeline * merge dynamo and docker take 1 * endpoint dynamo * create table job * little cli * poller * Implement persistent job queue with optimistic locking - Change job table schema to use job_id as primary key - Add optimistic locking with version field - Ensure jobs are never deleted, only updated with new statuses - Implement strong consistency for critical operations - Fix DynamoDB compatibility issues with empty strings * docs for system * deprecated, now unified in top level create tables * add job status cli command * cli checks status checks status * poller logging * rename tables * docs * delphi cdk 1 * docker delphi * add narrative to dockerfile * run delphi with db fix, remove unsued file * stubbing job hierarchy * update job logs claude context * example env for ollama * log missing anthropic key * Add automatic Ollama model setup and environment variable handling for jobs * python error * ignore node modeuls for node scripts in delphi * deprecate delphi compose * parametirize ollama model for topics * complete db rename * fix list convos in cli * remove default model * get rid of default model * service url claude for client report * try catch around conversation.py * hello world move delphi to report client * Load from DynamoDB. * commentsReport route * s3 and submit job from client * no viz error message * Load data properly from DynamoDB and check results are populated. * guard llm names * docs * success showing report in commentsReport * remove cleanup script * add uuid * node batch route * Add PCAPlot component with ASCII-style symbols for high-density retro visualization * don't pop browser * batch reports endpoint * batch reports endpoint * skip static, don't pop browser * don't pop browser * batch debugging * batch polling * only run layer 0 * batch * Improve batch processing with new job type architecture This commit introduces a new approach to batch processing jobs with clearer job types: 1. CREATE_NARRATIVE_BATCH: Jobs that create batches via 801_narrative_report_batch.py 2. AWAITING_NARRATIVE_BATCH: Jobs that check batch status via 803_check_batch_status.py The change fixes a critical issue where jobs were running the wrong script, and adds proper handling of DynamoDB reserved keywords like 'status'. This state machine design is now documented in docs/JOB_STATE_MACHINE_DESIGN.md. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * narrative batch * create user fix, reset db script, commentsreport list * Unified comment extremity calculation. Stored in DynamoDB. * run delphy python conversion * switch off sh to python * call python job poller * go to create narrative batch rather than narrative batch, explicit * commentsReport linter * tmp citations comment * copy py into docker * poller grab python * wrap json parse for narrative * update delphi for create_narrative_report batch * render all cited comments * make arch ready (#2014) * make arch ready * begin splitting up cdk code * organize cdk * cdk efs and permissions fixes * docker fixes, add block storage * remove default dynamo endpoint * endpoint check fix * use us-east-1 * remove more hardcoded values * adjust delphi dockerfile * fix up setupdynamo func * fix reference * add ssl mode hardcoded * another place to add secure mode * ssl fix * update iam roles * remove hardcoded minio from docker * remove s3 check * change s3 endpoint to none if missingf * more s3 config * fix indentation * s3 logic fix * indentation fix * remove env check * comment out s3 creds * remove location constraint * unique bucket name * remove public read on aws * fix comment * endpoint urls in s3 * remove another dynamo default * more endpoint config * lint and test cleanup * more lint fix * defaults fixes * tsc fix * attempt free up disk space * multi stage builds * more space optimization * more optimization * debug * remove commented out docker code, readme and makefile updates * build fix local vs CI * pass arg to ci * add missing comma * ssl mode fixes * modify defaults and change bucket name * side by side * Mark jobs as completed. * Add AWAITING_NARRATIVE_BATCH job type to delphi_cli.py and fix syntax error in job_poller.py * Improve nested log handling in job poller. * add report bundles to proxy static files... * s3 for delphi routes in node server * batches running * show batch time run job timestamp * runs timestamps * quiet batch check debug logging * fix 700 s3 upload config * hash delim * show timestamp of report in ui * logging * no-op, 600 not used, but changes to topic naming * topic naming logic and display * batch uses new topic naming * clean up topic name quotes * happy memorial day bug squash * no batch limit * llm model as param * concise logging * add topic report route * topicReport shows comments * export report embeddable * export report responsiveness * deps update * viz per layer * comments report gets dropdown * switch scan command for query command * fix sorting * we have two dropdowns we should unify later * begin add GSI for perf improvements (#2029) * begin add GSI for perf improvements * refactor to avoid scan * move config to outside of handler func * replace another table scan * clean up CLI dynamo * fix 801 * cleaner batch jobs * remove another table scan * viz job client logic --------- Co-authored-by: Colin Megill * actions fix * fix reports gsi query * removing zid from responses, client doesn't consume, running * update docs after checsk * viz zid * batch report rid * fix indentation issues * integrate job id * topicVizReport view * fix reportsByJobId * copy * get all layers, get consensus... * Colinmegill/node delphi (#2027) * batch debugging * batch polling * only run layer 0 * batch * Improve batch processing with new job type architecture This commit introduces a new approach to batch processing jobs with clearer job types: 1. CREATE_NARRATIVE_BATCH: Jobs that create batches via 801_narrative_report_batch.py 2. AWAITING_NARRATIVE_BATCH: Jobs that check batch status via 803_check_batch_status.py The change fixes a critical issue where jobs were running the wrong script, and adds proper handling of DynamoDB reserved keywords like 'status'. This state machine design is now documented in docs/JOB_STATE_MACHINE_DESIGN.md. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * narrative batch * create user fix, reset db script, commentsreport list * Unified comment extremity calculation. Stored in DynamoDB. * run delphy python conversion * switch off sh to python * call python job poller * go to create narrative batch rather than narrative batch, explicit * commentsReport linter * tmp citations comment * copy py into docker * poller grab python * wrap json parse for narrative * update delphi for create_narrative_report batch * render all cited comments * make arch ready (#2014) * make arch ready * begin splitting up cdk code * organize cdk * cdk efs and permissions fixes * docker fixes, add block storage * remove default dynamo endpoint * endpoint check fix * use us-east-1 * remove more hardcoded values * adjust delphi dockerfile * fix up setupdynamo func * fix reference * add ssl mode hardcoded * another place to add secure mode * ssl fix * update iam roles * remove hardcoded minio from docker * remove s3 check * change s3 endpoint to none if missingf * more s3 config * fix indentation * s3 logic fix * indentation fix * remove env check * comment out s3 creds * remove location constraint * unique bucket name * remove public read on aws * fix comment * endpoint urls in s3 * remove another dynamo default * more endpoint config * lint and test cleanup * more lint fix * defaults fixes * tsc fix * attempt free up disk space * multi stage builds * more space optimization * more optimization * debug * remove commented out docker code, readme and makefile updates * build fix local vs CI * pass arg to ci * add missing comma * ssl mode fixes * Mark jobs as completed. * Add AWAITING_NARRATIVE_BATCH job type to delphi_cli.py and fix syntax error in job_poller.py * modify defaults and change bucket name * side by side * Improve nested log handling in job poller. * add report bundles to proxy static files... * s3 for delphi routes in node server * batches running * show batch time run job timestamp * runs timestamps * quiet batch check debug logging * fix 700 s3 upload config * hash delim * show timestamp of report in ui * logging * no-op, 600 not used, but changes to topic naming * topic naming logic and display * batch uses new topic naming * clean up topic name quotes * happy memorial day bug squash * no batch limit * llm model as param * concise logging * add topic report route * topicReport shows comments * export report embeddable * export report responsiveness * remove .sh references * bump to 16 gigs default, and numba jit * remove shell script rferences from dockerfile * remove sh commands * bump python deps * run, and show, all visualizations in comments Report * claude code reference * notebooks * python tests against real data, compare with clojure * notebooks * vw analysis output * vw correlation plot * vw output * vw notebook * Revert "vw notebook" This reverts commit 7548529a6b4f4c15a77164e9691648bf8afe307d. * biodiversity notebook * pca validation notebook * pca validation notebook fix * add ignore, add analysis check. * gpu test * benchmark pngs * move math/python_conversion to delphi dir * setup docker for delphi * update path naming python_converstion -> delph * corrections to postgres schema * Add PostgreSQL integration tests for biodiversity dataset * Add AWS Lambda function for biodiversity dataset processing * Add optimized math functionality to delphi branch and fix PostgreSQL integration tests * Remove incorrectly added math/python_conversion files - all code should be in delphi/polismath * Cleanup Pakistan workflow and write to DynamoDB. * Add Pakistan test for current implementation. * Add requirements. * umap narrative * move docs. * nuke test gpu direction * add evoc to gitigonre a * merge req txt * delete other req txt * remove notebooks * unified create dynamo tables * add fastapi to reqs * add missing group votes initialization * error handinling in db connect * experimental dockerization * concise readme * HAS TO BE ROOT USER!!! * run_delphi talks to dynamo internally :) * all dependencies in req.txt, and c++ in dockerfile. * run command * let's run the umap pipeline directly, wait on orchestrator * dynamo config for docker * docker endpoint for dynamo config throughout, working * ignore python init * remove enhanced table that was deprecated * add curl to dockerfile * docker compose includes ollama * run delphi script checks for ollama avail, defaults to ll3.18b * ollama generalization vs remote llm, working locally * strip quotes * claude generating report narratives. * ensure datamapplot is in scope — docker fix * run visualization after other steps, copy result out to disk * info, not debug from boto * debug model provider * generate all topics for layer 0 * generate a static plot * ignore png and svg image output from static plots * Abstract docker-compose configuration. * Integrate PCA/K-Means/Rep. pipeline. * 702 color by consesus / divisive * add 702 to run pipeline * merge dynamo and docker take 1 * endpoint dynamo * create table job * little cli * poller * Implement persistent job queue with optimistic locking - Change job table schema to use job_id as primary key - Add optimistic locking with version field - Ensure jobs are never deleted, only updated with new statuses - Implement strong consistency for critical operations - Fix DynamoDB compatibility issues with empty strings * docs for system * deprecated, now unified in top level create tables * add job status cli command * cli checks status checks status * poller logging * rename tables * docs * delphi cdk 1 * docker delphi * add narrative to dockerfile * run delphi with db fix, remove unsued file * stubbing job hierarchy * update job logs claude context * example env for ollama * log missing anthropic key * Add automatic Ollama model setup and environment variable handling for jobs * python error * ignore node modeuls for node scripts in delphi * deprecate delphi compose * parametirize ollama model for topics * complete db rename * fix list convos in cli * remove default model * get rid of default model * service url claude for client report * try catch around conversation.py * hello world move delphi to report client * Load from DynamoDB. * commentsReport route * s3 and submit job from client * no viz error message * Load data properly from DynamoDB and check results are populated. * guard llm names * docs * success showing report in commentsReport * remove cleanup script * add uuid * node batch route * Add PCAPlot component with ASCII-style symbols for high-density retro visualization * don't pop browser * batch reports endpoint * batch reports endpoint * skip static, don't pop browser * don't pop browser * batch debugging * batch polling * only run layer 0 * batch * Improve batch processing with new job type architecture This commit introduces a new approach to batch processing jobs with clearer job types: 1. CREATE_NARRATIVE_BATCH: Jobs that create batches via 801_narrative_report_batch.py 2. AWAITING_NARRATIVE_BATCH: Jobs that check batch status via 803_check_batch_status.py The change fixes a critical issue where jobs were running the wrong script, and adds proper handling of DynamoDB reserved keywords like 'status'. This state machine design is now documented in docs/JOB_STATE_MACHINE_DESIGN.md. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * narrative batch * create user fix, reset db script, commentsreport list * Unified comment extremity calculation. Stored in DynamoDB. * run delphy python conversion * switch off sh to python * call python job poller * go to create narrative batch rather than narrative batch, explicit * commentsReport linter * tmp citations comment * copy py into docker * poller grab python * wrap json parse for narrative * update delphi for create_narrative_report batch * render all cited comments * make arch ready (#2014) * make arch ready * begin splitting up cdk code * organize cdk * cdk efs and permissions fixes * docker fixes, add block storage * remove default dynamo endpoint * endpoint check fix * use us-east-1 * remove more hardcoded values * adjust delphi dockerfile * fix up setupdynamo func * fix reference * add ssl mode hardcoded * another place to add secure mode * ssl fix * update iam roles * remove hardcoded minio from docker * remove s3 check * change s3 endpoint to none if missingf * more s3 config * fix indentation * s3 logic fix * indentation fix * remove env check * comment out s3 creds * remove location constraint * unique bucket name * remove public read on aws * fix comment * endpoint urls in s3 * remove another dynamo default * more endpoint config * lint and test cleanup * more lint fix * defaults fixes * tsc fix * attempt free up disk space * multi stage builds * more space optimization * more optimization * debug * remove commented out docker code, readme and makefile updates * build fix local vs CI * pass arg to ci * add missing comma * ssl mode fixes * modify defaults and change bucket name * side by side * Mark jobs as completed. * Add AWAITING_NARRATIVE_BATCH job type to delphi_cli.py and fix syntax error in job_poller.py * Improve nested log handling in job poller. * add report bundles to proxy static files... * s3 for delphi routes in node server * batches running * show batch time run job timestamp * runs timestamps * quiet batch check debug logging * fix 700 s3 upload config * hash delim * show timestamp of report in ui * logging * no-op, 600 not used, but changes to topic naming * topic naming logic and display * batch uses new topic naming * clean up topic name quotes * happy memorial day bug squash * no batch limit * llm model as param * concise logging * add topic report route * topicReport shows comments * export report embeddable * export report responsiveness * deps update * viz per layer * comments report gets dropdown * switch scan command for query command * fix sorting * we have two dropdowns we should unify later * begin add GSI for perf improvements (#2029) * begin add GSI for perf improvements * refactor to avoid scan * move config to outside of handler func * replace another table scan * clean up CLI dynamo * fix 801 * cleaner batch jobs * remove another table scan * viz job client logic --------- Co-authored-by: Colin Megill * actions fix * fix reports gsi query * removing zid from responses, client doesn't consume, running * update docs after checsk * viz zid * batch report rid * fix indentation issues * integrate job id * topicVizReport view * fix reportsByJobId * copy * attempt jest test fixes * add clear runner space to jest tests job * resolve port conflict from pg --------- Co-authored-by: Colin Megill Co-authored-by: Claude Co-authored-by: Christian Weilbach Co-authored-by: Bennie Rosas Co-authored-by: Christian Weilbach * reports rendering with new batch format * both commentsReport and topicsReport use both routes. * narrative filtering * componetize, and map jobs and sections * default seelct consensus * clean up logging * reset single convo * copywriting * match viz job id with topics job id * update cdk (#2037) * use same access key name, simplify boto3 connection (#2038) * use same access key name, simplify boto3 connection * remove duplicates * fix s3 url & py script (#2040) * bad creds fix (#2042) * bad creds fix * clean up env * remove infinite loop (#2044) * switch to on demand capacity for some tables (#2046) * switch to on demand capacity for some tables * job poll updates * poller updates final * fix invalid url bug (#2048) * claim job fix (#2050) * fix ability to claim locked jobs (#2052) * remove more bad dynamo urls (#2054) * fix date sorting logic (#2056) * poling simplification and lock cleaning (#2058) * prevent subprocess timeouts in prod (#2060) * increase timeout (#2062) * bulk test ignore * Te client side viz fixes (#2064) * find best match in key * add correct prefix * rss feed route, and topics * consensus feed rss prototype * better filtering client side (#2067) * fix key error (#2069) * upgrade delphi images (#2071) * Some delphi comments cleanup + JSON repairing (#2073) * remove unnecessary comments * json healing and comment removing * Te topic map narrative report (#2076) * begin add topicMapNarrativeReport * clean up page * Te delphi instance size config (#2077) * respect instance sizes * err fixes * make comments more responsive (#2080) * make comments more responsive * move rawdataexport to bottom * Te bulk statement upload (#2081) * begin basic bulk upload capability * save state * minor fixes * Te comment moderation 2.0 (#2084) * begin mod capability * update gemini api * fix xml output * lightening fast * clean up and fine tuning * don't analyze comment from mods * fix lint * test fix * try test fixing * add another mock * try to make mock global * try better mock path * move setup file * delete test for unused code path * try more debugging * attempt try catch * remove console log * add back deleted suite * fix instance size variable so large jobs are processed (#2087) * Te job poller size fix (#2089) * fix instance size variable so large jobs are processed * add to docker * Te job poller size fix (#2091) * fix instance size variable so large jobs are processed * add to docker * update install script * Te job poller size fix (#2093) * fix instance size variable so large jobs are processed * add to docker * update install script * replace all instances of old var * Full OIDC + JWT Integration (#2024) Full Integration of OIDC authentication system. Deprecation of legacy cookie-based auth. Upgrade client-admin app. OIDC Simulator for dev/test environments. * add missing actions vars * Fix comment filtering bug (#2111) * change from tmp (#2112) * add reset script * update email sending * revert changes but with more explicit error handling --------- Co-authored-by: Colin Megill Co-authored-by: Bennie Rosas Co-authored-by: Christian Weilbach Co-authored-by: Christian Weilbach Co-authored-by: Claude --- delphi/run_delphi.py | 37 +++- delphi/scripts/job_poller.py | 7 + delphi/umap_narrative/reset_conversation.py | 231 ++++++++++++++++++++ server/src/email/senders.ts | 132 +++++++---- 4 files changed, 358 insertions(+), 49 deletions(-) create mode 100644 delphi/umap_narrative/reset_conversation.py diff --git a/delphi/run_delphi.py b/delphi/run_delphi.py index 798a3dd388..aab132808a 100644 --- a/delphi/run_delphi.py +++ b/delphi/run_delphi.py @@ -19,6 +19,7 @@ def show_usage(): print(" --zid=CONVERSATION_ID The Polis conversation ID to process") print() print("Optional arguments:") + print(" --rid=REPORT_ID (Optional) The report ID for full narrative cleanup") print(" --verbose Show detailed logs") print(" --force Force reprocessing even if data exists") print(" --validate Run extra validation checks") @@ -27,6 +28,7 @@ def show_usage(): def main(): parser = argparse.ArgumentParser(description="Process a Polis conversation with the Delphi analytics pipeline.", add_help=False) parser.add_argument("--zid", required=True, help="The Polis conversation ID to process") + parser.add_argument("--rid", required=False, help="The report ID, if available, for full narrative cleanup.") parser.add_argument("--verbose", action="store_true", help="Show detailed logs") parser.add_argument("--force", action="store_true", help="Force reprocessing even if data exists") parser.add_argument("--validate", action="store_true", help="Run extra validation checks") @@ -39,11 +41,29 @@ def main(): sys.exit(0) zid = args.zid + rid = args.rid verbose_arg = "--verbose" if args.verbose else "" force_arg = "--force" if args.force else "" # validate_arg is not used in the python script execution steps, but kept for parity with bash # validate_arg = "--validate" if args.validate else "" + # --- Reset all data before processing --- + print(f"{YELLOW}Resetting all existing data for conversation {zid} before processing...{NC}") + reset_command = [ + "python", + "umap_narrative/reset_conversation.py", + f"--zid={zid}", + ] + # If a report ID is provided, pass it to the reset script for full cleanup + if rid: + reset_command.append(f"--rid={rid}") + print(f"{YELLOW}Using report ID {rid} for full narrative report cleanup.{NC}") + + reset_process = subprocess.run(reset_command) + if reset_process.returncode != 0: + print(f"{RED}Data reset failed with exit code {reset_process.returncode}. Aborting pipeline.{NC}") + sys.exit(reset_process.returncode) + print(f"{GREEN}Data reset complete.{NC}") print(f"{GREEN}Processing conversation {zid}...{NC}") @@ -58,8 +78,6 @@ def main(): os.environ["PYTHONPATH"] = f"/app:{os.environ.get('PYTHONPATH', '')}" os.environ["OLLAMA_HOST"] = os.environ.get("OLLAMA_HOST", "http://ollama:11434") # OLLAMA_MODEL is already set and checked - os.environ["DYNAMODB_ENDPOINT"] = os.environ.get("DYNAMODB_ENDPOINT", None) - max_votes = os.environ.get("MAX_VOTES") max_votes_arg = f"--max-votes={max_votes}" if max_votes else "" if max_votes: @@ -138,9 +156,16 @@ def main(): raw_endpoint = os.environ.get('DYNAMODB_ENDPOINT') endpoint_url = raw_endpoint if raw_endpoint and raw_endpoint.strip() else None - dynamodb = boto3.resource('dynamodb', - endpoint_url=endpoint_url, - region_name='us-east-1') + # Using dummy credentials for local, IAM role for AWS + if endpoint_url: + dynamodb = boto3.resource('dynamodb', + endpoint_url=endpoint_url, + region_name='us-east-1', + aws_access_key_id='dummy', + aws_secret_access_key='dummy') + else: + dynamodb = boto3.resource('dynamodb', region_name='us-east-1') + table = dynamodb.Table('Delphi_CommentHierarchicalClusterAssignments') @@ -223,4 +248,4 @@ def main(): sys.exit(exit_code) if __name__ == "__main__": - main() \ No newline at end of file + main() \ No newline at end of file diff --git a/delphi/scripts/job_poller.py b/delphi/scripts/job_poller.py index 8523324242..4dd847fd08 100755 --- a/delphi/scripts/job_poller.py +++ b/delphi/scripts/job_poller.py @@ -682,7 +682,14 @@ def process_job(self, job): cmd_job_id = job.get('batch_job_id', job_id) cmd = ['python', '/app/umap_narrative/803_check_batch_status.py', f'--job-id={cmd_job_id}'] else: # FULL_PIPELINE + # Base command cmd = ['python', '/app/run_delphi.py', f'--zid={conversation_id}'] + # Check for report_id and append if it exists + report_id = job.get('report_id') + if report_id: + cmd.append(f'--rid={report_id}') + self.update_job_logs(job, {'level': 'INFO', 'message': f"Passing report_id {report_id} to run_delphi.py"}) + # 2. Execute the command and stream logs to prevent deadlocks self.update_job_logs(job, {'level': 'INFO', 'message': f'Executing command: {" ".join(cmd)}'}) diff --git a/delphi/umap_narrative/reset_conversation.py b/delphi/umap_narrative/reset_conversation.py new file mode 100644 index 0000000000..21823c88be --- /dev/null +++ b/delphi/umap_narrative/reset_conversation.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Reset/delete all Delphi data for a specific conversation. +This script is environment-aware and works for both local (Docker/MinIO) +and live AWS environments. +""" + +import os +import argparse +import logging +import boto3 +from boto3.dynamodb.conditions import Key, Attr + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def get_boto_resource(service_name: str): + """ + Creates a boto3 resource, automatically using the correct endpoint + and credentials for local vs. AWS environments. + """ + resource_args = {'region_name': os.environ.get('AWS_REGION', 'us-east-1')} + endpoint_url = None + + if service_name == 's3': + endpoint_url = os.environ.get('AWS_S3_ENDPOINT') + elif service_name == 'dynamodb': + endpoint_url = os.environ.get('DYNAMODB_ENDPOINT') + + if endpoint_url: + logger.info(f"Local environment detected. Connecting {service_name} to endpoint: {endpoint_url}") + resource_args['endpoint_url'] = endpoint_url + resource_args['aws_access_key_id'] = os.environ.get('AWS_ACCESS_KEY_ID') + resource_args['aws_secret_access_key'] = os.environ.get('AWS_SECRET_ACCESS_KEY') + else: + logger.info(f"AWS environment detected for {service_name}. Using IAM role credentials.") + + return boto3.resource(service_name, **resource_args) + + +def delete_dynamodb_data(conversation_id: str, report_id: str = None): + """ + Deletes all data from DynamoDB tables for a given conversation_id. + This function handles multiple key structures and uses efficient batch deletion. + """ + dynamodb = get_boto_resource('dynamodb') + total_deleted_count = 0 + + def batch_delete_items(table, items, primary_keys): + """Helper to perform batch deletion and handle errors.""" + if not items: + return 0 + try: + with table.batch_writer() as batch: + for item in items: + key_to_delete = {pk: item[pk] for pk in primary_keys} + batch.delete_item(Key=key_to_delete) + logger.info(f" ✓ {table.name}: {len(items)} items deleted.") + return len(items) + except Exception as e: + logger.error(f" ✗ {table.name}: Batch delete failed - {e}") + return 0 + + logger.info(f"\nDeleting DynamoDB data for conversation {conversation_id}...") + + single_key_tables = { + 'Delphi_PCAConversationConfig': 'zid', + 'Delphi_UMAPConversationConfig': 'conversation_id', + } + for table_name, key_name in single_key_tables.items(): + try: + table = dynamodb.Table(table_name) + table.delete_item(Key={key_name: conversation_id}) + logger.info(f" ✓ {table_name}: 1 item deleted.") + total_deleted_count += 1 + except Exception as e: + if 'ResourceNotFoundException' in str(e): continue + if 'ConditionalCheckFailedException' in str(e): continue # Item didn't exist + logger.error(f" ✗ {table_name}: Error - {e}") + + query_tables = { + 'Delphi_CommentEmbeddings': ['conversation_id', 'comment_id'], + 'Delphi_CommentHierarchicalClusterAssignments': ['conversation_id', 'comment_id'], + 'Delphi_CommentClustersStructureKeywords': ['conversation_id', 'topic_key'], + 'Delphi_CommentClustersFeatures': ['conversation_id', 'topic_key'], + 'Delphi_CommentClustersLLMTopicNames': ['conversation_id', 'topic_key'], + 'Delphi_UMAPGraph': ['conversation_id', 'edge_id'], + 'Delphi_CommentExtremity': ['conversation_id', 'comment_id'], + } + for table_name, keys in query_tables.items(): + try: + table = dynamodb.Table(table_name) + response = table.query(KeyConditionExpression=Key(keys[0]).eq(conversation_id)) + items = response.get('Items', []) + while 'LastEvaluatedKey' in response: + response = table.query(KeyConditionExpression=Key(keys[0]).eq(conversation_id), ExclusiveStartKey=response['LastEvaluatedKey']) + items.extend(response.get('Items', [])) + total_deleted_count += batch_delete_items(table, items, keys) + except Exception as e: + if 'ResourceNotFoundException' in str(e): continue + logger.error(f" ✗ {table_name}: Query failed - {e}") + + prefix_scan_tables = { + 'Delphi_CommentRouting': {'keys': ['zid_tick', 'comment_id'], 'prefix': f'{conversation_id}:'}, + 'Delphi_PCAResults': {'keys': ['zid', 'math_tick'], 'prefix': conversation_id}, + 'Delphi_KMeansClusters': {'keys': ['zid_tick', 'group_id'], 'prefix': f'{conversation_id}:'}, + 'Delphi_RepresentativeComments': {'keys': ['zid_tick_gid', 'comment_id'], 'prefix': f'{conversation_id}:'}, + 'Delphi_PCAParticipantProjections': {'keys': ['zid_tick', 'participant_id'], 'prefix': f'{conversation_id}:'}, + } + for table_name, config in prefix_scan_tables.items(): + try: + table = dynamodb.Table(table_name) + scan_kwargs = {'FilterExpression': Key(config['keys'][0]).begins_with(config['prefix'])} + 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, config['keys']) + except Exception as e: + if 'ResourceNotFoundException' in str(e): continue + logger.error(f" ✗ {table_name}: Scan failed - {e}") + + if report_id: + try: + table = dynamodb.Table('Delphi_NarrativeReports') + scan_kwargs = {'FilterExpression': Key('rid_section_model').begins_with(report_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, ['rid_section_model', 'timestamp']) + except Exception as e: + if 'ResourceNotFoundException' not in str(e): + logger.error(f" ✗ Delphi_NarrativeReports: Scan failed - {e}") + + try: + table = dynamodb.Table('Delphi_JobQueue') + scan_kwargs = {'FilterExpression': Attr('job_params').contains(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, ['job_id']) + except Exception as e: + if 'ResourceNotFoundException' not in str(e): + logger.error(f" ✗ Delphi_JobQueue: Scan failed - {e}") + + return total_deleted_count + +def delete_s3_data(bucket_name: str, report_id: str): + """ + Deletes all visualization files from S3/MinIO for a given report_id. + """ + if not report_id: + logger.info("\nNo report_id (--rid) provided. Skipping S3/MinIO cleanup.") + return 0 + + s3 = get_boto_resource('s3') + bucket = s3.Bucket(bucket_name) + prefix = f'visualizations/{report_id}/' + + logger.info(f"\nDeleting S3/MinIO data for report {report_id} from bucket '{bucket_name}'...") + logger.info(f" - Looking for objects with prefix: {prefix}") + + try: + objects_to_delete = [{'Key': obj.key} for obj in bucket.objects.filter(Prefix=prefix)] + + if not objects_to_delete: + logger.info(" No visualization files found to delete.") + return 0 + + logger.info(f" Found {len(objects_to_delete)} files to delete.") + response = bucket.delete_objects(Delete={'Objects': objects_to_delete}) + deleted_count = len(response.get('Deleted', [])) + + if errors := response.get('Errors', []): + logger.error(f" ✗ Encountered {len(errors)} errors during S3 deletion.") + for error in errors: logger.error(f" - Key: {error['Key']}, Code: {error['Code']}") + if deleted_count > 0: + logger.info(f" ✓ Successfully deleted {deleted_count} files.") + + return deleted_count + + except Exception as e: + logger.error(f" ✗ An error occurred accessing S3/MinIO: {e}") + return 0 + +def main(zid: str, rid: str = None): + """ + Main function to coordinate the deletion process. + """ + zid_str = str(zid) + logger.info(f"\n🗑️ Starting reset for conversation zid='{zid_str}'" + (f" and report rid='{rid}'" if rid else "")) + print("=" * 60) + + dynamo_deleted_count = delete_dynamodb_data(zid_str, rid) + + s3_bucket = os.environ.get("AWS_S3_BUCKET_NAME", "polis-delphi") + s3_deleted_count = delete_s3_data(s3_bucket, rid) + + print("=" * 60) + logger.info("✅ Reset complete!\n") + logger.info(f"DynamoDB: Deleted a total of {dynamo_deleted_count} items across all tables.") + logger.info(f"S3/MinIO: Deleted a total of {s3_deleted_count} visualization files.") + + logger.info("\nThe conversation is ready for a fresh Delphi run.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Reset Delphi data for a conversation.") + parser.add_argument( + '--zid', + type=int, + required=True, + help="The numeric conversation ID (e.g., 19548). Used for all DynamoDB and S3 cleanup." + ) + parser.add_argument( + '--rid', + type=str, + required=False, + help="The report ID (e.g., r4tykwac8thvzv35jrn53). Only needed for cleaning the Delphi_NarrativeReports table." + ) + + args = parser.parse_args() + + main(zid=args.zid, rid=args.rid) \ No newline at end of file diff --git a/server/src/email/senders.ts b/server/src/email/senders.ts index 61159d4f3c..af2a57fcd5 100644 --- a/server/src/email/senders.ts +++ b/server/src/email/senders.ts @@ -13,8 +13,6 @@ import nodemailer from "nodemailer"; import Config from "../config"; import logger from "../utils/logger"; -// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-region.html -// v2 docs, since we use v2 in our package.json: "aws:sdk": "2.78.0" AWS.config.update({ region: Config.awsRegion }); function sendTextEmailWithBackup( @@ -34,7 +32,6 @@ function sendTextEmailWithBackup( } function isDocker() { - // See: https://stackoverflow.com/a/25518345/504018 return fs.existsSync("/.dockerenv"); } @@ -44,7 +41,6 @@ function getMailOptions(transportType: any) { switch (transportType) { case "maildev": return { - // Allows running outside docker, connecting to exposed port of maildev container. host: isDocker() ? "maildev" : "localhost", port: 1025, ignoreTLS: true, @@ -52,9 +48,6 @@ function getMailOptions(transportType: any) { case "mailgun": mailgunAuth = { auth: { - // This forces fake credentials if envvars unset, so error is caught - // in auth and failover works without crashing server process. - // TODO: Suppress error thrown by mailgun library when unset. api_key: Config.mailgunApiKey || "unset-value", domain: Config.mailgunDomain || "unset-value", }, @@ -62,12 +55,12 @@ function getMailOptions(transportType: any) { return mg(mailgunAuth); case "aws-ses": return { - // reads AWS_REGION, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from process.env - // reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from process.env SES: new AWS.SES({ apiVersion: "2010-12-01" }), }; default: - return {}; + throw new Error( + `Unknown or undefined email transport type: ${transportType}` + ); } } @@ -78,41 +71,76 @@ function sendTextEmail( text: any, transportTypes = Config.emailTransportTypes, priority = 1 -) { - // Exit if empty string passed. - if (!transportTypes) { - return; +): Promise { + if (!transportTypes || transportTypes.length === 0) { + // Base case for recursion: If all transports have been tried and failed, + // create and throw a final, definitive error. + const finalError = new Error( + `All email transports failed for recipient: ${recipient}` + ); + (finalError as any).code = "E_ALL_TRANSPORTS_FAILED"; + return Promise.reject(finalError); } const transportTypesArray = transportTypes.split(","); - // Shift first index and clone to rename. const thisTransportType = transportTypesArray.shift(); const nextTransportTypes = [...transportTypesArray]; - const mailOptions = getMailOptions(thisTransportType); - const transporter = nodemailer.createTransport(mailOptions); - - let promise: any = transporter - .sendMail({ from: sender, to: recipient, subject: subject, text: text }) - .catch(function (err: any) { - logger.error( - "polis_err_email_sender_failed_transport_priority_" + - priority.toString(), - err - ); - logger.error( - `Unable to send email via priority ${priority.toString()} transport '${thisTransportType}' to: ${recipient}`, - err - ); - return sendTextEmail( - sender, - recipient, - subject, - text, - nextTransportTypes.join(","), - priority + 1 - ); - }); - return promise; + + try { + const mailOptions = getMailOptions(thisTransportType); + const transporter = nodemailer.createTransport(mailOptions); + + return transporter + .sendMail({ from: sender, to: recipient, subject: subject, text: text }) + .catch(function (originalError: any) { + const errorContext = { + message: `Email transport failed at priority ${priority}.`, + details: `Transport type '${thisTransportType}' failed for recipient '${recipient}'. Attempting failover.`, + priority, + transport: thisTransportType, + recipient, + timestamp: new Date().toISOString(), + cause: { + name: originalError.name, + message: originalError.message, + stack: originalError.stack, + code: originalError.code, + }, + }; + + logger.error("polis_err_email_transport_failure", errorContext); + + return sendTextEmail( + sender, + recipient, + subject, + text, + nextTransportTypes.join(","), + priority + 1 + ); + }); + } catch (initializationError: any) { + const errorContext = { + message: `Failed to initialize email transporter at priority ${priority}.`, + transport: thisTransportType, + timestamp: new Date().toISOString(), + cause: { + name: initializationError.name, + message: initializationError.message, + stack: initializationError.stack, + }, + }; + logger.error("polis_err_email_init_failure", errorContext); + + return sendTextEmail( + sender, + recipient, + subject, + text, + nextTransportTypes.join(","), + priority + 1 + ); + } } function sendMultipleTextEmails( @@ -125,8 +153,18 @@ function sendMultipleTextEmails( return Promise.all( recipientArray.map(function (email: string) { const promise = sendTextEmail(sender, email, subject, text); - promise.catch(function (err: any) { - logger.error("polis_err_failed_to_email_for_user", { email, err }); + promise.catch(function (finalError: any) { + logger.error("polis_err_failed_to_email_user_definitively", { + message: `Could not send email to user '${email}' after trying all available transports.`, + recipient: email, + timestamp: new Date().toISOString(), + finalError: { + name: finalError.name, + message: finalError.message, + stack: finalError.stack, + code: finalError.code, + }, + }); }); return promise; }) @@ -142,7 +180,15 @@ function emailTeam(subject: string, body: string) { subject, body ).catch(function (err: any) { - logger.error("polis_err_failed_to_email_team", err); + logger.error("polis_err_uncaught_in_email_team", { + message: "An unexpected error occurred in the emailTeam function.", + timestamp: new Date().toISOString(), + cause: { + name: err.name, + message: err.message, + stack: err.stack, + }, + }); }); } From 55286fd17afaf9a9d85f91e9787a69135adf68de Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sat, 26 Jul 2025 10:06:57 -0700 Subject: [PATCH 02/71] clean up delphi root. --- delphi/config.yaml | 30 ---- delphi/create_topic_agenda_table.py | 110 ------------ delphi/demo_script.py | 88 ---------- delphi/docker-compose.yml.bak | 100 ----------- delphi/{ => docs}/BATCH_API_BUGFIX.md | 0 delphi/{ => docs}/DELPHI_DOCKER.md | 0 delphi/{ => docs}/DOCKER.md | 0 delphi/final_demo.py | 111 ------------- delphi/polis-delphi-dev.sh | 50 ------ delphi/run_delphi.sh | 157 ------------------ delphi/{ => scripts}/reset_database.sh | 0 delphi/{ => scripts}/reset_processing_jobs.py | 0 delphi/simple_demo.py | 79 --------- delphi/start_poller.sh | 34 ---- delphi/{ => tests}/compare_with_clojure.py | 0 .../{ => tests}/direct_conversation_test.py | 0 delphi/{ => tests}/direct_pca_test.py | 0 delphi/{ => tests}/direct_repness_test.py | 0 delphi/{ => tests}/full_pipeline_test.py | 0 delphi/{ => tests}/run_system_test.py | 0 delphi/{ => tests}/run_tests.py | 0 delphi/{ => tests}/simplified_repness_test.py | 0 delphi/{ => tests}/simplified_test.py | 0 delphi/{ => tests}/test_batch_id.py | 0 delphi/{ => tests}/test_minio_access.py | 0 25 files changed, 759 deletions(-) delete mode 100644 delphi/config.yaml delete mode 100755 delphi/create_topic_agenda_table.py delete mode 100644 delphi/demo_script.py delete mode 100644 delphi/docker-compose.yml.bak rename delphi/{ => docs}/BATCH_API_BUGFIX.md (100%) rename delphi/{ => docs}/DELPHI_DOCKER.md (100%) rename delphi/{ => docs}/DOCKER.md (100%) delete mode 100644 delphi/final_demo.py delete mode 100755 delphi/polis-delphi-dev.sh delete mode 100755 delphi/run_delphi.sh rename delphi/{ => scripts}/reset_database.sh (100%) rename delphi/{ => scripts}/reset_processing_jobs.py (100%) delete mode 100644 delphi/simple_demo.py delete mode 100755 delphi/start_poller.sh rename delphi/{ => tests}/compare_with_clojure.py (100%) rename delphi/{ => tests}/direct_conversation_test.py (100%) rename delphi/{ => tests}/direct_pca_test.py (100%) rename delphi/{ => tests}/direct_repness_test.py (100%) rename delphi/{ => tests}/full_pipeline_test.py (100%) rename delphi/{ => tests}/run_system_test.py (100%) rename delphi/{ => tests}/run_tests.py (100%) rename delphi/{ => tests}/simplified_repness_test.py (100%) rename delphi/{ => tests}/simplified_test.py (100%) rename delphi/{ => tests}/test_batch_id.py (100%) rename delphi/{ => tests}/test_minio_access.py (100%) 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_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/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/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/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/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/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/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/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 From aaad1d259aaad7616e96d5f084884fa1f767b142 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sat, 26 Jul 2025 19:00:47 -0700 Subject: [PATCH 03/71] repair toipcMapNarrativeReport import --- client-report/src/components/app.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index 5768303a5e..1af736231b 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -31,6 +31,7 @@ import TopicPrioritize from "./topicPrioritize/TopicPrioritize.jsx"; import TopicPrioritizeSimple from "./topicPrioritizeSimple/TopicPrioritizeSimple.jsx"; import TopicAgenda from "./topicAgenda/TopicAgenda.jsx"; import TopicHierarchy from "./topicHierarchy/TopicHierarchy.jsx"; +import TopicMapNarrativeReport from "./topicMapNarrativeReport.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" From 1ccb8673d787986a4f7c959b08da8341028f1085 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sat, 26 Jul 2025 21:40:45 -0700 Subject: [PATCH 04/71] topic stats rendering --- client-report/src/components/app.jsx | 15 +- .../src/components/framework/useReportId.js | 4 +- .../src/components/topicStats/TopicStats.jsx | 200 +++++++++ server/app.ts | 20 + server/src/routes/topicStats.ts | 407 ++++++++++++++++++ 5 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 client-report/src/components/topicStats/TopicStats.jsx create mode 100644 server/src/routes/topicStats.ts diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index 1af736231b..8415fb8f84 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -32,9 +32,10 @@ import TopicPrioritizeSimple from "./topicPrioritizeSimple/TopicPrioritizeSimple import TopicAgenda from "./topicAgenda/TopicAgenda.jsx"; import TopicHierarchy from "./topicHierarchy/TopicHierarchy.jsx"; import TopicMapNarrativeReport from "./topicMapNarrativeReport.jsx"; +import TopicStats from "./topicStats/TopicStats.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 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" or "/topicStats/2arcefpshi" +const route_type = pathname.split("/")[1]; // "report", "narrativeReport", "commentsReport", "topicReport", "topicsVizReport", "exportReport", "topicPrioritize", "topicPrioritizeSimple", "topicAgenda", "topicHierarchy", or "topicStats" const report_id = pathname.split("/")[2]; @@ -881,6 +882,16 @@ const App = (props) => { ) } + if (route_type === "topicStats") { + console.log("RENDERING: TopicStats"); + return ( + + ) + } + // Otherwise render the standard report console.log("RENDERING: Standard report"); return ( diff --git a/client-report/src/components/framework/useReportId.js b/client-report/src/components/framework/useReportId.js index 853f7dc896..b8feb6d51f 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 /topicPrioritize/rid or /topicPrioritizeSimple/rid or /topicAgenda/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|topicPrioritize|topicPrioritizeSimple|topicAgenda|topicMapNarrativeReport|topicHierarchy|topicReport|topicsVizReport|exportReport|topicStats)\/([a-zA-Z0-9]+)/ ); if (match && match[2]) { diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx new file mode 100644 index 0000000000..03fa0353a9 --- /dev/null +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -0,0 +1,200 @@ +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"; + +const TopicStats = ({ conversation, report_id: propsReportId }) => { + 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); + + 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") { + setStatsData(statsResponse.stats); + } + + setLoading(false); + } catch (err) { + console.error("Error fetching topic stats:", err); + setError(err.message || "Failed to load topic statistics"); + setLoading(false); + } + }; + + fetchData(); + }, [report_id]); + + 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()}

+ + {/* Overall ranking section */} +
+

Top Topics by Vote Density and Divisiveness

+

+ Topics ranked by vote density (engagement) and group-aware consensus (lower consensus = more divisive) +

+ {(() => { + // Collect all topics across layers + const allTopics = []; + Object.entries(latestRun.topics_by_layer || {}).forEach(([layerId, topics]) => { + Object.entries(topics).forEach(([clusterId, topic]) => { + const stats = statsData?.[topic.topic_key] || {}; + allTopics.push({ + layerId, + clusterId, + topic, + stats + }); + }); + }); + + // Sort and take top 10 + const topTopics = allTopics + .filter(item => item.stats.vote_density > 0) + .sort((a, b) => { + const densityA = a.stats.vote_density || 0; + const densityB = b.stats.vote_density || 0; + if (densityA !== densityB) return densityB - densityA; + + const consensusA = a.stats.group_aware_consensus || 1; + const consensusB = b.stats.group_aware_consensus || 1; + return consensusA - consensusB; + }) + .slice(0, 10); + + return ( +
    + {topTopics.map((item, index) => ( +
  1. + {item.topic.topic_name} (Layer {item.layerId}) +
    + Vote Density: {item.stats.vote_density?.toFixed(1) || 0} votes/comment | + Group Consensus: {item.stats.group_aware_consensus !== undefined ? `${(item.stats.group_aware_consensus * 100).toFixed(1)}%` : 'N/A'} | + Divisiveness: {item.stats.divisiveness?.toFixed(2) || 0} +
    +
  2. + ))} +
+ ); + })()} +
+ + {Object.entries(latestRun.topics_by_layer || {}).map(([layerId, topics]) => ( +
+

Layer {layerId}

+ + + + + + + + + + + + + {Object.entries(topics) + .map(([clusterId, topic]) => ({ + clusterId, + topic, + stats: statsData?.[topic.topic_key] || {} + })) + .sort((a, b) => { + // Sort by vote density first (descending) + const densityA = a.stats.vote_density || 0; + const densityB = b.stats.vote_density || 0; + if (densityA !== densityB) return densityB - densityA; + + // Then by group consensus (ascending - lower consensus = more divisive) + const consensusA = a.stats.group_aware_consensus || 0; + const consensusB = b.stats.group_aware_consensus || 0; + return consensusA - consensusB; + }) + .map(({ clusterId, topic, stats }) => ( + + + + + + + + + ))} + +
TopicCommentsTotal VotesVote DensityGroup ConsensusDivisiveness
{topic.topic_name}{stats.comment_count || 0}{stats.total_votes || 0} + {stats.vote_density !== undefined ? stats.vote_density.toFixed(1) : '-'} + + {stats.group_aware_consensus !== undefined ? `${(stats.group_aware_consensus * 100).toFixed(1)}%` : '-'} + + {stats.divisiveness !== undefined ? stats.divisiveness.toFixed(2) : '-'} +
+
+ ))} +
+ )} + +
+
+
+ ); +}; + +export default TopicStats; \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 112299a725..96026eb7ae 100644 --- a/server/app.ts +++ b/server/app.ts @@ -47,6 +47,8 @@ import { handle_GET_topicMod_stats, } from "./src/routes/delphi/topicMod"; +import { handle_GET_topicStats } from "./src/routes/topicStats"; + import { handle_POST_topicAgenda_selections, handle_GET_topicAgenda_selections, @@ -931,6 +933,18 @@ helpersInitialized.then( } }); + app.get("/api/v3/topicStats", moveToBody, function (req, res) { + try { + handle_GET_topicStats(req, res); + } catch (err) { + res.json({ + status: "error", + message: "Internal server error in topicStats endpoint", + error: err.message || "Unknown error", + }); + } + }); + // Topic Agenda routes app.post( "/api/v3/topicAgenda/selections", @@ -1839,6 +1853,12 @@ helpersInitialized.then( return fetchIndexForReportPage(req, res, next); } ); + app.get( + /^\/topicStats\/r?[0-9][0-9A-Za-z]+(\/.*)?/, + function (req, res, next) { + return fetchIndexForReportPage(req, res, next); + } + ); app.get( /^\/topicPrioritizeSimple\/r?[0-9][0-9A-Za-z]+(\/.*)?/, function (req, res, next) { diff --git a/server/src/routes/topicStats.ts b/server/src/routes/topicStats.ts new file mode 100644 index 0000000000..c23bee81cf --- /dev/null +++ b/server/src/routes/topicStats.ts @@ -0,0 +1,407 @@ +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"; +import pgQuery from "../db/pg-query"; +import * as request from "request-promise"; + +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; + total_votes: number; + consensus: number; + divisiveness: number; + agree_votes: number; + disagree_votes: number; + pass_votes: number; + group_aware_consensus: number; + vote_density: number; // votes per comment +} + +/** + * Calculate consensus and divisiveness metrics for a set of comments + */ +async function calculateTopicMetrics( + zid: number, + commentIds: number[], + groupAwareConsensus?: Record +): Promise { + if (commentIds.length === 0) { + return { + comment_count: 0, + total_votes: 0, + consensus: 0, + divisiveness: 0, + agree_votes: 0, + disagree_votes: 0, + pass_votes: 0, + group_aware_consensus: 0, + vote_density: 0, + }; + } + + try { + // Get vote data for these comments + const voteQuery = ` + SELECT + tid, + COUNT(*) as vote_count, + SUM(CASE WHEN vote = 1 THEN 1 ELSE 0 END) as agree_count, + SUM(CASE WHEN vote = -1 THEN 1 ELSE 0 END) as disagree_count, + SUM(CASE WHEN vote = 0 THEN 1 ELSE 0 END) as pass_count + FROM votes_latest_unique + WHERE zid = $1 AND tid = ANY($2::int[]) + GROUP BY tid + `; + + const voteResults = await pgQuery.queryP(voteQuery, [zid, commentIds]) as any[]; + + if (!voteResults || voteResults.length === 0) { + return { + comment_count: commentIds.length, + total_votes: 0, + consensus: 0, + divisiveness: 0, + agree_votes: 0, + disagree_votes: 0, + pass_votes: 0, + group_aware_consensus: 0, + vote_density: 0, + }; + } + + // Calculate aggregate metrics + let totalVotes = 0; + let totalAgree = 0; + let totalDisagree = 0; + let totalPass = 0; + let consensusSum = 0; + let divisiveSum = 0; + + voteResults.forEach((row: any) => { + const voteCount = parseInt(row.vote_count) || 0; + const agreeCount = parseInt(row.agree_count) || 0; + const disagreeCount = parseInt(row.disagree_count) || 0; + const passCount = parseInt(row.pass_count) || 0; + + totalVotes += voteCount; + totalAgree += agreeCount; + totalDisagree += disagreeCount; + totalPass += passCount; + + // Calculate per-comment consensus (agreement rate among non-pass votes) + const activeVotes = agreeCount + disagreeCount; + if (activeVotes > 0) { + const agreeRate = agreeCount / activeVotes; + const disagreeRate = disagreeCount / activeVotes; + const consensus = Math.max(agreeRate, disagreeRate); + consensusSum += consensus * voteCount; // Weight by vote count + + // Divisiveness: how evenly split the votes are (0 = consensus, 1 = perfectly split) + const divisiveness = 1 - Math.abs(agreeRate - disagreeRate); + divisiveSum += divisiveness * voteCount; + } + }); + + // Calculate weighted averages + const avgConsensus = totalVotes > 0 ? consensusSum / totalVotes : 0; + const avgDivisiveness = totalVotes > 0 ? divisiveSum / totalVotes : 0; + + // Calculate group-aware consensus if available + let avgGroupAwareConsensus = 0; + if (groupAwareConsensus) { + let gacSum = 0; + let gacCount = 0; + commentIds.forEach(tid => { + const gac = groupAwareConsensus[tid.toString()]; + if (gac !== undefined) { + gacSum += gac; + gacCount++; + } + }); + avgGroupAwareConsensus = gacCount > 0 ? gacSum / gacCount : 0; + } + + // Calculate vote density + const voteDensity = commentIds.length > 0 ? totalVotes / commentIds.length : 0; + + return { + comment_count: commentIds.length, + total_votes: totalVotes, + consensus: avgConsensus, + divisiveness: avgDivisiveness, + agree_votes: totalAgree, + disagree_votes: totalDisagree, + pass_votes: totalPass, + group_aware_consensus: avgGroupAwareConsensus, + vote_density: voteDensity, + }; + } catch (err) { + logger.error(`Error calculating topic metrics: ${err}`); + throw err; + } +} + +/** + * 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}`); + + // Fetch group-aware consensus from DynamoDB + let groupAwareConsensusData: Record = {}; + try { + // First, check what tick values exist for this conversation + const pca_table = "Delphi_PCAConversationConfig"; + const pcaParams = { + TableName: pca_table, + KeyConditionExpression: "zid = :z", + ExpressionAttributeValues: { + ":z": conversation_id, + }, + }; + + const pcaData = await docClient.send(new QueryCommand(pcaParams)); + let tick = "999999999"; // Default + if (pcaData.Items && pcaData.Items.length > 0) { + // Get the latest tick from the PCA config + const latestItem = pcaData.Items[pcaData.Items.length - 1]; + if (latestItem.math_tick) { + tick = latestItem.math_tick.toString(); + logger.info(`Using tick ${tick} from PCAConversationConfig`); + } + } else { + logger.info(`No PCA config found for zid ${conversation_id}, using default tick ${tick}`); + } + + // Query the Delphi_CommentRouting table for comment metrics + const commentRoutingTable = "Delphi_CommentRouting"; + const commentRoutingParams = { + TableName: commentRoutingTable, + KeyConditionExpression: "zid_tick = :zt", + ExpressionAttributeValues: { + ":zt": `${conversation_id}:${tick}`, + }, + }; + + const commentRoutingData = await docClient.send(new QueryCommand(commentRoutingParams)); + logger.info(`Queried Delphi_CommentRouting with key ${conversation_id}:${tick}, got ${commentRoutingData.Items?.length || 0} items`); + + if (commentRoutingData.Items && commentRoutingData.Items.length > 0) { + // Build a map of comment_id to consensus_score (group-informed consensus) + commentRoutingData.Items.forEach((item: any) => { + if (item.comment_id && item.consensus_score !== undefined) { + // Store using the comment_id as a string key + // consensus_score is stored as a string in DynamoDB + groupAwareConsensusData[item.comment_id.toString()] = parseFloat(item.consensus_score); + } + }); + logger.info(`Fetched group-aware consensus for ${Object.keys(groupAwareConsensusData).length} comments from DynamoDB`); + + // Debug: log a sample of the data + const sampleKeys = Object.keys(groupAwareConsensusData).slice(0, 3); + logger.info(`Sample group-aware consensus data: ${JSON.stringify(sampleKeys.map(k => ({ tid: k, gac: groupAwareConsensusData[k] })))}`); + } else { + logger.warn(`No items found in Delphi_CommentRouting for ${conversation_id}:${tick}`); + } + } catch (err) { + logger.warn(`Could not fetch group-aware consensus from DynamoDB: ${err}`); + // Continue without it - we'll just use the simple consensus metric + } + + // 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 + for (let layer = 0; layer < 4; 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); + } + } + } + }); + + // Debug: Log mapping results + logger.info(`Cluster to topic mapping has ${Object.keys(clusterToTopic).length} entries`); + logger.info(`Found ${allAssignments.length} comment assignments`); + const nonEmptyTopics = Object.entries(commentsByTopic).filter(([_, comments]) => comments.size > 0); + logger.info(`Topics with comments: ${nonEmptyTopics.length} out of ${Object.keys(commentsByTopic).length} total topics`); + + // Calculate metrics for each topic + const topicStats: Record = {}; + + for (const [topicKey, commentIdSet] of Object.entries(commentsByTopic)) { + const commentIds = Array.from(commentIdSet); + logger.info(`Calculating metrics for topic ${topicKey} with ${commentIds.length} comments`); + + // Debug: log sample comment IDs for first topic + if (Object.keys(topicStats).length === 0 && commentIds.length > 0) { + logger.info(`Sample comment IDs for topic ${topicKey}: ${JSON.stringify(commentIds.slice(0, 3))}`); + } + + const metrics = await calculateTopicMetrics(zid, commentIds, groupAwareConsensusData); + 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 From 9bc3b76b44b5f8d6b528e3da4c53a231916f1704 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sat, 26 Jul 2025 22:27:47 -0700 Subject: [PATCH 05/71] reset conversation script --- delphi/reset_conversation.sh | 15 ++ delphi/scripts/reset_conversation.py | 330 +++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100755 delphi/reset_conversation.sh create mode 100755 delphi/scripts/reset_conversation.py 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/scripts/reset_conversation.py b/delphi/scripts/reset_conversation.py new file mode 100755 index 0000000000..ec1953037b --- /dev/null +++ b/delphi/scripts/reset_conversation.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +""" +Reset/delete all Delphi data for a specific conversation. +This includes DynamoDB tables and MinIO visualization files. + +Usage: + python reset_conversation.py + +Example: + python reset_conversation.py 19548 + python reset_conversation.py r4tykwac8thvzv35jrn53 +""" + +import sys +import boto3 +from boto3.dynamodb.conditions import Key +import subprocess +import os + +def get_conversation_id(identifier): + """Convert report_id to conversation_id if needed.""" + # If it's already a number, assume it's a conversation_id + if identifier.isdigit(): + return identifier + + # If it starts with 'r', it's likely a report_id - need to look it up + if identifier.startswith('r'): + # This would require PostgreSQL access + print(f"Note: Report ID provided ({identifier}). You may need to provide the conversation ID directly.") + return None + + return identifier + +def delete_dynamodb_data(conversation_id): + """Delete all data from DynamoDB tables for a conversation.""" + + # Setup DynamoDB + dynamodb = boto3.resource('dynamodb', + endpoint_url=os.environ.get('DYNAMODB_ENDPOINT', 'http://dynamodb:8000'), + region_name='us-east-1', + aws_access_key_id='dummy', + aws_secret_access_key='dummy' + ) + + deleted_count = {} + + # List of all Delphi tables and their key structures + table_configs = { + # Single key tables + 'Delphi_PCAConversationConfig': { + 'type': 'single', + 'hash_key': 'zid' + }, + 'Delphi_UMAPConversationConfig': { + 'type': 'single', + 'hash_key': 'conversation_id' + }, + + # Tables with conversation_id as hash key + 'Delphi_CommentEmbeddings': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'comment_id' + }, + 'Delphi_CommentHierarchicalClusterAssignments': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'comment_id' + }, + 'Delphi_CommentClustersStructureKeywords': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'topic_key' + }, + 'Delphi_CommentClustersFeatures': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'topic_key' + }, + 'Delphi_CommentClustersLLMTopicNames': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'topic_key' + }, + 'Delphi_UMAPGraph': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'edge_id' + }, + 'Delphi_CommentExtremity': { + 'type': 'query', + 'hash_key': 'conversation_id', + 'range_key': 'comment_id' + }, + + # Tables with composite keys using zid + 'Delphi_CommentRouting': { + 'type': 'scan_prefix', + 'hash_key': 'zid_tick', + 'range_key': 'comment_id', + 'prefix': f'{conversation_id}:' + }, + 'Delphi_PCAResults': { + 'type': 'scan_prefix', + 'hash_key': 'zid', + 'range_key': 'math_tick', + 'prefix': conversation_id + }, + 'Delphi_KMeansClusters': { + 'type': 'scan_prefix', + 'hash_key': 'zid_tick', + 'range_key': 'group_id', + 'prefix': f'{conversation_id}:' + }, + 'Delphi_RepresentativeComments': { + 'type': 'scan_prefix', + 'hash_key': 'zid_tick_gid', + 'range_key': 'comment_id', + 'prefix': f'{conversation_id}:' + }, + 'Delphi_PCAParticipantProjections': { + 'type': 'scan_prefix', + 'hash_key': 'zid_tick', + 'range_key': 'participant_id', + 'prefix': f'{conversation_id}:' + }, + + # Special case tables + 'Delphi_NarrativeReports': { + 'type': 'scan_contains', + 'hash_key': 'rid_section_model', + 'range_key': 'timestamp' + }, + 'Delphi_JobQueue': { + 'type': 'scan_job', + 'hash_key': 'job_id' + } + } + + print(f'\nDeleting DynamoDB data for conversation {conversation_id}...\n') + + for table_name, config in table_configs.items(): + try: + table = dynamodb.Table(table_name) + count = 0 + + if config['type'] == 'single': + # Single item delete + try: + response = table.delete_item(Key={config['hash_key']: conversation_id}) + count = 1 + except: + count = 0 + + elif config['type'] == 'query': + # Query by hash key and delete all items + response = table.query( + KeyConditionExpression=Key(config['hash_key']).eq(conversation_id) + ) + + for item in response.get('Items', []): + key = { + config['hash_key']: conversation_id, + config['range_key']: item[config['range_key']] + } + table.delete_item(Key=key) + count += 1 + + # Handle pagination + while 'LastEvaluatedKey' in response: + response = table.query( + KeyConditionExpression=Key(config['hash_key']).eq(conversation_id), + ExclusiveStartKey=response['LastEvaluatedKey'] + ) + for item in response.get('Items', []): + key = { + config['hash_key']: conversation_id, + config['range_key']: item[config['range_key']] + } + table.delete_item(Key=key) + count += 1 + + elif config['type'] == 'scan_prefix': + # Scan for items with prefix + response = table.scan( + FilterExpression=f'begins_with({config["hash_key"]}, :prefix)', + ExpressionAttributeValues={':prefix': config['prefix']} + ) + + for item in response.get('Items', []): + key = { + config['hash_key']: item[config['hash_key']], + config['range_key']: item[config['range_key']] + } + table.delete_item(Key=key) + count += 1 + + # Handle pagination + while 'LastEvaluatedKey' in response: + response = table.scan( + FilterExpression=f'begins_with({config["hash_key"]}, :prefix)', + ExpressionAttributeValues={':prefix': config['prefix']}, + ExclusiveStartKey=response['LastEvaluatedKey'] + ) + for item in response.get('Items', []): + key = { + config['hash_key']: item[config['hash_key']], + config['range_key']: item[config['range_key']] + } + table.delete_item(Key=key) + count += 1 + + elif config['type'] == 'scan_contains' and table_name == 'Delphi_NarrativeReports': + # Special handling for narrative reports - need report_id + # For now, skip if we don't have report_id + pass + + elif config['type'] == 'scan_job': + # Scan for jobs containing this conversation_id + response = table.scan( + FilterExpression='contains(job_params, :cid)', + ExpressionAttributeValues={':cid': conversation_id} + ) + + for item in response.get('Items', []): + table.delete_item(Key={config['hash_key']: item[config['hash_key']]}) + count += 1 + + if count > 0: + deleted_count[table_name] = count + print(f' ✓ {table_name}: {count} items deleted') + + except Exception as e: + if 'ResourceNotFoundException' not in str(e): + print(f' ✗ {table_name}: Error - {str(e)}') + + return deleted_count + +def delete_minio_data(identifier): + """Delete visualization files from MinIO.""" + + # Determine if we have a report_id or need to find it + if identifier.startswith('r'): + report_id = identifier + else: + print(f"\n⚠️ Note: To delete MinIO visualizations, you need the report_id (starting with 'r')") + return 0 + + print(f'\nDeleting MinIO visualization data for report {report_id}...\n') + + # Configure AWS CLI for MinIO + env = os.environ.copy() + env['AWS_ACCESS_KEY_ID'] = 'minioadmin' + env['AWS_SECRET_ACCESS_KEY'] = 'minioadmin' + + # List files first + list_cmd = [ + 'aws', 's3', 'ls', + f's3://polis-delphi/visualizations/{report_id}/', + '--recursive', + '--endpoint-url', 'http://localhost:9000' + ] + + try: + result = subprocess.run(list_cmd, capture_output=True, text=True, env=env) + if result.returncode == 0 and result.stdout: + file_count = len(result.stdout.strip().split('\n')) + print(f' Found {file_count} visualization files') + + # Delete files + delete_cmd = [ + 'aws', 's3', 'rm', + f's3://polis-delphi/visualizations/{report_id}/', + '--recursive', + '--endpoint-url', 'http://localhost:9000' + ] + + result = subprocess.run(delete_cmd, capture_output=True, text=True, env=env) + if result.returncode == 0: + print(f' ✓ Deleted all visualization files for report {report_id}') + return file_count + else: + print(f' ✗ Error deleting files: {result.stderr}') + return 0 + else: + print(f' No visualization files found for report {report_id}') + return 0 + + except Exception as e: + print(f' ✗ Error accessing MinIO: {str(e)}') + return 0 + +def main(): + if len(sys.argv) != 2: + print(__doc__) + sys.exit(1) + + identifier = sys.argv[1] + + # Get conversation_id + conversation_id = get_conversation_id(identifier) + + if not conversation_id: + print("\n❌ Could not determine conversation_id. Please provide the numeric conversation ID.") + sys.exit(1) + + print(f"\n🗑️ Resetting all Delphi data for conversation {conversation_id}") + print("=" * 60) + + # Delete DynamoDB data + deleted_tables = delete_dynamodb_data(conversation_id) + + # Delete MinIO data (if we have a report_id) + minio_files = delete_minio_data(identifier) + + # Summary + print("\n" + "=" * 60) + print("✅ Reset complete!\n") + + if deleted_tables: + total_items = sum(deleted_tables.values()) + print(f"DynamoDB: Deleted {total_items} items across {len(deleted_tables)} tables") + + if minio_files > 0: + print(f"MinIO: Deleted {minio_files} visualization files") + + print("\nThe conversation is ready for a fresh Delphi run.") + +if __name__ == "__main__": + main() \ No newline at end of file From 64b8fb6fddd25208ec407e4b94bf47468bb58479 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 27 Jul 2025 15:42:23 -0700 Subject: [PATCH 06/71] looking at cross topic gic measures --- delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC.md | 196 ++++ .../TOPIC_GROUP_CONSENSUS_METRIC_REVISED.md | 295 ++++++ delphi/docs/TOPIC_GROUP_CONSENSUS_o3_stub.MD | 982 ++++++++++++++++++ server/src/routes/topicStats.ts | 62 +- 4 files changed, 1502 insertions(+), 33 deletions(-) create mode 100644 delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC.md create mode 100644 delphi/docs/TOPIC_GROUP_CONSENSUS_METRIC_REVISED.md create mode 100644 delphi/docs/TOPIC_GROUP_CONSENSUS_o3_stub.MD 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/server/src/routes/topicStats.ts b/server/src/routes/topicStats.ts index c23bee81cf..49a27539a5 100644 --- a/server/src/routes/topicStats.ts +++ b/server/src/routes/topicStats.ts @@ -1,7 +1,7 @@ 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 { DynamoDBDocumentClient, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; import { getZidFromReport } from "../utils/parameter"; import Config from "../config"; import pgQuery from "../db/pg-query"; @@ -198,45 +198,41 @@ export async function handle_GET_topicStats(req: Request, res: Response) { // Fetch group-aware consensus from DynamoDB let groupAwareConsensusData: Record = {}; try { - // First, check what tick values exist for this conversation - const pca_table = "Delphi_PCAConversationConfig"; - const pcaParams = { - TableName: pca_table, - KeyConditionExpression: "zid = :z", + // Since we don't know the exact tick value, we need to scan for entries + // starting with the conversation_id + const commentRoutingTable = "Delphi_CommentRouting"; + + // Use scan with a filter to find all entries for this conversation + const scanParams = { + TableName: commentRoutingTable, + FilterExpression: "begins_with(zid_tick, :zid)", ExpressionAttributeValues: { - ":z": conversation_id, + ":zid": `${conversation_id}:`, }, }; - const pcaData = await docClient.send(new QueryCommand(pcaParams)); - let tick = "999999999"; // Default - if (pcaData.Items && pcaData.Items.length > 0) { - // Get the latest tick from the PCA config - const latestItem = pcaData.Items[pcaData.Items.length - 1]; - if (latestItem.math_tick) { - tick = latestItem.math_tick.toString(); - logger.info(`Using tick ${tick} from PCAConversationConfig`); + let allItems: any[] = []; + let lastEvaluatedKey; + + // Handle pagination + do { + const params: any = { + ...scanParams, + ExclusiveStartKey: lastEvaluatedKey, + }; + + const scanResult = await docClient.send(new ScanCommand(params)); + if (scanResult.Items) { + allItems = allItems.concat(scanResult.Items); } - } else { - logger.info(`No PCA config found for zid ${conversation_id}, using default tick ${tick}`); - } + lastEvaluatedKey = scanResult.LastEvaluatedKey; + } while (lastEvaluatedKey); - // Query the Delphi_CommentRouting table for comment metrics - const commentRoutingTable = "Delphi_CommentRouting"; - const commentRoutingParams = { - TableName: commentRoutingTable, - KeyConditionExpression: "zid_tick = :zt", - ExpressionAttributeValues: { - ":zt": `${conversation_id}:${tick}`, - }, - }; - - const commentRoutingData = await docClient.send(new QueryCommand(commentRoutingParams)); - logger.info(`Queried Delphi_CommentRouting with key ${conversation_id}:${tick}, got ${commentRoutingData.Items?.length || 0} items`); + logger.info(`Found ${allItems.length} items in Delphi_CommentRouting for conversation ${conversation_id}`); - if (commentRoutingData.Items && commentRoutingData.Items.length > 0) { + if (allItems.length > 0) { // Build a map of comment_id to consensus_score (group-informed consensus) - commentRoutingData.Items.forEach((item: any) => { + allItems.forEach((item: any) => { if (item.comment_id && item.consensus_score !== undefined) { // Store using the comment_id as a string key // consensus_score is stored as a string in DynamoDB @@ -249,7 +245,7 @@ export async function handle_GET_topicStats(req: Request, res: Response) { const sampleKeys = Object.keys(groupAwareConsensusData).slice(0, 3); logger.info(`Sample group-aware consensus data: ${JSON.stringify(sampleKeys.map(k => ({ tid: k, gac: groupAwareConsensusData[k] })))}`); } else { - logger.warn(`No items found in Delphi_CommentRouting for ${conversation_id}:${tick}`); + logger.warn(`No items found in Delphi_CommentRouting for conversation ${conversation_id}`); } } catch (err) { logger.warn(`Could not fetch group-aware consensus from DynamoDB: ${err}`); From ba584c0b78d5d20e9f8346746509623a288e299f Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 27 Jul 2025 15:42:32 -0700 Subject: [PATCH 07/71] reset jobs uitl --- run_reset_processing_jobs.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 run_reset_processing_jobs.sh 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 From 39e92a724a0cda5c4068e0e8f82e66121d365765 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 27 Jul 2025 15:42:45 -0700 Subject: [PATCH 08/71] reset batch util --- delphi/scripts/stop_batch_check_cycle.py | 244 +++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100755 delphi/scripts/stop_batch_check_cycle.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 From 74f68b24f86a06ce492cf824826e344cf16a724d Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 27 Jul 2025 15:42:57 -0700 Subject: [PATCH 09/71] rename notebooks folder --- delphi/{eda_notebooks => notebooks}/README.md | 0 delphi/{eda_notebooks => notebooks}/biodiversity_analysis.ipynb | 0 delphi/{eda_notebooks => notebooks}/launch_notebook.sh | 0 delphi/{eda_notebooks => notebooks}/run_analysis.py | 0 delphi/{eda_notebooks => notebooks}/vw_analysis.ipynb | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename delphi/{eda_notebooks => notebooks}/README.md (100%) rename delphi/{eda_notebooks => notebooks}/biodiversity_analysis.ipynb (100%) rename delphi/{eda_notebooks => notebooks}/launch_notebook.sh (100%) rename delphi/{eda_notebooks => notebooks}/run_analysis.py (100%) rename delphi/{eda_notebooks => notebooks}/vw_analysis.ipynb (100%) 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 From 400a4d2449571878c93423201ac2cb6bd5605350 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 31 Jul 2025 09:58:35 -0700 Subject: [PATCH 10/71] move markdown files to docs --- delphi/{umap_narrative => docs}/702_CONSENSUS_DIVISIVE_README.md | 0 delphi/{umap_narrative => docs}/BATCH_NARRATIVE_README.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename delphi/{umap_narrative => docs}/702_CONSENSUS_DIVISIVE_README.md (100%) rename delphi/{umap_narrative => docs}/BATCH_NARRATIVE_README.md (100%) 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/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 From a797d8ce1d93469923c90c752edbaf3b791d1c52 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 31 Jul 2025 09:59:12 -0700 Subject: [PATCH 11/71] batch complete job --- delphi/scripts/job_poller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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}") From d3cb69e258a9859fb4e31893a975f4ec574cbe50 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 31 Jul 2025 09:59:46 -0700 Subject: [PATCH 12/71] revert dynamo scan --- .../src/components/topicStats/TopicStats.jsx | 24 ++--- server/src/routes/topicStats.ts | 87 ++----------------- 2 files changed, 16 insertions(+), 95 deletions(-) diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index 03fa0353a9..3a26327475 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -86,9 +86,9 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { {/* Overall ranking section */}
-

Top Topics by Vote Density and Divisiveness

+

Top Topics by Vote Density and Consensus

- Topics ranked by vote density (engagement) and group-aware consensus (lower consensus = more divisive) + Topics ranked by vote density (engagement) and overall consensus (lower divisiveness = higher agreement)

{(() => { // Collect all topics across layers @@ -113,9 +113,10 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { const densityB = b.stats.vote_density || 0; if (densityA !== densityB) return densityB - densityA; - const consensusA = a.stats.group_aware_consensus || 1; - const consensusB = b.stats.group_aware_consensus || 1; - return consensusA - consensusB; + // Sort by divisiveness (lower = more consensus) + const divisiveA = a.stats.divisiveness || 0; + const divisiveB = b.stats.divisiveness || 0; + return divisiveA - divisiveB; }) .slice(0, 10); @@ -126,7 +127,6 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { {item.topic.topic_name} (Layer {item.layerId})
Vote Density: {item.stats.vote_density?.toFixed(1) || 0} votes/comment | - Group Consensus: {item.stats.group_aware_consensus !== undefined ? `${(item.stats.group_aware_consensus * 100).toFixed(1)}%` : 'N/A'} | Divisiveness: {item.stats.divisiveness?.toFixed(2) || 0}
@@ -146,8 +146,7 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { Comments Total Votes Vote Density - Group Consensus - Divisiveness + Divisiveness @@ -163,10 +162,8 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { const densityB = b.stats.vote_density || 0; if (densityA !== densityB) return densityB - densityA; - // Then by group consensus (ascending - lower consensus = more divisive) - const consensusA = a.stats.group_aware_consensus || 0; - const consensusB = b.stats.group_aware_consensus || 0; - return consensusA - consensusB; + // Then by divisiveness (ascending - lower divisiveness = more consensus) + return a.stats.divisiveness - b.stats.divisiveness; }) .map(({ clusterId, topic, stats }) => ( @@ -176,9 +173,6 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { {stats.vote_density !== undefined ? stats.vote_density.toFixed(1) : '-'} - - {stats.group_aware_consensus !== undefined ? `${(stats.group_aware_consensus * 100).toFixed(1)}%` : '-'} - {stats.divisiveness !== undefined ? stats.divisiveness.toFixed(2) : '-'} diff --git a/server/src/routes/topicStats.ts b/server/src/routes/topicStats.ts index 49a27539a5..d64c8dee80 100644 --- a/server/src/routes/topicStats.ts +++ b/server/src/routes/topicStats.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import logger from "../utils/logger"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { DynamoDBDocumentClient, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; import { getZidFromReport } from "../utils/parameter"; import Config from "../config"; import pgQuery from "../db/pg-query"; @@ -40,17 +40,16 @@ interface TopicMetrics { agree_votes: number; disagree_votes: number; pass_votes: number; - group_aware_consensus: number; vote_density: number; // votes per comment } + /** * Calculate consensus and divisiveness metrics for a set of comments */ async function calculateTopicMetrics( zid: number, - commentIds: number[], - groupAwareConsensus?: Record + commentIds: number[] ): Promise { if (commentIds.length === 0) { return { @@ -61,7 +60,6 @@ async function calculateTopicMetrics( agree_votes: 0, disagree_votes: 0, pass_votes: 0, - group_aware_consensus: 0, vote_density: 0, }; } @@ -91,7 +89,6 @@ async function calculateTopicMetrics( agree_votes: 0, disagree_votes: 0, pass_votes: 0, - group_aware_consensus: 0, vote_density: 0, }; } @@ -133,23 +130,11 @@ async function calculateTopicMetrics( const avgConsensus = totalVotes > 0 ? consensusSum / totalVotes : 0; const avgDivisiveness = totalVotes > 0 ? divisiveSum / totalVotes : 0; - // Calculate group-aware consensus if available - let avgGroupAwareConsensus = 0; - if (groupAwareConsensus) { - let gacSum = 0; - let gacCount = 0; - commentIds.forEach(tid => { - const gac = groupAwareConsensus[tid.toString()]; - if (gac !== undefined) { - gacSum += gac; - gacCount++; - } - }); - avgGroupAwareConsensus = gacCount > 0 ? gacSum / gacCount : 0; - } - // Calculate vote density const voteDensity = commentIds.length > 0 ? totalVotes / commentIds.length : 0; + + // Topic-based group consensus would go here if we had group membership data + // For now, we rely on the Delphi group-aware consensus return { comment_count: commentIds.length, @@ -159,7 +144,6 @@ async function calculateTopicMetrics( agree_votes: totalAgree, disagree_votes: totalDisagree, pass_votes: totalPass, - group_aware_consensus: avgGroupAwareConsensus, vote_density: voteDensity, }; } catch (err) { @@ -194,63 +178,6 @@ export async function handle_GET_topicStats(req: Request, res: Response) { const conversation_id = zid.toString(); logger.info(`Fetching topic stats for conversation_id: ${conversation_id}`); - - // Fetch group-aware consensus from DynamoDB - let groupAwareConsensusData: Record = {}; - try { - // Since we don't know the exact tick value, we need to scan for entries - // starting with the conversation_id - const commentRoutingTable = "Delphi_CommentRouting"; - - // Use scan with a filter to find all entries for this conversation - const scanParams = { - TableName: commentRoutingTable, - FilterExpression: "begins_with(zid_tick, :zid)", - ExpressionAttributeValues: { - ":zid": `${conversation_id}:`, - }, - }; - - let allItems: any[] = []; - let lastEvaluatedKey; - - // Handle pagination - do { - const params: any = { - ...scanParams, - ExclusiveStartKey: lastEvaluatedKey, - }; - - const scanResult = await docClient.send(new ScanCommand(params)); - if (scanResult.Items) { - allItems = allItems.concat(scanResult.Items); - } - lastEvaluatedKey = scanResult.LastEvaluatedKey; - } while (lastEvaluatedKey); - - logger.info(`Found ${allItems.length} items in Delphi_CommentRouting for conversation ${conversation_id}`); - - if (allItems.length > 0) { - // Build a map of comment_id to consensus_score (group-informed consensus) - allItems.forEach((item: any) => { - if (item.comment_id && item.consensus_score !== undefined) { - // Store using the comment_id as a string key - // consensus_score is stored as a string in DynamoDB - groupAwareConsensusData[item.comment_id.toString()] = parseFloat(item.consensus_score); - } - }); - logger.info(`Fetched group-aware consensus for ${Object.keys(groupAwareConsensusData).length} comments from DynamoDB`); - - // Debug: log a sample of the data - const sampleKeys = Object.keys(groupAwareConsensusData).slice(0, 3); - logger.info(`Sample group-aware consensus data: ${JSON.stringify(sampleKeys.map(k => ({ tid: k, gac: groupAwareConsensusData[k] })))}`); - } else { - logger.warn(`No items found in Delphi_CommentRouting for conversation ${conversation_id}`); - } - } catch (err) { - logger.warn(`Could not fetch group-aware consensus from DynamoDB: ${err}`); - // Continue without it - we'll just use the simple consensus metric - } // Get all topics first const topicsTable = "Delphi_CommentClustersLLMTopicNames"; @@ -375,7 +302,7 @@ export async function handle_GET_topicStats(req: Request, res: Response) { logger.info(`Sample comment IDs for topic ${topicKey}: ${JSON.stringify(commentIds.slice(0, 3))}`); } - const metrics = await calculateTopicMetrics(zid, commentIds, groupAwareConsensusData); + const metrics = await calculateTopicMetrics(zid, commentIds); topicStats[topicKey] = metrics; } From 77630a7a799ad93eeef6b2645fec84e90cbc4483 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 31 Jul 2025 20:09:23 -0700 Subject: [PATCH 13/71] show button on low divisive statemtns --- .../src/components/topicStats/TopicStats.jsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index 3a26327475..df3c3708dd 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -147,6 +147,7 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { Total Votes Vote Density Divisiveness + Action @@ -176,6 +177,24 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { {stats.divisiveness !== undefined ? stats.divisiveness.toFixed(2) : '-'} + + {stats.divisiveness !== undefined && stats.divisiveness < 0.2 && stats.total_votes > 50 ? ( + + ) : null} + ))} From 36cd792bcb128683d8924df19580ebc2afbef348 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 31 Jul 2025 23:48:10 -0700 Subject: [PATCH 14/71] collective statements render --- .../.astro/data-store.json | 2 +- client-participation-alpha/.astro/types.d.ts | 1 - client-report/src/components/app.jsx | 5 + .../topicStats/CollectiveStatementModal.jsx | 229 ++++++++++ .../src/components/topicStats/TopicStats.jsx | 27 +- delphi/create_dynamodb_tables.py | 10 + server/app.ts | 30 ++ server/src/routes/collectiveStatement.ts | 402 ++++++++++++++++++ 8 files changed, 702 insertions(+), 4 deletions(-) create mode 100644 client-report/src/components/topicStats/CollectiveStatementModal.jsx create mode 100644 server/src/routes/collectiveStatement.ts diff --git a/client-participation-alpha/.astro/data-store.json b/client-participation-alpha/.astro/data-store.json index e72bff0062..65d8fe2091 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/colinmegill/polis/client-participation-alpha/node_modules/.astro/sessions\"}}}"] \ 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-report/src/components/app.jsx b/client-report/src/components/app.jsx index 8415fb8f84..f75e36cd9c 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -888,6 +888,11 @@ const App = (props) => { ) } diff --git a/client-report/src/components/topicStats/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx new file mode 100644 index 0000000000..615921ce50 --- /dev/null +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from "react"; +import net from "../../util/net"; +import CommentList from "../lists/commentList.jsx"; + +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); + + useEffect(() => { + if (isOpen && topicKey && reportId) { + generateStatement(); + } + }, [isOpen, topicKey, reportId]); + + const generateStatement = async () => { + try { + setLoading(true); + setError(null); + + const response = await net.polisPost("/api/v3/collectiveStatement", { + report_id: reportId, + topic_key: topicKey, + topic_name: topicName, + }); + + if (response.status === "success") { + console.log("Collective statement response:", response); + setStatementData(response.statementData); + setCommentsData(response.commentsData); + } 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); + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

Collective Statement

+

+ Topic: {topicName} +

+
+ + {loading && ( +
+

Generating collective statement...

+

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

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

Error: {error}

+
+ )} + + {!loading && !error && statementData && ( +
+
+ {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 && ' '} + + ))} +

+ ))} +
+ ))} +
+ + {/* Comments list for citations */} + {comments && comments.length > 0 && statementData && ( +
+

Referenced Comments

+ +
+ )} + +
+

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

+
+
+ )} + +
+ +
+
+
+ ); +}; + +export default CollectiveStatementModal; \ No newline at end of file diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index df3c3708dd..becf6c3690 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -3,13 +3,16 @@ 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"; -const TopicStats = ({ conversation, report_id: propsReportId }) => { +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 [selectedTopic, setSelectedTopic] = useState(null); useEffect(() => { if (!report_id) return; @@ -189,7 +192,10 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => { cursor: "pointer", fontSize: "0.85em" }} - onClick={() => console.log('Create collective statement for topic:', topic.topic_name, topic.topic_key)} + onClick={() => { + setSelectedTopic({ name: topic.topic_name, key: topic.topic_key }); + setModalOpen(true); + }} > Create Collective Statement @@ -206,6 +212,23 @@ const TopicStats = ({ conversation, report_id: propsReportId }) => {
+ + { + 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} + /> ); }; diff --git a/delphi/create_dynamodb_tables.py b/delphi/create_dynamodb_tables.py index 377e5ed4e5..326b0f5c4b 100644 --- a/delphi/create_dynamodb_tables.py +++ b/delphi/create_dynamodb_tables.py @@ -407,6 +407,16 @@ def create_evoc_tables(dynamodb, delete_existing=False): 'ReadCapacityUnits': 5, 'WriteCapacityUnits': 5 } + }, + # 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'} + ], + 'BillingMode': 'PAY_PER_REQUEST' } } diff --git a/server/app.ts b/server/app.ts index 96026eb7ae..a9f80dc644 100644 --- a/server/app.ts +++ b/server/app.ts @@ -49,6 +49,11 @@ import { 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, @@ -945,6 +950,31 @@ helpersInitialized.then( } }); + // Collective Statement routes + app.post("/api/v3/collectiveStatement", moveToBody, function (req, res) { + try { + handle_POST_collectiveStatement(req, res); + } catch (err) { + res.json({ + status: "error", + message: "Internal server error in collectiveStatement endpoint", + error: err.message || "Unknown error", + }); + } + }); + + app.get("/api/v3/collectiveStatement", moveToBody, function (req, res) { + try { + handle_GET_collectiveStatement(req, res); + } catch (err) { + res.json({ + status: "error", + message: "Internal server error in collectiveStatement endpoint", + error: err.message || "Unknown error", + }); + } + }); + // Topic Agenda routes app.post( "/api/v3/topicAgenda/selections", diff --git a/server/src/routes/collectiveStatement.ts b/server/src/routes/collectiveStatement.ts new file mode 100644 index 0000000000..72963b7eaf --- /dev/null +++ b/server/src/routes/collectiveStatement.ts @@ -0,0 +1,402 @@ +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, + })); + + // Build the XML prompt + const systemPrompt = `You are a professional facilitator helping diverse groups find common ground and shared understanding. You will analyze voting patterns and comments to create collective statements that all participants might agree with.`; + + const userPrompt = ` +Write a collective statement for a topic where participants have shown consensus. The statement should be written in first person plural ("We believe...", "We agree that...", "We recognize...") and capture areas of agreement. + + + +${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 } = 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", + }); + } + + // Generate the collective statement + const result = await generateCollectiveStatement( + zid, + topic_key, + topic_name, + topicComments + ); + + // 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 + */ +export async function handle_GET_collectiveStatement(req: Request, res: Response) { + const { statement_id } = req.query; + + if (!statement_id) { + return res.status(400).json({ + status: "error", + message: "statement_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`); + + 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, + COUNT(DISTINCT v.pid) as total_votes, + SUM(CASE WHEN v.vote = 1 THEN 1 ELSE 0 END) as agrees, + SUM(CASE WHEN v.vote = -1 THEN 1 ELSE 0 END) as disagrees, + SUM(CASE WHEN v.vote = 0 THEN 1 ELSE 0 END) 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[]; + + // 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; + } +} \ No newline at end of file From 6d6d6503f02afd9d20500f017b0aa414e41de7f1 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 31 Jul 2025 23:48:10 -0700 Subject: [PATCH 15/71] table sort --- .../src/components/topicStats/TopicStats.jsx | 136 ++++++++++++++---- 1 file changed, 108 insertions(+), 28 deletions(-) diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index becf6c3690..71efa0a841 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -13,6 +13,14 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt const [statsData, setStatsData] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [selectedTopic, setSelectedTopic] = useState(null); + const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); + + const handleSort = (key) => { + setSortConfig(prevConfig => ({ + key, + direction: prevConfig.key === key && prevConfig.direction === 'desc' ? 'asc' : 'desc' + })); + }; useEffect(() => { if (!report_id) return; @@ -89,9 +97,9 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt {/* Overall ranking section */}
-

Top Topics by Vote Density and Consensus

+

Top Topics by Comment Count

- Topics ranked by vote density (engagement) and overall consensus (lower divisiveness = higher agreement) + Topics ranked by number of comments (size) and overall consensus

{(() => { // Collect all topics across layers @@ -110,16 +118,17 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt // Sort and take top 10 const topTopics = allTopics - .filter(item => item.stats.vote_density > 0) + .filter(item => item.stats.comment_count > 0) .sort((a, b) => { - const densityA = a.stats.vote_density || 0; - const densityB = b.stats.vote_density || 0; - if (densityA !== densityB) return densityB - densityA; + // Sort by comment count (descending) + const commentsA = a.stats.comment_count || 0; + const commentsB = b.stats.comment_count || 0; + if (commentsA !== commentsB) return commentsB - commentsA; - // Sort by divisiveness (lower = more consensus) - const divisiveA = a.stats.divisiveness || 0; - const divisiveB = b.stats.divisiveness || 0; - return divisiveA - divisiveB; + // Then by consensus (descending) + const consensusA = a.stats.divisiveness !== undefined ? (1 - a.stats.divisiveness) : 0; + const consensusB = b.stats.divisiveness !== undefined ? (1 - b.stats.divisiveness) : 0; + return consensusB - consensusA; }) .slice(0, 10); @@ -130,7 +139,7 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt {item.topic.topic_name} (Layer {item.layerId})
Vote Density: {item.stats.vote_density?.toFixed(1) || 0} votes/comment | - Divisiveness: {item.stats.divisiveness?.toFixed(2) || 0} + Consensus: {item.stats.divisiveness !== undefined ? (1 - item.stats.divisiveness).toFixed(2) : '-'}
))} @@ -139,17 +148,60 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt })()}
- {Object.entries(latestRun.topics_by_layer || {}).map(([layerId, topics]) => ( + {(() => { + const layerEntries = Object.entries(latestRun.topics_by_layer || {}); + const totalLayers = layerEntries.length; + + return 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 (
-

Layer {layerId}

+

{layerLabel}

- - - - - + + + + + @@ -161,25 +213,51 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt stats: statsData?.[topic.topic_key] || {} })) .sort((a, b) => { - // Sort by vote density first (descending) - const densityA = a.stats.vote_density || 0; - const densityB = b.stats.vote_density || 0; - if (densityA !== densityB) return densityB - densityA; + let aValue, bValue; + + switch (sortConfig.key) { + case 'topic_name': + aValue = a.topic.topic_name.toLowerCase(); + bValue = b.topic.topic_name.toLowerCase(); + break; + case 'consensus': + aValue = a.stats.divisiveness !== undefined ? (1 - a.stats.divisiveness) : 0; + bValue = b.stats.divisiveness !== undefined ? (1 - b.stats.divisiveness) : 0; + 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; + default: + aValue = a.stats.comment_count || 0; + bValue = b.stats.comment_count || 0; + } - // Then by divisiveness (ascending - lower divisiveness = more consensus) - return a.stats.divisiveness - b.stats.divisiveness; + 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 }) => ( + -
TopicCommentsTotal VotesVote DensityDivisiveness handleSort('topic_name')}> + Topic {sortConfig.key === 'topic_name' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + handleSort('consensus')}> + Topic Consensus {sortConfig.key === 'consensus' && (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')}> + Vote Density {sortConfig.key === 'vote_density' && (sortConfig.direction === 'desc' ? '↓' : '↑')} + Action
{topic.topic_name} + {stats.divisiveness !== undefined ? (1 - stats.divisiveness).toFixed(2) : '-'} + {stats.comment_count || 0} {stats.total_votes || 0} {stats.vote_density !== undefined ? stats.vote_density.toFixed(1) : '-'} - {stats.divisiveness !== undefined ? stats.divisiveness.toFixed(2) : '-'} - {stats.divisiveness !== undefined && stats.divisiveness < 0.2 && stats.total_votes > 50 ? (
- ))} + ); + }); + })()} )} From 90e7bb6595fa99fad76729e791c6f24ee61c3685 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sat, 2 Aug 2025 14:53:07 -0700 Subject: [PATCH 16/71] plotly scatterplot topic metrics --- client-report/public/index.html | 1 + .../src/components/topicScatterplot/README.md | 177 +++++++++++++++ .../topicScatterplot/TopicScatterplot.jsx | 204 ++++++++++++++++++ .../topicStats/CollectiveStatementModal.jsx | 181 +++++++++------- .../src/components/topicStats/TopicStats.jsx | 44 +++- 5 files changed, 530 insertions(+), 77 deletions(-) create mode 100644 client-report/src/components/topicScatterplot/README.md create mode 100644 client-report/src/components/topicScatterplot/TopicScatterplot.jsx 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/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..817550d682 --- /dev/null +++ b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx @@ -0,0 +1,204 @@ +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)); + }; + + // Prepare Plotly data + const plotData = [{ + x: data.map(d => d.avg_votes_per_comment || 0), + y: data.map(d => d.consensus || 0), + mode: 'markers', + type: 'scatter', + marker: { + size: data.map(d => scaleSize(d.comment_count || 0)), + color: 'rgba(66, 133, 244, 1)', // Google blue + opacity: mergedConfig.bubbleOpacity, + line: { + color: 'rgba(66, 133, 244, 1)', + width: 1 + } + }, + text: data.map(d => { + // Build hover text + let hoverText = `${d.topic_name}
`; + hoverText += `Consensus: ${(d.consensus * 100).toFixed(1)}%
`; + 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)' + }, + yaxis: { + title: mergedConfig.yAxisLabel, + zeroline: false, + gridcolor: 'rgba(0,0,0,0.1)', + tickformat: '.0%', // Format as percentage + range: [-0.05, 1.05] // Ensure full 0-100% range is visible + }, + 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/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx index 615921ce50..93526f2509 100644 --- a/client-report/src/components/topicStats/CollectiveStatementModal.jsx +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -92,7 +92,7 @@ const CollectiveStatementModal = ({ isOpen, onClose, topicName, topicKey, report style={{ backgroundColor: "white", borderRadius: "8px", - maxWidth: "600px", + maxWidth: "1200px", width: "90%", maxHeight: "80vh", overflow: "auto", @@ -101,10 +101,10 @@ const CollectiveStatementModal = ({ isOpen, onClose, topicName, topicKey, report }} onClick={(e) => e.stopPropagation()} > -
-

Collective Statement

-

- Topic: {topicName} +

+

{topicName}

+

+ Collective Statement

@@ -129,81 +129,112 @@ const CollectiveStatementModal = ({ isOpen, onClose, topicName, topicKey, report )} {!loading && !error && statementData && ( -
-
- {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 && ' '} - - ))} -

- ))} -
- ))} +
+ {/* Left side: Collective Statement */} +
+

Statement

+
+ {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 && ' '} + + ))} +

+ ))} +
+ ))} +
- {/* Comments list for citations */} - {comments && comments.length > 0 && statementData && ( -
-

Referenced Comments

- -
- )} - -
-

- Note: This collective statement was generated using AI (Claude Opus 4) based on - the voting patterns and comments from all participants. It represents areas of shared - understanding and consensus on this topic. -

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

Referenced Comments

+ {comments && comments.length > 0 && statementData ? ( +
+ +
+ ) : ( +
+ No comments referenced +
+ )}
)} + + )} + + {!loading && !error && statementData && ( +
+

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

+
+ )}
)} - {/* Overall ranking section */} -
-

Top Topics by Comment Count

-

- Topics ranked by number of comments (size) and overall consensus -

- {(() => { - // Collect all topics across layers - const allTopics = []; - Object.entries(latestRun.topics_by_layer || {}).forEach(([layerId, topics]) => { - Object.entries(topics).forEach(([clusterId, topic]) => { - const stats = statsData?.[topic.topic_key] || {}; - allTopics.push({ - layerId, - clusterId, - topic, - stats - }); - }); - }); - - // Sort and take top 10 - const topTopics = allTopics - .filter(item => item.stats.comment_count > 0) - .sort((a, b) => { - // Sort by comment count (descending) - const commentsA = a.stats.comment_count || 0; - const commentsB = b.stats.comment_count || 0; - if (commentsA !== commentsB) return commentsB - commentsA; - - // Then by consensus (descending) - const consensusA = a.stats.divisiveness !== undefined ? (1 - a.stats.divisiveness) : 0; - const consensusB = b.stats.divisiveness !== undefined ? (1 - b.stats.divisiveness) : 0; - return consensusB - consensusA; - }) - .slice(0, 10); - - return ( -
    - {topTopics.map((item, index) => ( -
  1. - {item.topic.topic_name} (Layer {item.layerId}) -
    - Avg Votes/Comment: {item.stats.vote_density?.toFixed(1) || 0} | - Consensus: {item.stats.divisiveness !== undefined ? (1 - item.stats.divisiveness).toFixed(2) : '-'} -
    -
  2. - ))} -
- ); - })()} -
{(() => { const layerEntries = Object.entries(latestRun.topics_by_layer || {}); From daeaf65a651726298efb2ac4b1eb671f0d4f2ae5 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sat, 2 Aug 2025 23:54:18 -0700 Subject: [PATCH 21/71] all comments plot --- .../topicScatterplot/TopicScatterplot.jsx | 44 +++++++++++- .../src/components/topicStats/TopicStats.jsx | 67 ++++++++++++++++++- server/src/routes/collectiveStatement.ts | 19 ++++-- server/src/routes/topicStats.ts | 12 ---- 4 files changed, 124 insertions(+), 18 deletions(-) diff --git a/client-report/src/components/topicScatterplot/TopicScatterplot.jsx b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx index f80f49df87..d56dbb858d 100644 --- a/client-report/src/components/topicScatterplot/TopicScatterplot.jsx +++ b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx @@ -90,6 +90,7 @@ const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { }; // 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); @@ -108,6 +109,45 @@ const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { return val; }); + // Calculate custom tick values for transformed axes + let xTickVals, xTickText; + 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; + } + // Store original values for custom hover text const originalValues = data.map(d => ({ x: d.avg_votes_per_comment || 0, @@ -169,7 +209,9 @@ const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { zeroline: false, gridcolor: 'rgba(0,0,0,0.1)', type: mergedConfig.xAxisType || 'linear', - exponentformat: mergedConfig.xAxisType === 'pow' ? 'e' : undefined + tickmode: xTickVals ? 'array' : 'auto', + tickvals: xTickVals, + ticktext: xTickText }, yaxis: { title: mergedConfig.yAxisLabel, diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index 5f68fb6a94..42c670db6b 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -167,6 +167,71 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt

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

+ {/* Individual comments scatterplot */} + {comments && math && math["group-aware-consensus"] && ( +
+

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 +

+ { + const scatterData = []; + let minConsensus = Infinity; + let maxConsensus = -Infinity; + + comments.forEach(comment => { + const groupConsensus = math["group-aware-consensus"][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.substring(0, 50)}...`, + 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 })); + })()} + config={{ + height: 600, + bubbleOpacity: 0.6, + xTransform: 'sqrt', + yTransform: 'pow2', + yAxisLabel: "Group-Aware Consensus", + xAxisLabel: "Total Votes", + useColorScale: true, + colorScale: [[0, '#e74c3c'], [0.5, '#f1c40f'], [1, '#21a53a']], + minBubbleSize: 8, + maxBubbleSize: 8 // Fixed size for all comments + }} + onClick={(comment) => { + console.log('Comment clicked:', comment); + }} + /> +
+ )} + {/* Group-aware consensus scatterplot */} {statsData && math && math["group-aware-consensus"] && (
@@ -232,7 +297,7 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt config={{ height: 600, bubbleOpacity: 0.8, - xAxisType: 'log', + xTransform: 'sqrt', yTransform: 'pow2', yAxisLabel: "Group-Aware Consensus", useColorScale: true, diff --git a/server/src/routes/collectiveStatement.ts b/server/src/routes/collectiveStatement.ts index fc3f88bcd1..3bb24d9e49 100644 --- a/server/src/routes/collectiveStatement.ts +++ b/server/src/routes/collectiveStatement.ts @@ -387,6 +387,11 @@ async function getCommentsForTopic(zid: number, topicKey: string): Promise 0) { + logger.info(`Comment IDs for topic ${topicKey}: ${JSON.stringify(commentIds.slice(0, 20))}`); + } if (commentIds.length === 0) { return []; @@ -397,10 +402,10 @@ async function getCommentsForTopic(zid: number, topicKey: string): Promise comments.size > 0); - logger.info(`Topics with comments: ${nonEmptyTopics.length} out of ${Object.keys(commentsByTopic).length} total topics`); // Calculate metrics for each topic const topicStats: Record = {}; for (const [topicKey, commentIdSet] of Object.entries(commentsByTopic)) { const commentIds = Array.from(commentIdSet); - logger.info(`Calculating metrics for topic ${topicKey} with ${commentIds.length} comments`); - - // Debug: log sample comment IDs for first topic - if (Object.keys(topicStats).length === 0 && commentIds.length > 0) { - logger.info(`Sample comment IDs for topic ${topicKey}: ${JSON.stringify(commentIds.slice(0, 3))}`); - } - const metrics = await calculateTopicMetrics(zid, commentIds); topicStats[topicKey] = metrics; } From 7ead8afc13d6fb30b4d53e01061b7f9afcfa40b4 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 3 Aug 2025 01:37:33 -0700 Subject: [PATCH 22/71] sort! --- .../.astro/settings.json | 2 +- .../topicStats/CollectiveStatementModal.jsx | 21 +++++++++++ server/src/routes/collectiveStatement.ts | 37 ++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/client-participation-alpha/.astro/settings.json b/client-participation-alpha/.astro/settings.json index 22ce6f2977..adf5ec424e 100644 --- a/client-participation-alpha/.astro/settings.json +++ b/client-participation-alpha/.astro/settings.json @@ -1,5 +1,5 @@ { "_variables": { - "lastUpdateCheck": 1753121227723 + "lastUpdateCheck": 1754207464180 } } \ No newline at end of file diff --git a/client-report/src/components/topicStats/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx index b4dbdc6c2a..7c61d08f43 100644 --- a/client-report/src/components/topicStats/CollectiveStatementModal.jsx +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -31,10 +31,31 @@ const CollectiveStatementModal = ({ setLoading(true); setError(null); + // 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 || []; + } + + // Extract only the group consensus values we need + const relevantConsensus = {}; + if (math && math["group-aware-consensus"]) { + topicCommentIds.forEach(tid => { + if (math["group-aware-consensus"][tid] !== undefined) { + relevantConsensus[tid] = math["group-aware-consensus"][tid]; + } + }); + } + const response = await net.polisPost("/api/v3/collectiveStatement", { report_id: reportId, topic_key: topicKey, topic_name: topicName, + group_consensus: relevantConsensus, }); if (response.status === "success") { diff --git a/server/src/routes/collectiveStatement.ts b/server/src/routes/collectiveStatement.ts index 3bb24d9e49..46d8d89a09 100644 --- a/server/src/routes/collectiveStatement.ts +++ b/server/src/routes/collectiveStatement.ts @@ -60,6 +60,7 @@ async function generateCollectiveStatement( disagrees: comment.disagrees || 0, passes: comment.passes || 0, total_votes: comment.total_votes || 0, + group_consensus: comment.group_consensus || null, })); // Build the XML prompt @@ -227,12 +228,46 @@ export async function handle_POST_collectiveStatement(req: Request, res: Respons }); } + // Filter and rank comments by group-aware consensus + const groupConsensus = req.body.group_consensus; + let filteredComments = topicComments; + + if (groupConsensus && Object.keys(groupConsensus).length > 0) { + // Add group consensus to each comment + filteredComments = topicComments.map(comment => ({ + ...comment, + group_consensus: groupConsensus[comment.comment_id] || 0 + })); + + // Debug: Log a sample to verify mapping + if (filteredComments.length > 0) { + logger.info(`Sample comment with consensus: ID ${filteredComments[0].comment_id}, consensus: ${filteredComments[0].group_consensus}`); + } + + // 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)`); + + // Debug: Log the consensus values of top and bottom comments + if (filteredComments.length > 0) { + logger.info(`Top comment consensus: ${filteredComments[0].group_consensus}, Bottom comment consensus: ${filteredComments[filteredComments.length - 1].group_consensus}`); + } + } + // Generate the collective statement const result = await generateCollectiveStatement( zid, topic_key, topic_name, - topicComments + filteredComments ); // Store in DynamoDB From 0429b24a8614b0bb9f61856b2d9c1f56fb3bc68d Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 3 Aug 2025 13:59:26 -0700 Subject: [PATCH 23/71] collective statement modal --- .../topicStats/CollectiveStatementModal.jsx | 268 ++++++++++-------- 1 file changed, 154 insertions(+), 114 deletions(-) diff --git a/client-report/src/components/topicStats/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx index 7c61d08f43..52d109be4c 100644 --- a/client-report/src/components/topicStats/CollectiveStatementModal.jsx +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -125,67 +125,115 @@ const CollectiveStatementModal = ({ style={{ backgroundColor: "white", borderRadius: "8px", - maxWidth: "1200px", - width: "90%", - maxHeight: "80vh", - overflow: "auto", - padding: "30px", + maxWidth: "95vw", + width: "95vw", + maxHeight: "95vh", + height: "95vh", + overflow: "hidden", + display: "flex", + flexDirection: "column", boxShadow: "0 4px 20px rgba(0, 0, 0, 0.15)", }} onClick={(e) => e.stopPropagation()} >
-

{topicName}

-

Candidate Collective Statement

-
- - {loading && ( -
-

Generating collective statement...

-

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

-
- )} - - {error && ( -
-

Error: {error}

+
+

{topicName}

+

Candidate Collective Statement

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

Statement

+ {loading && ( +
+

Generating collective statement...

+

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

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

Error: {error}

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

Statement

+
{statementData && statementData.paragraphs && statementData.paragraphs.map((paragraph, idx) => ( @@ -218,25 +266,51 @@ const CollectiveStatementModal = ({ ))}
))} -
-
+
+
+ + {/* Footer note in left column */} +
+

+ Note: This candidate collective statement was generated using AI (Claude Opus + 4) 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 ? ( -
+ {/* Right side: Cited Comments */} +
+

+ Cited Comments +

+ {comments && comments.length > 0 && statementData ? ( +
+
+ ) : ( +
+ No comments referenced +
+ )}
- ) : ( -
- No comments referenced -
- )} -
-
- )} - - {!loading && !error && statementData && ( -
-

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

+
+ )}
- )} - -
-
From 9f0c2ca572ab5117c8f5a50b37586387ecf1e176 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 3 Aug 2025 14:48:47 -0700 Subject: [PATCH 24/71] refactor componetize --- .../src/components/topicStats/TopicStats.jsx | 330 ++---------------- .../visualizations/AllCommentsScatterplot.jsx | 76 ++++ .../TopicOverviewScatterplot.jsx | 86 +++++ .../topicStats/visualizations/TopicTables.jsx | 165 +++++++++ 4 files changed, 354 insertions(+), 303 deletions(-) create mode 100644 client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx create mode 100644 client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx create mode 100644 client-report/src/components/topicStats/visualizations/TopicTables.jsx diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index 42c670db6b..55049ccdda 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -4,7 +4,9 @@ import { useReportId } from "../framework/useReportId"; import Heading from "../framework/heading.jsx"; import Footer from "../framework/Footer.jsx"; import CollectiveStatementModal from "./CollectiveStatementModal.jsx"; -import TopicScatterplot from "../topicScatterplot/TopicScatterplot.jsx"; +import AllCommentsScatterplot from "./visualizations/AllCommentsScatterplot.jsx"; +import TopicOverviewScatterplot from "./visualizations/TopicOverviewScatterplot.jsx"; +import TopicTables from "./visualizations/TopicTables.jsx"; const TopicStats = ({ conversation, report_id: propsReportId, math, comments, ptptCount, formatTid, voteColors }) => { const { report_id } = useReportId(propsReportId); @@ -14,7 +16,6 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt const [statsData, setStatsData] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [selectedTopic, setSelectedTopic] = useState(null); - const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); // Calculate metrics from comments data const calculateMetricsFromComments = (commentTids, allComments) => { @@ -76,12 +77,6 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt }; - const handleSort = (key) => { - setSortConfig(prevConfig => ({ - key, - direction: prevConfig.key === key && prevConfig.direction === 'desc' ? 'asc' : 'desc' - })); - }; useEffect(() => { if (!report_id) return; @@ -168,305 +163,34 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt {/* Individual comments scatterplot */} - {comments && math && math["group-aware-consensus"] && ( -
-

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 -

- { - const scatterData = []; - let minConsensus = Infinity; - let maxConsensus = -Infinity; - - comments.forEach(comment => { - const groupConsensus = math["group-aware-consensus"][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.substring(0, 50)}...`, - 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 })); - })()} - config={{ - height: 600, - bubbleOpacity: 0.6, - xTransform: 'sqrt', - yTransform: 'pow2', - yAxisLabel: "Group-Aware Consensus", - xAxisLabel: "Total Votes", - useColorScale: true, - colorScale: [[0, '#e74c3c'], [0.5, '#f1c40f'], [1, '#21a53a']], - minBubbleSize: 8, - maxBubbleSize: 8 // Fixed size for all comments - }} - onClick={(comment) => { - console.log('Comment clicked:', comment); - }} - /> -
- )} + {/* Group-aware consensus scatterplot */} - {statsData && math && math["group-aware-consensus"] && ( -
-

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 -

- { - 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 => math["group-aware-consensus"][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 })); - })()} - config={{ - height: 600, - bubbleOpacity: 0.8, - xTransform: 'sqrt', - yTransform: 'pow2', - yAxisLabel: "Group-Aware Consensus", - useColorScale: true, - colorScale: [[0, '#e74c3c'], [0.5, '#f1c40f'], [1, '#21a53a']] - }} - onClick={(topic) => { - setSelectedTopic({ name: topic.topic_name, key: topic.topic_key }); - setModalOpen(true); - }} - /> -
- )} + { + setSelectedTopic({ name: topic.topic_name, key: topic.topic_key }); + setModalOpen(true); + }} + /> - {(() => { - const layerEntries = Object.entries(latestRun.topics_by_layer || {}); - const totalLayers = layerEntries.length; - - return 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; - if (math && math["group-aware-consensus"] && stats.comment_tids) { - const consensusValues = stats.comment_tids - .map(tid => math["group-aware-consensus"][tid]) - .filter(val => val !== undefined); - - if (consensusValues.length > 0) { - groupConsensus = consensusValues.reduce((sum, val) => sum + val, 0) / consensusValues.length; - } - } - - return { - clusterId, - topic, - stats: { ...stats, group_consensus: groupConsensus } - }; - }) - .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 }) => ( - - - - - - - - - ))} - -
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
{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) : '-'} - - {stats.divisiveness !== undefined && stats.divisiveness < 0.2 && stats.total_votes > 50 ? ( - - ) : null} -
-
- ); - }); - })()} + { + setSelectedTopic(topic); + setModalOpen(true); + }} + />
)} 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..140766fc8f --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import TopicScatterplot from '../../topicScatterplot/TopicScatterplot.jsx'; + +const AllCommentsScatterplot = ({ comments, math, voteColors }) => { + if (!comments || !math || !math["group-aware-consensus"]) { + return null; + } + + const data = (() => { + const scatterData = []; + let minConsensus = Infinity; + let maxConsensus = -Infinity; + + comments.forEach(comment => { + const groupConsensus = math["group-aware-consensus"][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.substring(0, 50)}...`, + 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/TopicOverviewScatterplot.jsx b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx new file mode 100644 index 0000000000..bd37ef53b6 --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import TopicScatterplot from '../../topicScatterplot/TopicScatterplot.jsx'; + +const TopicOverviewScatterplot = ({ latestRun, statsData, math, voteColors, onTopicClick }) => { + if (!statsData || !math || !math["group-aware-consensus"]) { + 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 => math["group-aware-consensus"][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..49e9053888 --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; + +const TopicTables = ({ latestRun, statsData, math, onTopicSelect }) => { + 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 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; + if (math && math["group-aware-consensus"] && stats.comment_tids) { + const consensusValues = stats.comment_tids + .map(tid => math["group-aware-consensus"][tid]) + .filter(val => val !== undefined); + + if (consensusValues.length > 0) { + groupConsensus = consensusValues.reduce((sum, val) => sum + val, 0) / consensusValues.length; + } + } + + return { + clusterId, + topic, + stats: { ...stats, group_consensus: groupConsensus } + }; + }) + .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 }) => ( + + + + + + + + + ))} + +
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
{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) : '-'} + + {stats.divisiveness !== undefined && stats.divisiveness < 0.2 && stats.total_votes > 50 ? ( + + ) : null} +
+
+ ); + }); +}; + +export default TopicTables; \ No newline at end of file From 0355dcc3e1bda332b8fc673aa0a539a459786068 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 3 Aug 2025 16:47:46 -0700 Subject: [PATCH 25/71] three modals. --- .../topicScatterplot/TopicScatterplot.jsx | 31 ++- .../topicStats/AllCommentsModal.jsx | 114 ++++++++ .../topicStats/CollectiveStatementModal.jsx | 11 + .../src/components/topicStats/TopicStats.jsx | 52 +++- .../topicStats/TopicSummaryModal.jsx | 252 ++++++++++++++++++ .../visualizations/TopicBeeswarm.jsx | 227 ++++++++++++++++ .../TopicOverviewScatterplot.jsx | 1 + .../topicStats/visualizations/TopicTables.jsx | 40 ++- 8 files changed, 715 insertions(+), 13 deletions(-) create mode 100644 client-report/src/components/topicStats/AllCommentsModal.jsx create mode 100644 client-report/src/components/topicStats/TopicSummaryModal.jsx create mode 100644 client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx diff --git a/client-report/src/components/topicScatterplot/TopicScatterplot.jsx b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx index d56dbb858d..672d19c078 100644 --- a/client-report/src/components/topicScatterplot/TopicScatterplot.jsx +++ b/client-report/src/components/topicScatterplot/TopicScatterplot.jsx @@ -111,6 +111,8 @@ const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { // 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); @@ -148,6 +150,30 @@ const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { 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, @@ -218,7 +244,10 @@ const TopicScatterplot = ({ data, config = {}, onHover, onClick }) => { zeroline: false, gridcolor: 'rgba(0,0,0,0.1)', type: mergedConfig.yAxisType || 'linear', - tickformat: mergedConfig.yAxisTickFormat || '', + tickmode: yTickVals ? 'array' : 'auto', + tickvals: yTickVals, + ticktext: yTickText, + tickformat: yTickVals ? undefined : (mergedConfig.yAxisTickFormat || ''), exponentformat: mergedConfig.yAxisType === 'pow' ? 'e' : undefined }, hovermode: 'closest', diff --git a/client-report/src/components/topicStats/AllCommentsModal.jsx b/client-report/src/components/topicStats/AllCommentsModal.jsx new file mode 100644 index 0000000000..15ea6a2550 --- /dev/null +++ b/client-report/src/components/topicStats/AllCommentsModal.jsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import AllCommentsScatterplot from './visualizations/AllCommentsScatterplot.jsx'; + +const AllCommentsModal = ({ + isOpen, + onClose, + topicName, + topicKey, + topicStats, + comments, + math, + voteColors +}) => { + const [topicComments, setTopicComments] = 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) { + // Get comments for this topic + const commentTids = topicStats.comment_tids || []; + const topicCommentsData = comments.filter(c => commentTids.includes(c.tid)); + setTopicComments(topicCommentsData); + } + }, [isOpen, topicStats, comments]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{topicName} - Comments Analysis

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

+ Visualization of all comments in this topic showing group-aware consensus vs. total 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/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx index 52d109be4c..4a4413c5fb 100644 --- a/client-report/src/components/topicStats/CollectiveStatementModal.jsx +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -26,6 +26,17 @@ const CollectiveStatementModal = ({ } }, [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 generateStatement = async () => { try { setLoading(true); diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index 55049ccdda..2d91c92600 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -4,7 +4,8 @@ import { useReportId } from "../framework/useReportId"; import Heading from "../framework/heading.jsx"; import Footer from "../framework/Footer.jsx"; import CollectiveStatementModal from "./CollectiveStatementModal.jsx"; -import AllCommentsScatterplot from "./visualizations/AllCommentsScatterplot.jsx"; +import TopicSummaryModal from "./TopicSummaryModal.jsx"; +import AllCommentsModal from "./AllCommentsModal.jsx"; import TopicOverviewScatterplot from "./visualizations/TopicOverviewScatterplot.jsx"; import TopicTables from "./visualizations/TopicTables.jsx"; @@ -15,6 +16,8 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt 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 [selectedTopic, setSelectedTopic] = useState(null); // Calculate metrics from comments data @@ -161,14 +164,6 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt

Model: {latestRun.model_name}

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

- - {/* Individual comments scatterplot */} - - {/* Group-aware consensus scatterplot */} { + setSelectedTopic(topic); + setScatterModalOpen(true); + }} + onBeeswarm={(topic) => { + setSelectedTopic(topic); + setBeeswarmModalOpen(true); + }} />
)} @@ -213,6 +216,37 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt formatTid={formatTid} voteColors={voteColors} /> + + { + setScatterModalOpen(false); + setSelectedTopic(null); + }} + topicName={selectedTopic?.name} + topicKey={selectedTopic?.key} + topicStats={selectedTopic ? statsData[selectedTopic.key] : null} + comments={comments} + math={math} + 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} + />
); }; diff --git a/client-report/src/components/topicStats/TopicSummaryModal.jsx b/client-report/src/components/topicStats/TopicSummaryModal.jsx new file mode 100644 index 0000000000..d4778fff0b --- /dev/null +++ b/client-report/src/components/topicStats/TopicSummaryModal.jsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect } from 'react'; +import CommentList from '../lists/commentList.jsx'; +import TopicBeeswarm from './visualizations/TopicBeeswarm.jsx'; + +const TopicSummaryModal = ({ + 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; + + const totalVotes = topicStats?.total_votes || 0; + const commentCount = topicStats?.comment_count || 0; + const avgVotesPerComment = topicStats?.vote_density || 0; + const agreeVotes = topicStats?.agree_votes || 0; + const disagreeVotes = topicStats?.disagree_votes || 0; + const passVotes = topicStats?.pass_votes || 0; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{topicName}

+ +
+ + {/* Content */} +
+ {/* Statistics Grid */} +
+
+
+ {commentCount} +
+
Comments
+
+ +
+
+ {totalVotes.toLocaleString()} +
+
Total Votes
+
+ +
+
+ {avgVotesPerComment.toFixed(1)} +
+
Avg Votes/Comment
+
+ +
+
+ {((agreeVotes / totalVotes) * 100).toFixed(0)}% +
+
Agree ({agreeVotes.toLocaleString()})
+
+ +
+
+ {((disagreeVotes / totalVotes) * 100).toFixed(0)}% +
+
Disagree ({disagreeVotes.toLocaleString()})
+
+ +
+
+ {((passVotes / totalVotes) * 100).toFixed(0)}% +
+
Pass ({passVotes.toLocaleString()})
+
+
+ + {/* Beeswarm Visualization */} +
+

Group-Aware Consensus Distribution

+

+ Each circle represents a comment with 5+ votes. Position shows how similarly groups voted. + Hover to see details. +

+ +
+ + {/* Comments List */} +
+

Comments (sorted by group consensus)

+
+ {sortedByConsensus.map(comment => ( +
+
+ + Group Consensus: {comment.groupConsensus.toFixed(3)} + + + {comment.agree_count} agree, {comment.disagree_count} disagree, {comment.pass_count} pass + +
+ +
+ ))} +
+
+
+
+
+ ); +}; + +export default TopicSummaryModal; \ 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..0af4adb6c3 --- /dev/null +++ b/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx @@ -0,0 +1,227 @@ +import React, { useState, useEffect, useRef } from 'react'; +import _ from 'lodash'; + +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 = (consensus - min) / (max - min); + + // Red (disagree) -> Yellow (split) -> Green (agree) + if (normalized < 0.5) { + // Red to Yellow + const ratio = normalized * 2; + const r = 231; + const g = Math.round(76 + (241 - 76) * ratio); + const b = Math.round(60 + (64 - 60) * ratio); + return `rgb(${r},${g},${b})`; + } else { + // Yellow to Green + const ratio = (normalized - 0.5) * 2; + const r = Math.round(241 - (241 - 33) * ratio); + const g = Math.round(196 - (196 - 165) * ratio); + const b = Math.round(64 - (64 - 58) * ratio); + return `rgb(${r},${g},${b})`; + } + } + } + + return ( + + {voronoi.map((cell, i) => { + return ( + + + + + ) + })} + + ) +} + +const TopicBeeswarm = ({ comments, commentTids, math }) => { + 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 svgRef = useRef(null); + + const onHoverCallback = (d) => { + return () => { + setCurrentComment(d.data); + } + } + + const setup = () => { + if (!comments || !commentTids || !math || !math["group-aware-consensus"]) return; + + // Filter to only topic comments with enough votes 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); + // Only include comments with 5+ votes + if (totalVotes >= 5) { + const groupConsensus = math["group-aware-consensus"][comment.tid]; + if (groupConsensus !== undefined) { + commentsWithConsensusData.push({ + ...comment, + groupConsensus: groupConsensus, + totalVotes: totalVotes + }); + } + } + } + }); + + if (commentsWithConsensusData.length === 0) return; + + // Find actual data extent with some padding + const consensusValues = commentsWithConsensusData.map(d => d.groupConsensus); + const minConsensus = Math.min(...consensusValues); + const maxConsensus = Math.max(...consensusValues); + + // Add 5% padding to show all points clearly + const padding = (maxConsensus - minConsensus) * 0.05; + const paddedMin = Math.max(0, minConsensus - padding); + const paddedMax = Math.min(1, maxConsensus + padding); + + setDataExtent([paddedMin, paddedMax]); + + // Create x scale based on actual data range + const x = window.d3.scaleLinear() + .domain([paddedMin, paddedMax]) + .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); + + // Add x-axis + if (svgRef.current) { + const svg = window.d3.select(svgRef.current); + svg.select(".x-axis").remove(); // Clear any existing axis + + svg.append("g") + .attr("class", "x-axis") + .attr("transform", `translate(${margin.left}, ${heightMinusMargins + margin.top})`) + .call(window.d3.axisBottom(x).ticks(5).tickFormat(d => d.toFixed(1))); + } + } + + useEffect(() => { + setup(); + }, [comments, commentTids, math]); + + if (!commentsWithConsensus || !voronoi) { + return ( +
+

Loading visualization...

+
+ ); + } + + return ( +
+ + + + + + +
+
+ {(() => { + const steps = 6; + const labels = []; + for (let i = 0; i < steps; i++) { + const value = dataExtent[0] + (dataExtent[1] - dataExtent[0]) * (i / (steps - 1)); + labels.push( + + {value.toFixed(2)} + + ); + } + return labels; + })()} +
+
+
+

+ All groups
DISAGREE +

+

+ Groups are split
(or low votes) +

+

+ All groups
AGREE +

+
+
+ + {currentComment && ( +
+
+ Group Consensus: {currentComment.groupConsensus.toFixed(3)} | + Votes: {currentComment.totalVotes} + ({currentComment.agree_count} agree, {currentComment.disagree_count} disagree, {currentComment.pass_count} pass) +
+
{currentComment.txt}
+
+ )} +
+ ); +} + +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 index bd37ef53b6..aa98a43952 100644 --- a/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx @@ -74,6 +74,7 @@ const TopicOverviewScatterplot = ({ latestRun, statsData, math, voteColors, onTo xTransform: 'sqrt', yTransform: 'pow2', yAxisLabel: "Group-Aware Consensus", + xAxisLabel: "Average Votes per Comment", useColorScale: true, colorScale: [[0, '#e74c3c'], [0.5, '#f1c40f'], [1, '#21a53a']] }} diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx index 49e9053888..777d4c6816 100644 --- a/client-report/src/components/topicStats/visualizations/TopicTables.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -const TopicTables = ({ latestRun, statsData, math, onTopicSelect }) => { +const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, onBeeswarm }) => { const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); const handleSort = (key) => { @@ -136,7 +136,7 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect }) => { {stats.group_consensus !== null ? stats.group_consensus.toFixed(2) : '-'} - {stats.divisiveness !== undefined && stats.divisiveness < 0.2 && stats.total_votes > 50 ? ( +
- ) : null} + + + + +
))} From ece08c545cef1459686515461c13c381f4221903 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 3 Aug 2025 17:50:24 -0700 Subject: [PATCH 26/71] modals. --- .../topicStats/AllCommentsModal.jsx | 165 ++++++++++-- .../components/topicStats/BeeswarmModal.jsx | 112 ++++++++ .../topicStats/LayerDistributionModal.jsx | 187 +++++++++++++ .../src/components/topicStats/TopicStats.jsx | 28 +- .../topicStats/TopicSummaryModal.jsx | 252 ------------------ .../visualizations/TopicBeeswarm.jsx | 65 +++-- .../topicStats/visualizations/TopicTables.jsx | 21 +- 7 files changed, 528 insertions(+), 302 deletions(-) create mode 100644 client-report/src/components/topicStats/BeeswarmModal.jsx create mode 100644 client-report/src/components/topicStats/LayerDistributionModal.jsx delete mode 100644 client-report/src/components/topicStats/TopicSummaryModal.jsx diff --git a/client-report/src/components/topicStats/AllCommentsModal.jsx b/client-report/src/components/topicStats/AllCommentsModal.jsx index 15ea6a2550..6154e60b01 100644 --- a/client-report/src/components/topicStats/AllCommentsModal.jsx +++ b/client-report/src/components/topicStats/AllCommentsModal.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import AllCommentsScatterplot from './visualizations/AllCommentsScatterplot.jsx'; +import CommentList from '../lists/commentList.jsx'; const AllCommentsModal = ({ isOpen, @@ -9,9 +10,13 @@ const AllCommentsModal = ({ topicStats, comments, math, + conversation, + ptptCount, + formatTid, voteColors }) => { const [topicComments, setTopicComments] = useState([]); + const [sortedByConsensus, setSortedByConsensus] = useState([]); useEffect(() => { const handleEscape = (e) => { @@ -25,13 +30,25 @@ const AllCommentsModal = ({ }, [isOpen, onClose]); useEffect(() => { - if (isOpen && topicStats && comments) { + 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]); + }, [isOpen, topicStats, comments, math]); if (!isOpen) return null; @@ -89,22 +106,136 @@ const AllCommentsModal = ({ overflow: 'auto', padding: '20px' }}> -

- Visualization of all comments in this topic showing group-aware consensus vs. total votes. - Each dot represents a comment. Hover for details. -

- - {topicComments.length > 0 ? ( - - ) : ( -
- No comments to display + {/* Statistics Grid */} +
+
+
+ {topicStats?.comment_count || 0} +
+
Comments
- )} + +
+
+ {(topicStats?.total_votes || 0).toLocaleString()} +
+
Total Votes
+
+ +
+
+ {(topicStats?.vote_density || 0).toFixed(1)} +
+
Avg Votes/Comment
+
+ +
+
+ {topicStats?.total_votes > 0 ? ((topicStats.agree_votes / topicStats.total_votes) * 100).toFixed(0) : 0}% +
+
Agree ({(topicStats?.agree_votes || 0).toLocaleString()})
+
+ +
+
+ {topicStats?.total_votes > 0 ? ((topicStats.disagree_votes / topicStats.total_votes) * 100).toFixed(0) : 0}% +
+
Disagree ({(topicStats?.disagree_votes || 0).toLocaleString()})
+
+ +
+
+ {topicStats?.total_votes > 0 ? ((topicStats.pass_votes / topicStats.total_votes) * 100).toFixed(0) : 0}% +
+
Pass ({(topicStats?.pass_votes || 0).toLocaleString()})
+
+
+ + {/* Scatterplot */} +
+

Comments: Group-Aware Consensus vs. Votes

+

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

+ {topicComments.length > 0 ? ( + + ) : ( +
+ No comments to display +
+ )} +
+ + {/* Comments List */} +
+

Comments (sorted by group consensus)

+
+ {sortedByConsensus.map(comment => ( +
+
+ + Group Consensus: {comment.groupConsensus.toFixed(3)} + + + {comment.agree_count} agree, {comment.disagree_count} disagree, {comment.pass_count} pass + +
+ +
+ ))} +
+
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/LayerDistributionModal.jsx b/client-report/src/components/topicStats/LayerDistributionModal.jsx new file mode 100644 index 0000000000..8497962e99 --- /dev/null +++ b/client-report/src/components/topicStats/LayerDistributionModal.jsx @@ -0,0 +1,187 @@ +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 || !math["group-aware-consensus"] || !comments) 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 = math["group-aware-consensus"][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 1 vote for meaningful distribution + if (totalVotes > 0) { + consensusValues.push(consensus); + commentsData.push({ tid, consensus, votes: totalVotes }); + } + } + }); + + if (consensusValues.length > 0) { + traces.push({ + y: consensusValues, + type: 'box', + name: topic.topic_name.length > 30 ? + topic.topic_name.substring(0, 30) + '...' : + topic.topic_name, + boxpoints: 'outliers', + marker: { + color: 'rgba(33, 165, 58, 0.6)', + outliercolor: 'rgba(231, 76, 60, 0.6)' + }, + line: { + color: 'rgba(33, 165, 58, 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: 60, + r: 20, + t: 20, + b: 120 + }, + 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 1 vote within each topic. + The box shows the quartiles, the line inside is the median, and outliers are shown as individual points. + Zero-vote comments 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 index 2d91c92600..60e62fa8a3 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -4,8 +4,9 @@ import { useReportId } from "../framework/useReportId"; import Heading from "../framework/heading.jsx"; import Footer from "../framework/Footer.jsx"; import CollectiveStatementModal from "./CollectiveStatementModal.jsx"; -import TopicSummaryModal from "./TopicSummaryModal.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"; @@ -18,7 +19,9 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt 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); // Calculate metrics from comments data const calculateMetricsFromComments = (commentTids, allComments) => { @@ -193,6 +196,10 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt setSelectedTopic(topic); setBeeswarmModalOpen(true); }} + onLayerDistribution={(layer) => { + setSelectedLayer(layer); + setLayerModalOpen(true); + }} /> )} @@ -228,10 +235,13 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt topicStats={selectedTopic ? statsData[selectedTopic.key] : null} comments={comments} math={math} + conversation={conversation} + ptptCount={ptptCount} + formatTid={formatTid} voteColors={voteColors} /> - { setBeeswarmModalOpen(false); @@ -247,6 +257,20 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt formatTid={formatTid} voteColors={voteColors} /> + + { + setLayerModalOpen(false); + setSelectedLayer(null); + }} + layerName={selectedLayer?.layerName} + layerId={selectedLayer?.layerId} + topics={selectedLayer?.topics} + statsData={statsData} + math={math} + comments={comments} + /> ); }; diff --git a/client-report/src/components/topicStats/TopicSummaryModal.jsx b/client-report/src/components/topicStats/TopicSummaryModal.jsx deleted file mode 100644 index d4778fff0b..0000000000 --- a/client-report/src/components/topicStats/TopicSummaryModal.jsx +++ /dev/null @@ -1,252 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import CommentList from '../lists/commentList.jsx'; -import TopicBeeswarm from './visualizations/TopicBeeswarm.jsx'; - -const TopicSummaryModal = ({ - 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; - - const totalVotes = topicStats?.total_votes || 0; - const commentCount = topicStats?.comment_count || 0; - const avgVotesPerComment = topicStats?.vote_density || 0; - const agreeVotes = topicStats?.agree_votes || 0; - const disagreeVotes = topicStats?.disagree_votes || 0; - const passVotes = topicStats?.pass_votes || 0; - - return ( -
-
e.stopPropagation()}> - {/* Header */} -
-

{topicName}

- -
- - {/* Content */} -
- {/* Statistics Grid */} -
-
-
- {commentCount} -
-
Comments
-
- -
-
- {totalVotes.toLocaleString()} -
-
Total Votes
-
- -
-
- {avgVotesPerComment.toFixed(1)} -
-
Avg Votes/Comment
-
- -
-
- {((agreeVotes / totalVotes) * 100).toFixed(0)}% -
-
Agree ({agreeVotes.toLocaleString()})
-
- -
-
- {((disagreeVotes / totalVotes) * 100).toFixed(0)}% -
-
Disagree ({disagreeVotes.toLocaleString()})
-
- -
-
- {((passVotes / totalVotes) * 100).toFixed(0)}% -
-
Pass ({passVotes.toLocaleString()})
-
-
- - {/* Beeswarm Visualization */} -
-

Group-Aware Consensus Distribution

-

- Each circle represents a comment with 5+ votes. Position shows how similarly groups voted. - Hover to see details. -

- -
- - {/* Comments List */} -
-

Comments (sorted by group consensus)

-
- {sortedByConsensus.map(comment => ( -
-
- - Group Consensus: {comment.groupConsensus.toFixed(3)} - - - {comment.agree_count} agree, {comment.disagree_count} disagree, {comment.pass_count} pass - -
- -
- ))} -
-
-
-
-
- ); -}; - -export default TopicSummaryModal; \ 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 index 0af4adb6c3..776fc5f1b6 100644 --- a/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx @@ -1,5 +1,6 @@ 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) => { @@ -9,24 +10,26 @@ const VoronoiCells = ({ currentComment, voronoi, onHoverCallback, dataExtent }) // Color based on group consensus value, normalized to data extent const consensus = cell.data.groupConsensus || 0; const [min, max] = dataExtent || [0, 1]; - const normalized = (consensus - min) / (max - min); + 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; - // Red (disagree) -> Yellow (split) -> Green (agree) if (normalized < 0.5) { - // Red to Yellow + // Red to Yellow (increase green) const ratio = normalized * 2; - const r = 231; - const g = Math.round(76 + (241 - 76) * ratio); - const b = Math.round(60 + (64 - 60) * ratio); - return `rgb(${r},${g},${b})`; + r = 231; + g = Math.round(76 + (165 * ratio)); + b = 60; } else { - // Yellow to Green + // Yellow to Green (decrease red) const ratio = (normalized - 0.5) * 2; - const r = Math.round(241 - (241 - 33) * ratio); - const g = Math.round(196 - (196 - 165) * ratio); - const b = Math.round(64 - (64 - 58) * ratio); - return `rgb(${r},${g},${b})`; + r = Math.round(231 * (1 - ratio)); + g = 231; + b = 60; } + + return `rgb(${r},${g},${b})`; } } @@ -49,7 +52,7 @@ const VoronoiCells = ({ currentComment, voronoi, onHoverCallback, dataExtent }) ) } -const TopicBeeswarm = ({ comments, commentTids, math }) => { +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}; @@ -71,21 +74,18 @@ const TopicBeeswarm = ({ comments, commentTids, math }) => { const setup = () => { if (!comments || !commentTids || !math || !math["group-aware-consensus"]) return; - // Filter to only topic comments with enough votes and add group consensus + // 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); - // Only include comments with 5+ votes - if (totalVotes >= 5) { - const groupConsensus = math["group-aware-consensus"][comment.tid]; - if (groupConsensus !== undefined) { - commentsWithConsensusData.push({ - ...comment, - groupConsensus: groupConsensus, - totalVotes: totalVotes - }); - } + const groupConsensus = math["group-aware-consensus"][comment.tid]; + if (groupConsensus !== undefined) { + commentsWithConsensusData.push({ + ...comment, + groupConsensus: groupConsensus, + totalVotes: totalVotes + }); } } }); @@ -210,14 +210,21 @@ const TopicBeeswarm = ({ comments, commentTids, math }) => { padding: "15px", backgroundColor: "#f5f5f5", borderRadius: "8px", - minHeight: "80px" + minHeight: "140px" }}> -
+
Group Consensus: {currentComment.groupConsensus.toFixed(3)} | - Votes: {currentComment.totalVotes} - ({currentComment.agree_count} agree, {currentComment.disagree_count} disagree, {currentComment.pass_count} pass) + Total Votes: {currentComment.totalVotes}
-
{currentComment.txt}
+
)} diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx index 777d4c6816..d03d8a3089 100644 --- a/client-report/src/components/topicStats/visualizations/TopicTables.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, onBeeswarm }) => { +const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, onBeeswarm, onLayerDistribution }) => { const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); const handleSort = (key) => { @@ -38,7 +38,24 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, return (
-

{layerLabel}

+
+

{layerLabel}

+ +
From 2539e2e523fc91dd04b61ff789a30c9afbb2853b Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Sun, 3 Aug 2025 21:04:08 -0700 Subject: [PATCH 27/71] topic page. --- client-report/REPORT_ROUTES.md | 151 +++++ .../src/components/topicPage/TopicPage.jsx | 613 ++++++++++++++++++ .../topicStats/LayerDistributionModal.jsx | 9 +- .../src/components/topicStats/TopicStats.jsx | 27 + .../topicStats/visualizations/TopicTables.jsx | 21 +- 5 files changed, 815 insertions(+), 6 deletions(-) create mode 100644 client-report/REPORT_ROUTES.md create mode 100644 client-report/src/components/topicPage/TopicPage.jsx 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/src/components/topicPage/TopicPage.jsx b/client-report/src/components/topicPage/TopicPage.jsx new file mode 100644 index 0000000000..e93474f310 --- /dev/null +++ b/client-report/src/components/topicPage/TopicPage.jsx @@ -0,0 +1,613 @@ +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"; + +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 [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 + if (math && math["group-aware-consensus"]) { + const sorted = topicCommentsData + .map(comment => ({ + ...comment, + groupConsensus: math["group-aware-consensus"][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 generateCollectiveStatement = async () => { + if (loadingStatement || statementGenerated) return; + + try { + setLoadingStatement(true); + + // Get group consensus values for filtering + const relevantConsensus = {}; + if (math && math["group-aware-consensus"] && topicStats.comment_tids) { + topicStats.comment_tids.forEach(tid => { + if (math["group-aware-consensus"][tid] !== undefined) { + relevantConsensus[tid] = math["group-aware-consensus"][tid]; + } + }); + } + + console.log("Generating collective statement with:", { + report_id: report_id, + topic_key: topic_key, + topic_name: topicData?.topic_name, + consensusCount: Object.keys(relevantConsensus).length + }); + + const response = await net.polisPost("/api/v3/collectiveStatement", { + report_id: report_id, + topic_key: topic_key, + topic_name: topicData?.topic_name || "", + group_consensus: relevantConsensus + }); + + console.log("Collective statement response:", response); + + if (response.status === "success" && response.statementData) { + setCollectiveStatement(response.statementData); + setStatementGenerated(true); + } 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 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}

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

Collective Statement

+ + {loadingStatement && ( +
+

Generating collective statement...

+
+ )} + + {collectiveStatement && !collectiveStatement.error && (() => { + // 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 collective statement. Please try refreshing the page. +

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

Comments

+

{topicStats.comment_count}

+
+
+

Total Votes

+

{topicStats.total_votes.toLocaleString()}

+
+
+

Agree

+

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

+

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

+
+
+

Disagree

+

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

+

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

+
+
+
+ + {/* 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, sorted by group consensus (highest to lowest). + The colored bar indicates the level of consensus across groups. +

+
+ {sortedComments.map((comment, index) => ( +
0.5 ? voteColors?.agree || globals.brandColors.agree : + comment.groupConsensus < 0.2 ? voteColors?.disagree || globals.brandColors.disagree : + globals.brandColors.yellowForRadial + }` + }}> +
+ + #{index + 1} | Group Consensus: {comment.groupConsensus.toFixed(3)} + + + {comment.agree_count} agree, {comment.disagree_count} disagree, {comment.pass_count} pass + +
+ +
+ ))} +
+
+ +
+
+ ); +}; + +export default TopicPage; \ No newline at end of file diff --git a/client-report/src/components/topicStats/LayerDistributionModal.jsx b/client-report/src/components/topicStats/LayerDistributionModal.jsx index 8497962e99..3b290070c8 100644 --- a/client-report/src/components/topicStats/LayerDistributionModal.jsx +++ b/client-report/src/components/topicStats/LayerDistributionModal.jsx @@ -45,8 +45,9 @@ const LayerDistributionModal = ({ const totalVotes = comment ? (comment.agree_count || 0) + (comment.disagree_count || 0) + (comment.pass_count || 0) : 0; - // Only include comments with at least 1 vote for meaningful distribution - if (totalVotes > 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 }); } @@ -166,9 +167,9 @@ const LayerDistributionModal = ({ padding: '20px' }}>

- Box plots showing the distribution of group-aware consensus values for comments with at least 1 vote within each topic. + 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. - Zero-vote comments are excluded as they default to 0.333 consensus. + Comments with fewer than 5 votes are excluded as they default to 0.333 consensus.

{plotData && plotData.length > 0 ? ( diff --git a/client-report/src/components/topicStats/TopicStats.jsx b/client-report/src/components/topicStats/TopicStats.jsx index 60e62fa8a3..ce4d4543eb 100644 --- a/client-report/src/components/topicStats/TopicStats.jsx +++ b/client-report/src/components/topicStats/TopicStats.jsx @@ -9,6 +9,7 @@ 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); @@ -22,6 +23,8 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt const [layerModalOpen, setLayerModalOpen] = useState(false); const [selectedTopic, setSelectedTopic] = useState(null); const [selectedLayer, setSelectedLayer] = useState(null); + const [showTopicPage, setShowTopicPage] = useState(false); + const [selectedTopicKey, setSelectedTopicKey] = useState(null); // Calculate metrics from comments data const calculateMetricsFromComments = (commentTids, allComments) => { @@ -156,6 +159,26 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt const latestRunKey = Object.keys(topicsData || {}).sort().reverse()[0]; const latestRun = topicsData?.[latestRunKey]; + // If showing a topic page, render that instead + if (showTopicPage && selectedTopicKey) { + return ( + { + setShowTopicPage(false); + setSelectedTopicKey(null); + }} + /> + ); + } + return (
@@ -200,6 +223,10 @@ const TopicStats = ({ conversation, report_id: propsReportId, math, comments, pt setSelectedLayer(layer); setLayerModalOpen(true); }} + onViewTopic={(topic) => { + setSelectedTopicKey(topic.key); + setShowTopicPage(true); + }} />
)} diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx index d03d8a3089..ddacdfc23e 100644 --- a/client-report/src/components/topicStats/visualizations/TopicTables.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, onBeeswarm, onLayerDistribution }) => { +const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, onBeeswarm, onLayerDistribution, onViewTopic }) => { const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); const handleSort = (key) => { @@ -154,6 +154,23 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot,
+ +
@@ -143,7 +143,24 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, }) .map(({ clusterId, topic, stats }) => ( - + From 20120ff4bf23c442e740540f0b4d7cf28f317c12 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Mon, 4 Aug 2025 03:14:26 -0700 Subject: [PATCH 31/71] button color --- .../topicStats/visualizations/TopicTables.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx index 56466b62fd..9a226195f9 100644 --- a/client-report/src/components/topicStats/visualizations/TopicTables.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -42,11 +42,11 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot,

{layerLabel}

{topic.topic_name} + { + e.preventDefault(); + onViewTopic({ name: topic.topic_name, key: topic.topic_key }); + }} + style={{ + color: "#0066cc", + textDecoration: "none", + cursor: "pointer" + }} + onMouseEnter={(e) => e.target.style.textDecoration = "underline"} + onMouseLeave={(e) => e.target.style.textDecoration = "none"} + > + {topic.topic_name} + + {stats.comment_count || 0} {stats.total_votes || 0} @@ -153,71 +170,52 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, {stats.group_consensus !== null ? stats.group_consensus.toFixed(2) : '-'} -
- - +
- - - - - - + @@ -169,17 +169,18 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, -
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' ? '↓' : '↑')} ActionAction
{stats.group_consensus !== null ? stats.group_consensus.toFixed(2) : '-'} -
+
+
- -
- -
- {hierarchyAnalysis.layers.slice().reverse().map(layerId => ( - - ))} -
- - {selectedTopics.size > 0 && ( -
-
{selectedTopics.size} topics selected for filtering
- -
- )} - - ); - }; - - // Render compact hierarchy analysis (moved to bottom) - const renderCompactAnalysis = () => { - if (!hierarchyAnalysis) return null; - - return ( -
-

Topic Structure Overview

-
- {hierarchyAnalysis.structure.toUpperCase()} - - {hierarchyAnalysis.layers.map(layerId => - `L${layerId}:${hierarchyAnalysis.layerCounts[layerId]}` - ).join(' • ')} - -
-
- ); - }; - - if (loading) { - return ( -
-

Topic Prioritize

-
Loading topic data...
-
- ); - } - - if (error) { - return ( -
-

Topic Prioritize

-
-

Error

-

{error}

-
-
- ); - } - - return ( -
- {renderLayerNavigation()} - -
- {renderPriorityLayer()} -
- - - -
- ); -}; - -export default TopicPrioritize; \ No newline at end of file diff --git a/client-report/src/components/topicPrioritizeSimple/TopicPrioritizeSimple.jsx b/client-report/src/components/topicPrioritizeSimple/TopicPrioritizeSimple.jsx deleted file mode 100644 index 90025c323c..0000000000 --- a/client-report/src/components/topicPrioritizeSimple/TopicPrioritizeSimple.jsx +++ /dev/null @@ -1,338 +0,0 @@ -import React, { useState, useEffect } from "react"; -import net from "../../util/net"; -import { useReportId } from "../framework/useReportId"; - -const TopicPrioritizeSimple = ({ conversation }) => { - const { report_id } = useReportId(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [topicData, setTopicData] = useState(null); - const [selectedTopics, setSelectedTopics] = useState(new Set()); - - useEffect(() => { - if (!report_id) return; - - setLoading(true); - // Fetch topic data from Delphi endpoint - net - .polisGet("/api/v3/delphi", { - report_id: report_id, - }) - .then((response) => { - console.log("TopicMod topics response:", response); - - if (response && response.status === "success") { - if (response.runs && Object.keys(response.runs).length > 0) { - setTopicData(response); - } else { - setError("No LLM topic data available yet. Run Delphi analysis first."); - } - } else { - setError("Failed to retrieve topic data"); - } - - setLoading(false); - }) - .catch((err) => { - console.error("Error fetching topic data:", err); - setError("Failed to connect to the topicMod endpoint"); - setLoading(false); - }); - }, [report_id]); - - const toggleTopicSelection = (topicKey) => { - const newSelected = new Set(selectedTopics); - if (newSelected.has(topicKey)) { - newSelected.delete(topicKey); - } else { - newSelected.add(topicKey); - } - setSelectedTopics(newSelected); - }; - - const renderTopicSelection = () => { - if (!topicData || !topicData.runs) { - return
No topic data available
; - } - - const runKeys = Object.keys(topicData.runs); - const firstRun = topicData.runs[runKeys[0]]; - - if (!firstRun.topics_by_layer || !firstRun.topics_by_layer[3]) { - return
No topics found for the coarsest layer
; - } - - const coarsestTopics = firstRun.topics_by_layer[3]; - const topicEntries = Object.entries(coarsestTopics); - - return ( -
-
-

- Which should rank higher in priority? Help set the agenda for you and for everyone: ({selectedTopics.size} selected) -

-
- -
- {topicEntries.map(([clusterId, topic]) => { - const topicKey = topic.topic_key; - const isSelected = selectedTopics.has(topicKey); - - // Clean topic name - let displayName = topic.topic_name; - const layerClusterPrefix = `3_${clusterId}`; - if (displayName && displayName.startsWith(layerClusterPrefix)) { - displayName = displayName.substring(layerClusterPrefix.length).replace(/^:\s*/, ''); - } - - return ( - - ); - })} -
- - {selectedTopics.size > 0 && ( -
-

Selected Topics

-
- {Array.from(selectedTopics).map(topicKey => { - const [clusterId, topic] = Object.entries(coarsestTopics).find( - ([_, t]) => t.topic_key === topicKey - ) || []; - - if (!topic) return null; - - let displayName = topic.topic_name; - const layerClusterPrefix = `3_${clusterId}`; - if (displayName && displayName.startsWith(layerClusterPrefix)) { - displayName = displayName.substring(layerClusterPrefix.length).replace(/^:\s*/, ''); - } - - return ( -
- 3_{clusterId} - {displayName} -
- ); - })} -
-
- )} -
- ); - }; - - if (loading) { - return ( -
-
Loading topic data...
-
- ); - } - - if (error) { - return ( -
-
-

Error

-

{error}

-
-
- ); - } - - return ( -
- {renderTopicSelection()} - - -
- ); -}; - -export default TopicPrioritizeSimple; \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index a9f80dc644..454822f1ec 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1856,13 +1856,6 @@ helpersInitialized.then( /^\/topicsVizReport\/r?[0-9][0-9A-Za-z]+(\/.*)?/, fetchIndexForReportPage ); - // Topic Prioritize route for dense comment view and hierarchy analysis - app.get( - /^\/topicPrioritize\/r?[0-9][0-9A-Za-z]+(\/.*)?/, - function (req, res, next) { - return fetchIndexForReportPage(req, res, next); - } - ); // Topic Hierarchy route for circle pack visualization app.get( /^\/topicHierarchy\/r?[0-9][0-9A-Za-z]+(\/.*)?/, @@ -1889,12 +1882,6 @@ helpersInitialized.then( return fetchIndexForReportPage(req, res, next); } ); - 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]+(\/.*)?/, function (req, res, next) { diff --git a/server/src/routes/participation/topicPrioritize.ts b/server/src/routes/participation/topicPrioritize.ts deleted file mode 100644 index 28ef257e61..0000000000 --- a/server/src/routes/participation/topicPrioritize.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Request, Response } from "express"; -import { getZidFromConversationId } from "../../conversation"; -import logger from "../../utils/logger"; -import p from "../../db/pg-query"; - -/** - * Lightweight endpoint for the participation interface to check if topic prioritization is available - * - * This endpoint is designed specifically for client-participation-alpha to determine - * if a conversation has report and Delphi data available for the topic prioritization feature. - * - * Public endpoint - no authentication required - */ -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, - }); - } - - // 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[]; - - if (!reportResult || reportResult.length === 0) { - return res.json({ - status: "success", - conversation_id: zid, - has_report: false, - has_delphi_data: false, - message: "No report available for this conversation", - }); - } - - const report = reportResult[0]; - - // For now, we'll assume if there's a report, there might be Delphi data - // In the future, we could check DynamoDB for actual Delphi data existence - return res.json({ - status: "success", - conversation_id: zid, - report_id: report.report_id, - has_report: true, - has_delphi_data: true, // Optimistic - the client will verify when fetching - report_created: report.created, - }); - } catch (error) { - logger.error("Error in topicPrioritize endpoint:", error); - return res.status(500).json({ - status: "error", - message: "Internal server error", - has_report: false, - has_delphi_data: false, - }); - } -} From 435911ce321ec0918a75d2ba0e421653489ec285 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Mon, 4 Aug 2025 23:18:24 -0700 Subject: [PATCH 34/71] remove topicAgenda since now copied to ptpt alpha --- client-report/src/components/app.jsx | 40 +- .../components/topicAgenda/TopicAgenda.jsx | 200 ---------- .../topicAgenda/components/LayerHeader.jsx | 17 - .../components/ScrollableTopicsGrid.jsx | 211 ----------- .../components/TopicAgendaStyles.jsx | 346 ------------------ .../topicAgenda/components/TopicItem.jsx | 38 -- .../topicAgenda/hooks/useAgendaBuilder.js | 89 ----- .../topicAgenda/hooks/useTopicData.js | 180 --------- .../topicAgenda/utils/archetypeExtraction.js | 129 ------- .../topicAgenda/utils/topicFiltering.js | 106 ------ .../topicAgenda/utils/topicUtils.js | 33 -- server/app.ts | 6 - 12 files changed, 2 insertions(+), 1393 deletions(-) delete mode 100644 client-report/src/components/topicAgenda/TopicAgenda.jsx delete mode 100644 client-report/src/components/topicAgenda/components/LayerHeader.jsx delete mode 100644 client-report/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx delete mode 100644 client-report/src/components/topicAgenda/components/TopicAgendaStyles.jsx delete mode 100644 client-report/src/components/topicAgenda/components/TopicItem.jsx delete mode 100644 client-report/src/components/topicAgenda/hooks/useAgendaBuilder.js delete mode 100644 client-report/src/components/topicAgenda/hooks/useTopicData.js delete mode 100644 client-report/src/components/topicAgenda/utils/archetypeExtraction.js delete mode 100644 client-report/src/components/topicAgenda/utils/topicFiltering.js delete mode 100644 client-report/src/components/topicAgenda/utils/topicUtils.js diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index f75e36cd9c..c05d987a3f 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -27,15 +27,12 @@ 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"; import TopicMapNarrativeReport from "./topicMapNarrativeReport.jsx"; import TopicStats from "./topicStats/TopicStats.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" or "/topicStats/2arcefpshi" -const route_type = pathname.split("/")[1]; // "report", "narrativeReport", "commentsReport", "topicReport", "topicsVizReport", "exportReport", "topicPrioritize", "topicPrioritizeSimple", "topicAgenda", "topicHierarchy", or "topicStats" +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 route_type = pathname.split("/")[1]; // "report", "narrativeReport", "commentsReport", "topicReport", "topicsVizReport", "exportReport", "topicHierarchy", or "topicStats" const report_id = pathname.split("/")[2]; @@ -749,9 +746,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", }); @@ -766,38 +760,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") { diff --git a/client-report/src/components/topicAgenda/TopicAgenda.jsx b/client-report/src/components/topicAgenda/TopicAgenda.jsx deleted file mode 100644 index 1ed83e9277..0000000000 --- a/client-report/src/components/topicAgenda/TopicAgenda.jsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useReportId } from "../framework/useReportId"; -import { useTopicData } from "./hooks/useTopicData"; -import { extractArchetypalComments, serializeArchetypes } from "./utils/archetypeExtraction"; -import LayerHeader from "./components/LayerHeader"; -import ScrollableTopicsGrid from "./components/ScrollableTopicsGrid"; -import TopicAgendaStyles from "./components/TopicAgendaStyles"; - -const TopicAgenda = ({ conversation, comments }) => { - const { report_id } = useReportId(); - const { - loading, - error, - topicData, - hierarchyAnalysis, - clusterGroups, - fetchUMAPData - } = useTopicData(report_id); - - const [selections, setSelections] = useState(new Set()); - const [commentMap, setCommentMap] = useState(new Map()); - - // Build comment map for easy lookup - useEffect(() => { - if (comments && comments.length > 0) { - const map = new Map(); - comments.forEach(comment => { - // Store by both tid (as number) and as string for flexibility - map.set(comment.tid, comment.txt); - map.set(String(comment.tid), comment.txt); - }); - setCommentMap(map); - console.log(`Built comment map with ${map.size / 2} comments`); - } - }, [comments]); - - // Fetch UMAP data when topic data is loaded - useEffect(() => { - if (topicData && conversation) { - fetchUMAPData(conversation); - } - }, [topicData, conversation, fetchUMAPData]); - - // Load previous selections when component mounts - useEffect(() => { - if (conversation && conversation.conversation_id) { - loadPreviousSelections(); - } - }, [conversation]); - - const loadPreviousSelections = async () => { - try { - const response = await fetch(`/api/v3/topicAgenda/selections?conversation_id=${conversation.conversation_id}`, { - method: 'GET', - credentials: 'include' - }); - - const result = await response.json(); - - if (result.status === 'success' && result.data) { - // Convert stored selections back to topic keys - const storedSelections = new Set(); - result.data.archetypal_selections.forEach(selection => { - storedSelections.add(selection.topic_key); - }); - setSelections(storedSelections); - console.log('Loaded previous selections:', Array.from(storedSelections)); - } - } catch (error) { - console.error('Error loading previous selections:', error); - } - }; - - const toggleTopicSelection = (topicKey) => { - const newSelections = new Set(selections); - if (newSelections.has(topicKey)) { - newSelections.delete(topicKey); - } else { - newSelections.add(topicKey); - } - setSelections(newSelections); - }; - - const handleDone = async () => { - try { - // Convert topic selections to archetypal comments - console.log("Selected topics:", Array.from(selections)); - - // Extract archetypal comments from selections - const archetypes = extractArchetypalComments(selections, topicData, clusterGroups, commentMap); - console.log("Extracted archetypes:", archetypes); - - // Log in a more readable format - console.log("\n=== SELECTED ARCHETYPAL COMMENTS ==="); - archetypes.forEach(group => { - console.log(`\nTopic: Layer ${group.layerId}, Cluster ${group.clusterId}`); - group.archetypes.forEach((archetype, i) => { - console.log(` ${i + 1}. "${archetype.text}" (ID: ${archetype.commentId})`); - }); - }); - console.log("=====================================\n"); - - // Transform to API format - const apiSelections = archetypes.map(group => ({ - layer_id: group.layerId, - cluster_id: group.clusterId, - topic_key: group.topicKey, - archetypal_comments: group.archetypes.map(a => ({ - comment_id: a.commentId, - comment_text: a.text, - coordinates: a.coordinates, - distance_to_centroid: a.distance - })) - })); - - // Send to API - const response = await fetch('/api/v3/topicAgenda/selections', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - conversation_id: conversation.conversation_id, - selections: apiSelections - }), - credentials: 'include' - }); - - const result = await response.json(); - - if (result.status === 'success') { - console.log('Topic agenda selections saved successfully:', result.data); - // TODO: Show success UI feedback - } else { - console.error('Failed to save selections:', result.message); - // TODO: Show error UI feedback - } - - } catch (error) { - console.error('Error saving topic agenda selections:', error); - // TODO: Show error UI feedback - } - }; - - if (loading) { - return ( -
-
-
Loading topic data...
-
- -
- ); - } - - if (error) { - return ( -
-
-
-

Error

-

{error}

-
-
- -
- ); - } - - return ( -
-
-
- - - - -
- -
-
-
- -
- ); -}; - -export default TopicAgenda; \ No newline at end of file diff --git a/client-report/src/components/topicAgenda/components/LayerHeader.jsx b/client-report/src/components/topicAgenda/components/LayerHeader.jsx deleted file mode 100644 index 8834b9a836..0000000000 --- a/client-report/src/components/topicAgenda/components/LayerHeader.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; - -const LayerHeader = () => { - return ( -
-

Select Your Priority Topics

- -
- Choose topics that matter most to you, and set the agenda for you and others. - The more you select, the more specific options you'll see. - Topics are based on statements written by people in this conversation. -
-
- ); -}; - -export default LayerHeader; \ No newline at end of file diff --git a/client-report/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx b/client-report/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx deleted file mode 100644 index b03d48c73f..0000000000 --- a/client-report/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { useEffect, useState } from "react"; -import TopicItem from "./TopicItem"; -import { getFilteredTopics } from "../utils/topicFiltering"; - -const ScrollableTopicsGrid = ({ - topicData, - selections, - onToggleSelection, - clusterGroups, - hierarchyAnalysis -}) => { - const [visibleLayers, setVisibleLayers] = useState(new Set()); - - if (!topicData || !hierarchyAnalysis) return null; - - const runKeys = Object.keys(topicData.runs); - const firstRun = topicData.runs[runKeys[0]]; - - 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 coarsestLayer = sortedLayers[0]; // e.g., 7 - const secondCoarsestLayer = sortedLayers[1]; // e.g., 6 - - // CRITICAL FEATURE: Cascading Auto-population - // ================================================================ - // EVERY LAYER DRIVES THE NEXT LEVEL OF DETAIL! - // - // DESIGN PHILOSOPHY: - // This creates an infinitely explorable space where each selection - // opens up new, more specific possibilities. It's like zooming into - // a fractal - the deeper you go, the more detail you discover. - // - // HOW IT WORKS: - // 1. Start with the two coarsest layers visible - // 2. Select from layer 2 → reveals nearby topics in layer 1 - // 3. Select from layer 1 → reveals nearby topics in layer 0 - // 4. And so on... each selection cascades down to finer layers - // - // THE CASCADE EFFECT: - // - Layer 3 (coarsest): Always visible as a safety net - // - Layer 2: Always visible, first driver of specificity - // - Layer 1: Appears when Layer 2 has selections - // - Layer 0: Appears when Layer 1 has selections - // - Future layers: Continue the pattern... - // - // SPATIAL PROXIMITY RULES: - // - We use UMAP coordinates to find "nearby" topics - // - Distance threshold gets tighter as you go deeper (more selective) - // - This ensures relevance increases with depth - // - // USER EXPERIENCE: - // - Feels like having a conversation that gets more specific - // - Never overwhelming - only shows what's relevant - // - Creates a sense of discovery and exploration - // - Users can stop at any level when they've found what they want - // - // IMPLEMENTATION NOTE: - // We track which layers should be visible based on selections - // in their parent layers. This creates a dependency chain where - // each layer's visibility depends on selections in the layer above. - // ================================================================ - - useEffect(() => { - if (!firstRun || !firstRun.topics_by_layer) return; - - const newVisibleLayers = new Set(); - - // Build a map of selections by layer - const selectionsByLayer = new Map(); - - // Categorize all selections by their layer - 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] || {}) - .find(t => t.topic_key === topicKey); - if (topic) { - if (!selectionsByLayer.has(layerId)) { - selectionsByLayer.set(layerId, new Set()); - } - selectionsByLayer.get(layerId).add(topicKey); - break; - } - } - }); - - // For each layer, check if its parent layer has selections - // Note: sortedLayers is ordered from coarsest to finest (e.g., [2, 1, 0]) - sortedLayers.forEach((layerId, index) => { - if (index < 2) return; // Skip the first two layers (always visible) - - const parentLayer = sortedLayers[index - 1]; // Parent is the previous in the array - - if (selectionsByLayer.has(parentLayer)) { - // Parent layer has selections, this layer should be visible - newVisibleLayers.add(layerId); - } - }); - - setVisibleLayers(newVisibleLayers); - }, [selections, sortedLayers.join(','), !!firstRun]); // Stable dependencies - - const renderLayerTopics = (layerId, layerLabel, parentLayerId = null) => { - const allTopics = firstRun.topics_by_layer[layerId]; - if (!allTopics) return null; - - let topicEntries; - - if (parentLayerId !== null) { - // This is a dynamically shown layer - filter by proximity to parent selections - const selectionsByLayer = new Map(); - - // Get selections from the parent layer - Array.from(selections).forEach(topicKey => { - const topic = Object.values(firstRun.topics_by_layer[parentLayerId] || {}) - .find(t => t.topic_key === topicKey); - if (topic) { - if (!selectionsByLayer.has(parentLayerId)) { - selectionsByLayer.set(parentLayerId, new Set()); - } - selectionsByLayer.get(parentLayerId).add(topicKey); - } - }); - - if (selectionsByLayer.size === 0) return null; - - // Get filtered topics based on proximity - const filteredTopics = getFilteredTopics( - allTopics, - layerId, - hierarchyAnalysis, - selectionsByLayer, - clusterGroups - ); - - // Apply distance threshold that gets tighter as we go deeper - const layerDepth = sortedLayers.indexOf(layerId); - const distanceThreshold = 3.0 - (layerDepth * 0.5); // 3.0, 2.5, 2.0, 1.5... - - topicEntries = filteredTopics.filter(entry => - entry.proximityScore !== null && entry.proximityScore < Math.max(distanceThreshold, 1.0) - ); - - if (topicEntries.length === 0) return null; - } else { - // This is a static layer - show all topics - topicEntries = Object.entries(allTopics).map(([clusterId, topic]) => ({ - clusterId, - topic, - proximityScore: null, - source: 'all' - })); - } - - return ( - - {layerLabel && ( -
- {layerLabel} -
- )} - {topicEntries.map(entry => ( - - ))} -
- ); - }; - - // Determine layer labels based on depth - const getLayerLabel = (layerId, index) => { - if (index === 0) return null; // Coarsest layer has no label - if (index === 1) return "More Specific Topics"; - if (index === 2) return "SUPER SPECIFIC TOPICS"; - return null; // No labels for deeper layers - }; - - return ( -
-
- {/* Always show the two coarsest layers */} - {renderLayerTopics(coarsestLayer, null)} - {secondCoarsestLayer !== undefined && - renderLayerTopics(secondCoarsestLayer, "More Specific Topics")} - - {/* Show additional layers based on selections in parent layers */} - {sortedLayers.slice(2).map((layerId, index) => { - if (!visibleLayers.has(layerId)) return null; - - const parentLayer = sortedLayers[index + 1]; // Parent is the previous in sorted order - return renderLayerTopics( - layerId, - getLayerLabel(layerId, index + 2), - parentLayer - ); - })} -
-
- ); -}; - -export default ScrollableTopicsGrid; \ No newline at end of file diff --git a/client-report/src/components/topicAgenda/components/TopicAgendaStyles.jsx b/client-report/src/components/topicAgenda/components/TopicAgendaStyles.jsx deleted file mode 100644 index 5032d76184..0000000000 --- a/client-report/src/components/topicAgenda/components/TopicAgendaStyles.jsx +++ /dev/null @@ -1,346 +0,0 @@ -import React from "react"; - -const TopicAgendaStyles = () => ( - -); - -export default TopicAgendaStyles; diff --git a/client-report/src/components/topicAgenda/components/TopicItem.jsx b/client-report/src/components/topicAgenda/components/TopicItem.jsx deleted file mode 100644 index ba296ef319..0000000000 --- a/client-report/src/components/topicAgenda/components/TopicItem.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; -import { getCommentCount, cleanTopicDisplayName } from "../utils/topicUtils"; - -const TopicItem = ({ - entry, - layerId, - isSelected, - onToggleSelection, - clusterGroups, - isBanked = false -}) => { - const { clusterId, topic, proximityScore, closestBankedTopic } = entry; - const topicKey = topic.topic_key; - const commentCount = getCommentCount(layerId, clusterId, clusterGroups); - const displayName = cleanTopicDisplayName(topic.topic_name, layerId, clusterId); - - return ( -
onToggleSelection(topicKey)} - > -
- {displayName} - {isSelected && ( - - - - - )} -
-
- ); -}; - -export default TopicItem; diff --git a/client-report/src/components/topicAgenda/hooks/useAgendaBuilder.js b/client-report/src/components/topicAgenda/hooks/useAgendaBuilder.js deleted file mode 100644 index f3480f1053..0000000000 --- a/client-report/src/components/topicAgenda/hooks/useAgendaBuilder.js +++ /dev/null @@ -1,89 +0,0 @@ -import { useState, useEffect } from "react"; - -export const useAgendaBuilder = (hierarchyAnalysis) => { - const [currentLayer, setCurrentLayer] = useState(null); - const [bankedTopics, setBankedTopics] = useState(new Map()); - const [currentSelections, setCurrentSelections] = useState(new Set()); - const [completedLayers, setCompletedLayers] = useState(new Set()); - - // Set current layer to the highest available layer when hierarchy is loaded - useEffect(() => { - if (currentLayer === null && hierarchyAnalysis && hierarchyAnalysis.layers.length > 0) { - const maxLayer = Math.max(...hierarchyAnalysis.layers); - setCurrentLayer(maxLayer); - console.log(`Setting current layer to highest available: ${maxLayer}`); - } - }, [hierarchyAnalysis, currentLayer]); - - const toggleTopicSelection = (topicKey) => { - const newSelections = new Set(currentSelections); - if (newSelections.has(topicKey)) { - newSelections.delete(topicKey); - } else { - newSelections.add(topicKey); - } - setCurrentSelections(newSelections); - }; - - const bankAndClear = () => { - if (currentSelections.size === 0) { - alert("Please select at least one topic to bank before proceeding."); - return; - } - - // Bank the current selections - const newBankedTopics = new Map(bankedTopics); - newBankedTopics.set(currentLayer, new Set(currentSelections)); - setBankedTopics(newBankedTopics); - - // Mark current layer as completed - const newCompletedLayers = new Set(completedLayers); - newCompletedLayers.add(currentLayer); - setCompletedLayers(newCompletedLayers); - - // Clear current selections - setCurrentSelections(new Set()); - - // Move to next layer (lower number = finer granularity) - const nextLayer = currentLayer - 1; - const minLayer = hierarchyAnalysis ? Math.min(...hierarchyAnalysis.layers) : 0; - - if ( - nextLayer >= minLayer && - hierarchyAnalysis && - hierarchyAnalysis.layers.includes(nextLayer) - ) { - setCurrentLayer(nextLayer); - console.log( - `Banked ${currentSelections.size} topics from Layer ${currentLayer}, moving to Layer ${nextLayer}` - ); - } else { - // Set currentLayer to null to indicate completion - setCurrentLayer(null); - console.log( - `Agenda building complete! Banked topics from ${newCompletedLayers.size} layers.` - ); - } - }; - - const resetAgenda = () => { - setBankedTopics(new Map()); - setCurrentSelections(new Set()); - setCompletedLayers(new Set()); - if (hierarchyAnalysis && hierarchyAnalysis.layers.length > 0) { - const maxLayer = Math.max(...hierarchyAnalysis.layers); - setCurrentLayer(maxLayer); - } - }; - - return { - currentLayer, - bankedTopics, - currentSelections, - completedLayers, - setCurrentSelections, - toggleTopicSelection, - bankAndClear, - resetAgenda, - }; -}; diff --git a/client-report/src/components/topicAgenda/hooks/useTopicData.js b/client-report/src/components/topicAgenda/hooks/useTopicData.js deleted file mode 100644 index 40f70b67a7..0000000000 --- a/client-report/src/components/topicAgenda/hooks/useTopicData.js +++ /dev/null @@ -1,180 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import net from "../../../util/net"; - -export const useTopicData = (reportId) => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [topicData, setTopicData] = useState(null); - const [hierarchyAnalysis, setHierarchyAnalysis] = useState(null); - const [umapData, setUmapData] = useState(null); - const [clusterGroups, setClusterGroups] = useState({}); - - const analyzeHierarchy = (data) => { - const runKeys = Object.keys(data.runs); - if (runKeys.length === 0) { - setHierarchyAnalysis({ hasHierarchy: false, reason: "No runs data" }); - return; - } - - const firstRun = data.runs[runKeys[0]]; - if (!firstRun.topics_by_layer) { - setHierarchyAnalysis({ hasHierarchy: false, reason: "No topics_by_layer data in run" }); - return; - } - - const layers = Object.keys(firstRun.topics_by_layer) - .map((k) => parseInt(k)) - .sort((a, b) => a - b); - console.log("Analyzing layers:", layers); - - const analysis = { - hasHierarchy: false, - layers: layers, - layerCounts: {}, - sampleTopics: {}, - totalComments: 0, - structure: "unknown", - runInfo: { - model_name: firstRun.model_name, - created_at: firstRun.created_at, - job_uuid: firstRun.job_uuid, - }, - }; - - layers.forEach((layerId) => { - const topics = firstRun.topics_by_layer[layerId]; - analysis.layerCounts[layerId] = Object.keys(topics).length; - - analysis.sampleTopics[layerId] = Object.values(topics) - .slice(0, 3) - .map((topic) => ({ - name: topic.topic_name, - key: topic.topic_key, - cluster_id: topic.cluster_id, - model_name: topic.model_name, - })); - }); - - const counts = Object.values(analysis.layerCounts); - const hasVariedCounts = Math.max(...counts) !== Math.min(...counts); - - if (hasVariedCounts && layers.length > 1) { - analysis.hasHierarchy = true; - analysis.structure = "hierarchical"; - analysis.reason = `Found ${layers.length} layers with varying topic counts: ${counts.join( - ", " - )}`; - } else if (layers.length === 1) { - analysis.structure = "flat"; - analysis.reason = "Only one layer found - flat structure"; - } else { - analysis.structure = "unclear"; - analysis.reason = "Multiple layers but similar counts - unclear hierarchy"; - } - - console.log("Hierarchy analysis:", analysis); - setHierarchyAnalysis(analysis); - }; - - const groupPointsByLayer = (data) => { - const groups = {}; - const allClusterIds = new Set(); - - for (let layer = 0; layer <= 3; layer++) { - groups[layer] = new Map(); - } - - data.forEach((point) => { - Object.entries(point.clusters || {}).forEach(([layerId, clusterId]) => { - const layer = parseInt(layerId); - const key = `${layer}_${clusterId}`; - - if (layer === 0) { - allClusterIds.add(clusterId); - } - - if (!groups[layer].has(key)) { - groups[layer].set(key, []); - } - - groups[layer].get(key).push({ - comment_id: point.comment_id, - cluster_id: clusterId, - layer: layer, - umap_x: point.umap_x, - umap_y: point.umap_y, - weight: point.weight || 1, - }); - }); - }); - - return groups; - }; - - const fetchUMAPData = useCallback(async (conversation) => { - try { - const conversationId = conversation?.conversation_id || reportId; - console.log("Fetching UMAP data for spatial filtering..."); - - const response = await fetch( - `/api/v3/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`); - setUmapData(data.proximity_data); - - const groups = groupPointsByLayer(data.proximity_data); - setClusterGroups(groups); - - console.log("UMAP cluster groups:", groups); - } else { - console.log("No UMAP data available for spatial filtering"); - } - } catch (err) { - console.error("Error fetching UMAP data:", err); - } - }, [reportId]); - - useEffect(() => { - if (!reportId) return; - - setLoading(true); - net - .polisGet("/api/v3/delphi", { - report_id: reportId, - }) - .then((response) => { - console.log("TopicAgenda topics response:", response); - - if (response && response.status === "success") { - if (response.runs && Object.keys(response.runs).length > 0) { - setTopicData(response); - analyzeHierarchy(response); - } else { - setError("No LLM topic data available yet. Run Delphi analysis first."); - } - } else { - setError("Failed to retrieve topic data"); - } - - setLoading(false); - }) - .catch((err) => { - console.error("Error fetching topic data:", err); - setError("Failed to connect to the topicMod endpoint"); - setLoading(false); - }); - }, [reportId]); - - return { - loading, - error, - topicData, - hierarchyAnalysis, - umapData, - clusterGroups, - fetchUMAPData, - }; -}; diff --git a/client-report/src/components/topicAgenda/utils/archetypeExtraction.js b/client-report/src/components/topicAgenda/utils/archetypeExtraction.js deleted file mode 100644 index 664280074b..0000000000 --- a/client-report/src/components/topicAgenda/utils/archetypeExtraction.js +++ /dev/null @@ -1,129 +0,0 @@ -import { calculateClusterCentroid, calculateDistance } from './topicUtils'; - -/** - * Extract archetypal comments from topic selections - * These serve as stable anchor points across Delphi runs - * - * STRATEGY: - * 1. For each selected topic, find its cluster in UMAP space - * 2. Identify the most representative comments (archetypes) - * 3. Return comment IDs that persist across topic model updates - * - * WHY THIS MATTERS: - * - Topic names/clusters change between Delphi runs - * - But the underlying comments remain stable - * - By storing comment IDs instead of topic IDs, we maintain consistency - * - These archetypal comments represent what users actually care about - */ -export const extractArchetypalComments = (selections, topicData, clusterGroups, commentMap = new Map()) => { - const archetypeComments = []; - - // Parse selections to extract layer and cluster info - selections.forEach(topicKey => { - // Topic key formats: - // Old: "4c5b018b-51ac-4a3e-9d41-6307a73ebf68#2#3" - // New: "layer3_9" - - let layerId, clusterId; - - if (topicKey.startsWith('layer')) { - // New format: "layer3_9" - const match = topicKey.match(/layer(\d+)_(\d+)/); - if (match) { - layerId = parseInt(match[1]); - clusterId = match[2]; - } - } else { - // Old format with # separators - const parts = topicKey.split('#'); - if (parts.length >= 3) { - layerId = parseInt(parts[parts.length - 2]); - clusterId = parts[parts.length - 1]; - } - } - - if (layerId !== undefined && clusterId !== undefined) { - - // Find the cluster in clusterGroups - const clusterKey = `${layerId}_${clusterId}`; - const clusterPoints = clusterGroups[layerId]?.get(clusterKey); - - if (clusterPoints && clusterPoints.length > 0) { - // Strategy 1: Get comments closest to cluster centroid - const centroid = calculateClusterCentroid(clusterPoints); - - if (centroid) { - // Sort points by distance to centroid - const sortedPoints = clusterPoints - .map(point => ({ - ...point, - distanceToCentroid: calculateDistance( - { x: point.umap_x, y: point.umap_y }, - centroid - ) - })) - .sort((a, b) => a.distanceToCentroid - b.distanceToCentroid); - - // Take the top N most central comments as archetypes - const numArchetypes = Math.min(3, sortedPoints.length); - const archetypes = sortedPoints.slice(0, numArchetypes); - - archetypeComments.push({ - topicKey, - layerId, - clusterId, - archetypes: archetypes.map(a => { - // Try to get comment text from the map (comment_id might be string or number) - const commentText = commentMap.get(a.comment_id) || - commentMap.get(parseInt(a.comment_id)) || - commentMap.get(String(a.comment_id)) || - a.comment_text || - `[Comment ${a.comment_id}]`; - console.log(`Archetype comment ${a.comment_id}: "${commentText}"`); - return { - commentId: a.comment_id, - text: commentText, - distance: a.distanceToCentroid, - coordinates: { x: a.umap_x, y: a.umap_y } - }; - }) - }); - } else { - console.log(`No cluster points found for ${clusterKey}`); - } - } else { - console.log(`No cluster points found for layer ${layerId}, cluster ${clusterId}`); - } - } - }); - - return archetypeComments; -}; - -/** - * Convert archetypal comments to a format suitable for storage - * This creates a stable representation that survives Delphi re-runs - */ -export const serializeArchetypes = (archetypeComments) => { - // Flatten to just comment IDs and their coordinates - const stableAnchors = []; - - archetypeComments.forEach(group => { - group.archetypes.forEach(archetype => { - stableAnchors.push({ - commentId: archetype.commentId, - text: archetype.text, // Include text for debugging - coordinates: archetype.coordinates, - sourceLayer: group.layerId, - sourceCluster: group.clusterId - }); - }); - }); - - return { - version: 1, - timestamp: new Date().toISOString(), - anchors: stableAnchors, - totalSelections: archetypeComments.length - }; -}; \ No newline at end of file diff --git a/client-report/src/components/topicAgenda/utils/topicFiltering.js b/client-report/src/components/topicAgenda/utils/topicFiltering.js deleted file mode 100644 index ef54363b4d..0000000000 --- a/client-report/src/components/topicAgenda/utils/topicFiltering.js +++ /dev/null @@ -1,106 +0,0 @@ -import { calculateClusterCentroid, calculateDistance } from './topicUtils'; - -// Get filtered topics for current layer based on spatial proximity to banked topics -export const getFilteredTopics = (allTopics, layerId, hierarchyAnalysis, bankedTopics, clusterGroups) => { - const maxLayer = hierarchyAnalysis ? Math.max(...hierarchyAnalysis.layers) : layerId; - - if (layerId === maxLayer || bankedTopics.size === 0) { - return Object.entries(allTopics).map(([clusterId, topic]) => ({ - clusterId, - topic, - proximityScore: null, - source: 'all' - })); - } - - // For subsequent layers, filter based on proximity to banked topics - const higherLayerId = layerId + 1; - const bankedFromHigherLayer = bankedTopics.get(higherLayerId); - - if (!bankedFromHigherLayer || !clusterGroups[higherLayerId] || !clusterGroups[layerId]) { - return Object.entries(allTopics).map(([clusterId, topic]) => ({ - clusterId, - topic, - proximityScore: null, - source: 'all' - })); - } - - // Calculate proximity to banked topics - const adaptiveDistance = 4.0; - - const topicsWithProximity = Object.entries(allTopics).map(([clusterId, topic]) => { - const clusterKey = `${layerId}_${clusterId}`; - const targetPoints = clusterGroups[layerId].get(clusterKey); - - let minProximity = Infinity; - let closestBankedTopic = null; - - if (targetPoints && targetPoints.length > 0) { - const targetCentroid = calculateClusterCentroid(targetPoints); - if (targetCentroid) { - // Check distance to each banked topic - bankedFromHigherLayer.forEach(bankedTopicKey => { - // Extract cluster info from topic key - let bankedClusterId; - if (bankedTopicKey.includes('#')) { - // Format: "2_uuid#2#6" -> clusterId = "6" - const parts = bankedTopicKey.split('#'); - bankedClusterId = parts[parts.length - 1]; - } else if (bankedTopicKey.includes('_')) { - // Format: "2_6" -> clusterId = "6" - const parts = bankedTopicKey.split('_'); - bankedClusterId = parts[parts.length - 1]; - } - - const bankedClusterKey = `${higherLayerId}_${bankedClusterId}`; - const bankedPoints = clusterGroups[higherLayerId].get(bankedClusterKey); - - if (bankedPoints && bankedPoints.length > 0) { - const bankedCentroid = calculateClusterCentroid(bankedPoints); - if (bankedCentroid) { - const distance = calculateDistance(targetCentroid, bankedCentroid); - if (distance < minProximity) { - minProximity = distance; - closestBankedTopic = bankedClusterKey; - } - } - } - }); - } - } - - const finalScore = minProximity === Infinity ? null : minProximity; - - return { - clusterId, - topic, - proximityScore: finalScore, - closestBankedTopic: closestBankedTopic, - source: (minProximity !== Infinity && minProximity <= adaptiveDistance) ? 'close' : 'far' - }; - }); - - // For coarsest and second coarsest layers: show all topics, just sort by proximity - // For finest layers: apply the proximity filtering and hide nulls - let filteredTopics; - if (layerId === maxLayer - 1) { - // Second coarsest layer: show all topics - filteredTopics = topicsWithProximity; - } else { - // Finest layers: apply proximity filtering and hide topics without distance data - filteredTopics = topicsWithProximity.filter(item => - item.source === 'close' - ); - } - - // Sort by proximity score (closest first, then nulls at end) - const sortedTopics = filteredTopics.sort((a, b) => { - if (a.proximityScore === null && b.proximityScore === null) return 0; - if (a.proximityScore === null) return 1; - if (b.proximityScore === null) return -1; - return a.proximityScore - b.proximityScore; - }); - - return sortedTopics; -}; diff --git a/client-report/src/components/topicAgenda/utils/topicUtils.js b/client-report/src/components/topicAgenda/utils/topicUtils.js deleted file mode 100644 index 2aff33c641..0000000000 --- a/client-report/src/components/topicAgenda/utils/topicUtils.js +++ /dev/null @@ -1,33 +0,0 @@ -// Calculate cluster centroid in UMAP space -export const calculateClusterCentroid = (clusterPoints) => { - if (!clusterPoints || clusterPoints.length === 0) return null; - const centroidX = clusterPoints.reduce((sum, p) => sum + p.umap_x, 0) / clusterPoints.length; - const centroidY = clusterPoints.reduce((sum, p) => sum + p.umap_y, 0) / clusterPoints.length; - return { x: centroidX, y: centroidY }; -}; - -// Calculate Euclidean distance between two points -export const calculateDistance = (point1, point2) => { - return Math.sqrt( - Math.pow(point1.x - point2.x, 2) + - Math.pow(point1.y - point2.y, 2) - ); -}; - -// Get comment count for a cluster -export const getCommentCount = (layerId, clusterId, clusterGroups) => { - const clusterKey = `${layerId}_${clusterId}`; - const points = clusterGroups[layerId]?.get(clusterKey); - return points ? points.length : 0; -}; - -// Clean topic display name by removing layer/cluster prefix -export const cleanTopicDisplayName = (topicName, layerId, clusterId) => { - if (!topicName) return `Topic ${clusterId}`; - - const layerClusterPrefix = `${layerId}_${clusterId}`; - if (topicName.startsWith(layerClusterPrefix)) { - return topicName.substring(layerClusterPrefix.length).replace(/^:\s*/, ''); - } - return topicName; -}; diff --git a/server/app.ts b/server/app.ts index 454822f1ec..333c0a1cf6 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1882,12 +1882,6 @@ helpersInitialized.then( return fetchIndexForReportPage(req, res, next); } ); - app.get( - /^\/topicAgenda\/r?[0-9][0-9A-Za-z]+(\/.*)?/, - function (req, res, next) { - return fetchIndexForReportPage(req, res, next); - } - ); app.get( /^\/embed$/, From 7d7f9b4b9353a92b8a544066011c5fd921e51c10 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Mon, 4 Aug 2025 23:40:27 -0700 Subject: [PATCH 35/71] put back participnat topic prioritize --- .../routes/participation/topicPrioritize.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 server/src/routes/participation/topicPrioritize.ts diff --git a/server/src/routes/participation/topicPrioritize.ts b/server/src/routes/participation/topicPrioritize.ts new file mode 100644 index 0000000000..8f20cf8911 --- /dev/null +++ b/server/src/routes/participation/topicPrioritize.ts @@ -0,0 +1,76 @@ +import { Request, Response } from "express"; +import { getZidFromConversationId } from "../../conversation"; +import logger from "../../utils/logger"; +import p from "../../db/pg-query"; + +/** + * Lightweight endpoint for the participation interface to check if topic prioritization is available + * + * This endpoint is designed specifically for client-participation-alpha to determine + * if a conversation has report and Delphi data available for the topic prioritization feature. + * + * Public endpoint - no authentication required + */ +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, + }); + } + + // 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[]; + + if (!reportResult || reportResult.length === 0) { + return res.json({ + status: "success", + conversation_id: zid, + has_report: false, + has_delphi_data: false, + message: "No report available for this conversation", + }); + } + + const report = reportResult[0]; + + // For now, we'll assume if there's a report, there might be Delphi data + // In the future, we could check DynamoDB for actual Delphi data existence + return res.json({ + status: "success", + conversation_id: zid, + report_id: report.report_id, + has_report: true, + has_delphi_data: true, // Optimistic - the client will verify when fetching + report_created: report.created, + }); + } catch (error) { + logger.error("Error in topicPrioritize endpoint:", error); + return res.status(500).json({ + status: "error", + message: "Internal server error", + has_report: false, + has_delphi_data: false, + }); + } +} \ No newline at end of file From c677471ccf07ff37186c8024068e45e563a96cf4 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Mon, 4 Aug 2025 23:40:40 -0700 Subject: [PATCH 36/71] lint hierarchy --- .../topicHierarchy/TopicHierarchy.jsx | 553 ++++++++++-------- 1 file changed, 322 insertions(+), 231 deletions(-) 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; From 6cb1551db517ff8b7f8e38828dd0a9975dbab2d9 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Tue, 5 Aug 2025 21:43:59 -0700 Subject: [PATCH 37/71] Normalized group aware consensus calculation --- client-report/src/components/app.jsx | 9 +- .../visualizations/TopicBeeswarm.jsx | 25 ++- client-report/src/util/normalizeConsensus.js | 68 ++++++++ .../src/util/normalizeConsensus.test.js | 84 ++++++++++ docs/consensus_normalization_discussion.md | 147 ++++++++++++++++++ 5 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 client-report/src/util/normalizeConsensus.js create mode 100644 client-report/src/util/normalizeConsensus.test.js create mode 100644 docs/consensus_normalization_discussion.md diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index c05d987a3f..1cc2b56710 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -30,6 +30,7 @@ import TopicsVizReport from "./topicsVizReport/TopicsVizReport.jsx"; import TopicHierarchy from "./topicHierarchy/TopicHierarchy.jsx"; import TopicMapNarrativeReport from "./topicMapNarrativeReport.jsx"; import TopicStats from "./topicStats/TopicStats.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 route_type = pathname.split("/")[1]; // "report", "narrativeReport", "commentsReport", "topicReport", "topicsVizReport", "exportReport", "topicHierarchy", or "topicStats" @@ -542,7 +543,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; @@ -557,6 +561,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); diff --git a/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx b/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx index f97f9470f0..71d4b5f5a0 100644 --- a/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicBeeswarm.jsx @@ -73,14 +73,18 @@ const TopicBeeswarm = ({ comments, commentTids, math, conversation, ptptCount, f } const setup = () => { - if (!comments || !commentTids || !math || !math["group-aware-consensus"]) return; + 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 = math["group-aware-consensus"][comment.tid]; + 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) { @@ -95,21 +99,12 @@ const TopicBeeswarm = ({ comments, commentTids, math, conversation, ptptCount, f if (commentsWithConsensusData.length === 0) return; - // Find actual data extent with some padding - const consensusValues = commentsWithConsensusData.map(d => d.groupConsensus); - const minConsensus = Math.min(...consensusValues); - const maxConsensus = Math.max(...consensusValues); - - // Add 5% padding to show all points clearly - const padding = (maxConsensus - minConsensus) * 0.05; - const paddedMin = Math.max(0, minConsensus - padding); - const paddedMax = Math.min(1, maxConsensus + padding); - - setDataExtent([paddedMin, paddedMax]); + // Always use fixed scale from 0 to 1 + setDataExtent([0, 1]); - // Create x scale based on actual data range + // Create x scale with fixed domain [0, 1] const x = window.d3.scaleLinear() - .domain([paddedMin, paddedMax]) + .domain([0, 1]) .rangeRound([0, widthMinusMargins]); // Run force simulation 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/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._ From b027fc2145a8d83671fec003d3ea84f6f85dab74 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Wed, 6 Aug 2025 18:19:10 -0700 Subject: [PATCH 38/71] thresholding .05 3@.8 --- .../src/components/topicPage/TopicPage.jsx | 74 +++++++-- .../topicStats/CollectiveStatementModal.jsx | 34 ++-- .../topicStats/LayerDistributionModal.jsx | 8 +- .../visualizations/AllCommentsScatterplot.jsx | 10 +- .../TopicOverviewScatterplot.jsx | 10 +- .../topicStats/visualizations/TopicTables.jsx | 67 ++++++-- client-report/src/util/consensusThreshold.js | 153 ++++++++++++++++++ server/src/routes/collectiveStatement.ts | 60 +++++-- 8 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 client-report/src/util/consensusThreshold.js diff --git a/client-report/src/components/topicPage/TopicPage.jsx b/client-report/src/components/topicPage/TopicPage.jsx index c0b7d89caf..9d17d9d8c3 100644 --- a/client-report/src/components/topicPage/TopicPage.jsx +++ b/client-report/src/components/topicPage/TopicPage.jsx @@ -6,6 +6,7 @@ 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); @@ -90,11 +91,12 @@ const TopicPage = ({ conversation, report_id, topic_key, math, comments, ptptCou setTopicComments(topicCommentsData); // Sort by group consensus - if (math && math["group-aware-consensus"]) { + const consensusData = math?.["group-consensus-normalized"] || math?.["group-aware-consensus"]; + if (consensusData) { const sorted = topicCommentsData .map(comment => ({ ...comment, - groupConsensus: math["group-aware-consensus"][comment.tid] || 0 + groupConsensus: consensusData[comment.tid] || 0 })) .sort((a, b) => b.groupConsensus - a.groupConsensus); setSortedComments(sorted); @@ -120,28 +122,45 @@ const TopicPage = ({ conversation, report_id, topic_key, math, comments, ptptCou try { setLoadingStatement(true); - // Get group consensus values for filtering - const relevantConsensus = {}; - if (math && math["group-aware-consensus"] && topicStats.comment_tids) { - topicStats.comment_tids.forEach(tid => { - if (math["group-aware-consensus"][tid] !== undefined) { - relevantConsensus[tid] = math["group-aware-consensus"][tid]; - } + // 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, - consensusCount: Object.keys(relevantConsensus).length + 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 + group_consensus: relevantConsensus, + qualifying_tids: qualifyingTids // Send the list of qualifying comment IDs }); console.log("Collective statement response:", response); @@ -382,11 +401,21 @@ const TopicPage = ({ conversation, report_id, topic_key, math, comments, ptptCou }}>

Collective Statement

+ }}>Candidate Collective Statement

+

+ Based on voting trends thus far +

{loadingStatement && (
)} - {collectiveStatement && !collectiveStatement.error && (() => { + {collectiveStatement && collectiveStatement.insufficient && ( +
+

+ Insufficient consensus: {collectiveStatement.message} +

+

+ 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 || []; diff --git a/client-report/src/components/topicStats/CollectiveStatementModal.jsx b/client-report/src/components/topicStats/CollectiveStatementModal.jsx index 4a4413c5fb..45117738a9 100644 --- a/client-report/src/components/topicStats/CollectiveStatementModal.jsx +++ b/client-report/src/components/topicStats/CollectiveStatementModal.jsx @@ -1,6 +1,7 @@ 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, @@ -52,21 +53,33 @@ const CollectiveStatementModal = ({ topicCommentIds = topicStats.stats[topicKey].comment_tids || []; } - // Extract only the group consensus values we need - const relevantConsensus = {}; - if (math && math["group-aware-consensus"]) { - topicCommentIds.forEach(tid => { - if (math["group-aware-consensus"][tid] !== undefined) { - relevantConsensus[tid] = math["group-aware-consensus"][tid]; - } - }); + // 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") { @@ -176,8 +189,9 @@ const CollectiveStatementModal = ({ overflow: "visible", maxHeight: "80vh" }}> -

{topicName}

-

Candidate Collective Statement

+

{topicName}

+

Candidate Collective Statement

+

Based on voting trends thus far

diff --git a/client-report/src/components/topicStats/LayerDistributionModal.jsx b/client-report/src/components/topicStats/LayerDistributionModal.jsx index 912e76b000..3373443f94 100644 --- a/client-report/src/components/topicStats/LayerDistributionModal.jsx +++ b/client-report/src/components/topicStats/LayerDistributionModal.jsx @@ -24,7 +24,11 @@ const LayerDistributionModal = ({ }, [isOpen, onClose]); useEffect(() => { - if (!isOpen || !topics || !statsData || !math || !math["group-aware-consensus"] || !comments) return; + 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 = []; @@ -38,7 +42,7 @@ const LayerDistributionModal = ({ const commentsData = []; stats.comment_tids.forEach(tid => { - const consensus = math["group-aware-consensus"][tid]; + const consensus = consensusData[tid]; if (consensus !== undefined) { // Find the comment to check vote count const comment = comments?.find(c => c.tid === tid); diff --git a/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx b/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx index 40373e19ee..d2f4b34971 100644 --- a/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx +++ b/client-report/src/components/topicStats/visualizations/AllCommentsScatterplot.jsx @@ -2,7 +2,13 @@ import React from 'react'; import TopicScatterplot from '../../topicScatterplot/TopicScatterplot.jsx'; const AllCommentsScatterplot = ({ comments, math, voteColors }) => { - if (!comments || !math || !math["group-aware-consensus"]) { + 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; } @@ -12,7 +18,7 @@ const AllCommentsScatterplot = ({ comments, math, voteColors }) => { let maxConsensus = -Infinity; comments.forEach(comment => { - const groupConsensus = math["group-aware-consensus"][comment.tid]; + const groupConsensus = consensusData[comment.tid]; if (groupConsensus !== undefined) { const totalVotes = (comment.agree_count || 0) + (comment.disagree_count || 0) + (comment.pass_count || 0); diff --git a/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx index aa98a43952..4863a6bf95 100644 --- a/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicOverviewScatterplot.jsx @@ -2,7 +2,13 @@ import React from 'react'; import TopicScatterplot from '../../topicScatterplot/TopicScatterplot.jsx'; const TopicOverviewScatterplot = ({ latestRun, statsData, math, voteColors, onTopicClick }) => { - if (!statsData || !math || !math["group-aware-consensus"]) { + 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; } @@ -19,7 +25,7 @@ const TopicOverviewScatterplot = ({ latestRun, statsData, math, voteColors, onTo let groupConsensus = null; if (stats.comment_tids) { const consensusValues = stats.comment_tids - .map(tid => math["group-aware-consensus"][tid]) + .map(tid => consensusData[tid]) .filter(val => val !== undefined); if (consensusValues.length > 0) { diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx index 4699a863db..8b9b7e0cc0 100644 --- a/client-report/src/components/topicStats/visualizations/TopicTables.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { canGenerateCollectiveStatement, THRESHOLDS } from '../../../util/consensusThreshold'; const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, onBeeswarm, onLayerDistribution, onViewTopic }) => { const [sortConfig, setSortConfig] = useState({ key: 'comment_count', direction: 'desc' }); @@ -13,7 +14,30 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, const layerEntries = Object.entries(latestRun.topics_by_layer || {}); const totalLayers = layerEntries.length; - return layerEntries + return ( +
+ {/* Threshold explanation note */} +
+ + 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; @@ -90,9 +114,11 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, // Calculate average group consensus for this topic let groupConsensus = null; - if (math && math["group-aware-consensus"] && stats.comment_tids) { + // 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 => math["group-aware-consensus"][tid]) + .map(tid => consensusData[tid]) .filter(val => val !== undefined); if (consensusValues.length > 0) { @@ -100,10 +126,14 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, } } + // Check if this topic can generate a collective statement + const statementCheck = canGenerateCollectiveStatement(stats.comment_tids, math); + return { clusterId, topic, - stats: { ...stats, group_consensus: groupConsensus } + stats: { ...stats, group_consensus: groupConsensus }, + statementCheck }; }) .sort((a, b) => { @@ -141,7 +171,7 @@ const TopicTables = ({ latestRun, statsData, math, onTopicSelect, onScatterplot, return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; } }) - .map(({ clusterId, topic, stats }) => ( + .map(({ clusterId, topic, stats, statementCheck }) => (
{ - e.preventDefault(); - onViewTopic({ name: topic.topic_name, key: topic.topic_key }); - }} + href={`/topicStats/${report_id}/${topic.topic_key}`} style={{ color: "#0066cc", textDecoration: "none", diff --git a/server/scripts/check-collective-statements.ts b/server/scripts/check-collective-statements.ts new file mode 100644 index 0000000000..a76a80c556 --- /dev/null +++ b/server/scripts/check-collective-statements.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +/** + * Script to check how many collective statements exist in DynamoDB + */ + +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import Config from "../src/config"; + +// Configure DynamoDB client +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, + }, +}); + +async function checkCollectiveStatements() { + try { + const params = { + TableName: "Delphi_CollectiveStatement" + }; + + let count = 0; + let lastEvaluatedKey; + + do { + const command: any = { + ...params, + ExclusiveStartKey: lastEvaluatedKey + }; + + const data = await docClient.send(new ScanCommand(command)); + if (data.Items) { + count += data.Items.length; + } + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + console.log(`Total collective statements in database: ${count}`); + + } catch (err) { + console.error("Error checking collective statements:", err); + } +} + +// Run the check +checkCollectiveStatements(); \ No newline at end of file diff --git a/server/scripts/clear-collective-statements.ts b/server/scripts/clear-collective-statements.ts new file mode 100644 index 0000000000..65953df1d8 --- /dev/null +++ b/server/scripts/clear-collective-statements.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +/** + * One-time script to clear all collective statements from DynamoDB + * Run with: npx ts-node scripts/clear-collective-statements.ts + */ + +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, ScanCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb"; +import Config from "../src/config"; + +// Configure DynamoDB client +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, + }, +}); + +async function clearCollectiveStatements() { + console.log("Starting to clear collective statements..."); + + try { + // First, scan to get all items + const params = { + TableName: "Delphi_CollectiveStatement" + }; + + const allItems: any[] = []; + let lastEvaluatedKey; + + // Scan all items + do { + const command: any = { + ...params, + ExclusiveStartKey: lastEvaluatedKey + }; + + const data = await docClient.send(new ScanCommand(command)); + if (data.Items) { + allItems.push(...data.Items); + } + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + console.log(`Found ${allItems.length} collective statements to delete`); + + if (allItems.length === 0) { + console.log("No items to delete. Table is already empty."); + return; + } + + // Confirm before deleting + console.log("\nItems to delete:"); + allItems.forEach(item => { + console.log(` - ${item.zid_topic_jobid} (Topic: ${item.topic_name}, Created: ${item.created_at})`); + }); + + console.log("\nPress Ctrl+C within 5 seconds to cancel..."); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Delete each item + console.log("\nDeleting items..."); + let deletedCount = 0; + let errors = 0; + + for (const item of allItems) { + try { + await docClient.send(new DeleteCommand({ + TableName: "Delphi_CollectiveStatement", + Key: { + zid_topic_jobid: item.zid_topic_jobid + } + })); + deletedCount++; + process.stdout.write(`\rDeleted ${deletedCount}/${allItems.length} items...`); + } catch (err) { + errors++; + console.error(`\nError deleting item ${item.zid_topic_jobid}:`, err); + } + } + + console.log(`\n\nCompleted! Deleted ${deletedCount} items with ${errors} errors.`); + + } catch (err) { + console.error("Fatal error:", err); + process.exit(1); + } +} + +// Run the script +clearCollectiveStatements() + .then(() => { + console.log("Script finished successfully"); + process.exit(0); + }) + .catch(err => { + console.error("Script failed:", err); + process.exit(1); + }); \ No newline at end of file diff --git a/server/src/routes/collectiveStatement.ts b/server/src/routes/collectiveStatement.ts index 3f075005c0..6f44cff2fe 100644 --- a/server/src/routes/collectiveStatement.ts +++ b/server/src/routes/collectiveStatement.ts @@ -1,7 +1,7 @@ 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 { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand, ScanCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb"; import { getZidFromReport } from "../utils/parameter"; import Config from "../config"; import Anthropic from "@anthropic-ai/sdk"; @@ -77,7 +77,7 @@ IMPORTANT CONTEXT: All comments provided have already been filtered to meet stri 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 believe...", "We agree...", "We support...") +- 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 @@ -334,16 +334,78 @@ export async function handle_POST_collectiveStatement(req: Request, res: Respons } } + /** * 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 } = req.query; + 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", + FilterExpression: "zid = :zid", + ExpressionAttributeValues: { + ":zid": zid.toString() + } + }; + + const statements: any[] = []; + let lastEvaluatedKey; + + do { + const command: any = { + ...params, + ExclusiveStartKey: lastEvaluatedKey + }; + + const data = await docClient.send(new ScanCommand(command)); + if (data.Items) { + statements.push(...data.Items); + } + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + // Parse the JSON data in each statement + const parsedStatements = statements.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 is required", + message: "statement_id or report_id is required", }); } From 9a71e26b5590afffa981fff8cfef2e2adcbc4b7c Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 7 Aug 2025 12:20:25 -0700 Subject: [PATCH 40/71] broken collective statement carousel alpha --- client-report/src/components/app.jsx | 16 + .../CollectiveStatementsReport.jsx | 469 ++++++++++++++++++ server/app.ts | 7 + 3 files changed, 492 insertions(+) create mode 100644 client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index f93c9370ed..70cf8a02d4 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -31,6 +31,7 @@ import TopicHierarchy from "./topicHierarchy/TopicHierarchy.jsx"; 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" @@ -898,6 +899,21 @@ const App = (props) => { ) } + 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..66683fb578 --- /dev/null +++ b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx @@ -0,0 +1,469 @@ +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 [cardWidth, setCardWidth] = useState(0); + const [isTransitioning, setIsTransitioning] = 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(); + } + }; + + // 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 on resize + useEffect(() => { + const updateCardWidth = () => { + if (carouselRef.current) { + const containerWidth = carouselRef.current.offsetWidth; + setCardWidth(Math.min(containerWidth * 0.9, 1200)); // 90% of container or max 1200px + } + }; + + updateCardWidth(); + window.addEventListener('resize', updateCardWidth); + return () => window.removeEventListener('resize', updateCardWidth); + }, []); + + const scrollToIndex = (index) => { + if (isTransitioning || index === currentIndex) return; + + setIsTransitioning(true); + setCurrentIndex(index); + + if (carouselRef.current) { + const scrollPosition = index * (cardWidth + 40); // 40px gap between cards + carouselRef.current.style.transform = `translateX(-${scrollPosition}px)`; + } + + setTimeout(() => setIsTransitioning(false), 300); + }; + + 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 ( +
+
+ +
+
+
+

Loading collective statements...

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

No collective statements have been generated yet.

+
+
+
+ ); + } + + return ( +
+
+ +
+ +
+ {/* Navigation Buttons */} + + + + + {/* Carousel Container */} +
+
+ {statements.map((statement, index) => renderStatement(statement, index))} +
+
+ + {/* Dots Indicator */} +
+ {statements.map((_, index) => ( +
+ + {/* Statement Counter */} +
+ {currentIndex + 1} of {statements.length} statements +
+
+ +
+
+
+
+ ); +}; + +export default CollectiveStatementsReport; \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 333c0a1cf6..67bdaf3594 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1863,6 +1863,13 @@ helpersInitialized.then( return fetchIndexForReportPage(req, res, next); } ); + // Collective Statements carousel route + app.get( + /^\/collectiveStatements\/r?[0-9][0-9A-Za-z]+(\/.*)?/, + function (req, res, next) { + return fetchIndexForReportPage(req, res, next); + } + ); // Export Report route for data export interface app.get( /^\/exportReport\/r?[0-9][0-9A-Za-z]+(\/.*)?/, From 2a835ba98ba73928ab3004287e8bb3646a5af621 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 7 Aug 2025 13:44:17 -0700 Subject: [PATCH 41/71] collective statemetn carousel --- client-report/src/components/app.jsx | 5 +- .../CollectiveStatementsReport.jsx | 52 ++++++++++++------- .../src/components/topicPage/TopicPage.jsx | 2 +- .../src/components/topicStats/TopicStats.jsx | 4 +- .../topicStats/visualizations/TopicTables.jsx | 2 +- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx index 70cf8a02d4..ab8d455347 100644 --- a/client-report/src/components/app.jsx +++ b/client-report/src/components/app.jsx @@ -41,9 +41,12 @@ const route_type = pathParts[1]; // "report", "narrativeReport", "commentsReport 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) { - topic_key = pathParts[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 diff --git a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx index 66683fb578..816b63f777 100644 --- a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx +++ b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx @@ -10,7 +10,8 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p const [statements, setStatements] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const carouselRef = useRef(null); - const [cardWidth, setCardWidth] = useState(0); + const containerRef = useRef(null); + const [cardWidth, setCardWidth] = useState(1400); // Default width - wider for better content display const [isTransitioning, setIsTransitioning] = useState(false); const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); @@ -86,9 +87,9 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p // Update card width on resize useEffect(() => { const updateCardWidth = () => { - if (carouselRef.current) { - const containerWidth = carouselRef.current.offsetWidth; - setCardWidth(Math.min(containerWidth * 0.9, 1200)); // 90% of container or max 1200px + if (containerRef.current) { + const containerWidth = containerRef.current.offsetWidth; + setCardWidth(Math.min(containerWidth * 0.9, 1600)); // 90% of container or max 1600px } }; @@ -96,6 +97,18 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p window.addEventListener('resize', updateCardWidth); return () => window.removeEventListener('resize', updateCardWidth); }, []); + + // Recalculate card width when statements are loaded + useEffect(() => { + if (statements.length > 0 && containerRef.current) { + const containerWidth = containerRef.current.offsetWidth; + setCardWidth(Math.min(containerWidth * 0.9, 1600)); + // Force a re-render by scrolling to current index + if (carouselRef.current) { + carouselRef.current.style.transform = `translateX(-${currentIndex * (cardWidth + 40)}px)`; + } + } + }, [statements]); const scrollToIndex = (index) => { if (isTransitioning || index === currentIndex) return; @@ -150,8 +163,8 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p
{/* Statement Text */}
+ {/* Statement Counter - moved to top */} +
+ {currentIndex + 1} of {statements.length} statements +
{/* Navigation Buttons */}
- - {/* Statement Counter */} -
- {currentIndex + 1} of {statements.length} statements -
diff --git a/client-report/src/components/topicPage/TopicPage.jsx b/client-report/src/components/topicPage/TopicPage.jsx index a8cfd59831..321d27ace1 100644 --- a/client-report/src/components/topicPage/TopicPage.jsx +++ b/client-report/src/components/topicPage/TopicPage.jsx @@ -309,7 +309,7 @@ const TopicPage = ({ conversation, report_id, topic_key, math, comments, ptptCou
diff --git a/client-report/src/components/topicStats/visualizations/TopicTables.jsx b/client-report/src/components/topicStats/visualizations/TopicTables.jsx index 3cacac602e..daa1b4097b 100644 --- a/client-report/src/components/topicStats/visualizations/TopicTables.jsx +++ b/client-report/src/components/topicStats/visualizations/TopicTables.jsx @@ -175,7 +175,7 @@ const TopicTables = ({ latestRun, statsData, math, report_id, onTopicSelect, onS
Date: Thu, 7 Aug 2025 13:56:51 -0700 Subject: [PATCH 42/71] carousel --- .../CollectiveStatementsReport.jsx | 148 ++++++++++-------- 1 file changed, 82 insertions(+), 66 deletions(-) diff --git a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx index 816b63f777..96d932b88c 100644 --- a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx +++ b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx @@ -12,7 +12,7 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p const carouselRef = useRef(null); const containerRef = useRef(null); const [cardWidth, setCardWidth] = useState(1400); // Default width - wider for better content display - const [isTransitioning, setIsTransitioning] = useState(false); + const [cardHeight, setCardHeight] = useState('600px'); // Default height const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); @@ -84,44 +84,60 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p } }, [report_id]); - // Update card width on resize + // Update card width and height on resize useEffect(() => { - const updateCardWidth = () => { + const updateCardDimensions = () => { 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 + } } }; - updateCardWidth(); - window.addEventListener('resize', updateCardWidth); - return () => window.removeEventListener('resize', updateCardWidth); + updateCardDimensions(); + window.addEventListener('resize', updateCardDimensions); + return () => window.removeEventListener('resize', updateCardDimensions); }, []); - // Recalculate card width when statements are loaded + // 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)); - // Force a re-render by scrolling to current index - if (carouselRef.current) { - carouselRef.current.style.transform = `translateX(-${currentIndex * (cardWidth + 40)}px)`; + + // 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 (isTransitioning || index === currentIndex) return; + if (index === currentIndex) return; - setIsTransitioning(true); setCurrentIndex(index); - - if (carouselRef.current) { - const scrollPosition = index * (cardWidth + 40); // 40px gap between cards - carouselRef.current.style.transform = `translateX(-${scrollPosition}px)`; - } - - setTimeout(() => setIsTransitioning(false), 300); }; const handlePrevious = () => { @@ -167,14 +183,15 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p maxWidth: (cardWidth || 1400) + "px", marginRight: "40px", opacity: isActive ? 1 : 0.5, - transform: isActive ? "scale(1)" : "scale(0.95)", + transform: isActive ? "scale(1) translateY(0)" : "scale(0.95) translateY(0)", transition: "all 0.3s ease", backgroundColor: "white", borderRadius: "12px", boxShadow: isActive ? "0 10px 40px rgba(0, 0, 0, 0.15)" : "0 5px 20px rgba(0, 0, 0, 0.1)", overflow: "hidden", display: "flex", - flexDirection: "column" + flexDirection: "column", + height: "100%" }} > {/* Header */} @@ -215,7 +232,7 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p flex: 1, display: "flex", overflow: "hidden", - maxHeight: "600px" + height: cardHeight }}> {/* Statement Text */}
- {/* Statement Counter - moved to top */} + {/* Statement Counter and Dots - moved to top */}
- {currentIndex + 1} of {statements.length} statements +
+ {currentIndex + 1} of {statements.length} statements +
+ + {/* Dots Indicator */} +
+ {statements.map((_, index) => ( +
{/* Navigation Buttons */} @@ -439,38 +480,13 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p ref={carouselRef} style={{ display: "flex", - transition: isTransitioning ? "transform 0.3s ease" : "none", + transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)", transform: `translateX(-${currentIndex * (cardWidth + 40)}px)` }} > {statements.map((statement, index) => renderStatement(statement, index))}
- - {/* Dots Indicator */} -
- {statements.map((_, index) => ( -
From 57853bf8b8ba5c00fa4ff647de5a45f381fd63c9 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 7 Aug 2025 14:20:43 -0700 Subject: [PATCH 43/71] embed logic --- .../CollectiveStatementsReport.jsx | 76 +++++++++++++------ 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx index 96d932b88c..8b41902d68 100644 --- a/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx +++ b/client-report/src/components/collectiveStatementsReport/CollectiveStatementsReport.jsx @@ -13,6 +13,8 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p 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); @@ -43,6 +45,13 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p } }; + // 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) => { @@ -87,6 +96,7 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p // 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 @@ -231,15 +241,18 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p
{/* Statement Text */}

Statement

{statement.statement_data && statement.statement_data.paragraphs && @@ -280,24 +293,31 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p {/* Cited Comments */}

Cited Comments ({uniqueCitations.length})

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

No comments cited

)} @@ -315,9 +335,11 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p backgroundColor: "#f5f6fa", minHeight: "100vh" }}> -
- -
+ {!isEmbedded && ( +
+ +
+ )}
- + {!isEmbedded && }

No collective statements have been generated yet.

-
+ {!isEmbedded &&
}
); } @@ -363,9 +385,11 @@ const CollectiveStatementsReport = ({ conversation, report_id, math, comments, p backgroundColor: "#f5f6fa", minHeight: "100vh" }}> -
- -
+ {!isEmbedded && ( +
+ +
+ )}
-
-
-
+ {!isEmbedded && ( +
+
+
+ )}
); }; From 6005e61109e1e2dd4bd1b8f7aa1e657f92180b58 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 7 Aug 2025 14:32:49 -0700 Subject: [PATCH 44/71] clean up collective statmetns before rerun --- delphi/CLAUDE.md | 1 + delphi/umap_narrative/reset_conversation.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) 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/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 From 87bed4152c7cf0b0c9588f6978363b9ebb94f17f Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 7 Aug 2025 18:00:41 -0500 Subject: [PATCH 45/71] Te client participation 2 (#2098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add astro version of client-participation * minor style updates * begin moving over translations * port over translations * ssr * Use real convo data * dynamically update title * more translations * wire in voting capability * get voting working * cors * type check * impliment convo closed status * admin mod, prio stub * hclust route checkpoitn * labels working * remove logging, get towards hclust * hclust explration 22 * contour plot * contours * prio take 1 * distances * remove dead 600 file * add more defense for small models * topic prioritize mock * webpack build size * topic sections * merge fix * add simple route * Restructure priority calculation to use group-based extremity - Remove priority calculation from math pipeline (conversation.py) * Delete _importance_metric and _priority_metric methods * Remove priority computation from recompute() method * Math pipeline now focuses on PCA, clustering, and representativeness - Add dedicated priority calculation script (502_calculate_priorities.py) * Implements PriorityCalculator class with group-based extremity * Matches Clojure priority formula: (importance * scaling_factor)^2 * Retrieves extremity values from Delphi_CommentExtremity table * Updates priorities in Delphi_CommentRouting table - Update pipeline execution order (run_delphi.py, run_delphi.sh) * Math pipeline → UMAP pipeline → Extremity calculation → Priority calculation * Ensures priorities use group-based extremity instead of PCA-based * Maintains separation of concerns between mathematical and priority calculations This fixes the priority calculation bug where all priorities were 0 due to missing extremity values, and implements proper group-based extremity usage as requested for the Pakistan conversation analysis. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Refactor priority calculation: separate formulas from service logic - Extract priority formulas into polismath/conversation/priority.py * Create PriorityCalculator class with static methods for core formulas * Port importance_metric and priority_metric from Clojure implementation * Add convenience methods: calculate_comment_priority, validate_inputs, explain_priority * Pure mathematical logic with no I/O dependencies for better testability - Refactor umap_narrative/502_calculate_priorities.py to use extracted formulas * Rename class from PriorityCalculator to PriorityService (clearer distinction) * Import and use PriorityCalculator.calculate_comment_priority() * Remove duplicate formula implementations (38 lines removed) * Focus service on DynamoDB operations and data orchestration Benefits: - Better separation of concerns: formulas vs data processing - Improved testability: mathematical logic can be unit tested independently - Enhanced reusability: priority formulas can be used in other contexts - Cleaner maintainability: formula changes only need to happen in one place Tested successfully with conversation 36324: - 807 comments processed - Priority statistics: min=0, max=3, avg=2.36 - All priorities calculated using group-based extremity values 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Revert "Refactor priority calculation: separate formulas from service logic" This reverts commit cfd829d60693d8eacd66d76082e6480cb3dd1c2d. * topic agenda collapse prototype tetris-y * topic agenda setting demo * begin basic bulk upload capability * save state * split by distance doesn't owrk * sentence transformer model * componetize * drive detail * more specifics * log archetypal comments * dynamo table and node services * Don't crash when we have not votes. * suppress logging * differnet approach for prepended labesl * Te arch updates topicmod (#2086) * add polis mod access and remove table scans * add comments back * more comment fixes * add footer links * begin dockerization * dockerize and new route structure * wire in xid * get alpha embed working * prune comments * connect comment form * translations fixes, hook up subscribe form * css fixes * fix env vars * Colinmegill/topic prio (#2103) * showing topics in participant view * logo color / p slightly more minimal change --------- Co-authored-by: Colin Megill * perf adjustments * remove console log * minor fixes * compilation fixes * more auth * get auth working * return vote response * participation alpha qa and better topic loading * add hide header ability * update headerless * turn off eslint for now * prep alpha for prod deploy --------- Co-authored-by: Colin Megill Co-authored-by: Christian Weilbach Co-authored-by: Claude --- client-admin/package-lock.json | 23 +- client-admin/package.json | 1 + .../components/conversation-admin/index.js | 17 + .../topic-moderation/README.md | 66 + .../topic-moderation/index.js | 134 + .../proximity-visualization.js | 327 + .../topic-moderation/topic-detail.js | 330 + .../topic-moderation/topic-moderation.css | 229 + .../topic-moderation/topic-stats.js | 182 + .../topic-moderation/topic-tree.js | 287 + .../.astro/content-assets.mjs | 1 + .../.astro/content-modules.mjs | 1 + .../.astro/content.d.ts | 199 + .../.astro/data-store.json | 1 + .../.astro/settings.json | 5 + client-participation-alpha/.astro/types.d.ts | 2 + client-participation-alpha/.gitignore | 13 + client-participation-alpha/Dockerfile | 21 + client-participation-alpha/README.md | 47 + client-participation-alpha/astro.config.mjs | 18 + client-participation-alpha/package-lock.json | 5301 +++++++++++++++++ client-participation-alpha/package.json | 20 + .../src/components/EmailSubscribeForm.jsx | 75 + .../src/components/Header.astro | 5 + .../src/components/Statement.jsx | 69 + .../src/components/Survey.jsx | 121 + .../src/components/SurveyForm.jsx | 106 + .../components/topicAgenda/TopicAgenda.jsx | 331 + .../topicAgenda/components/LayerHeader.jsx | 17 + .../components/ScrollableTopicsGrid.jsx | 211 + .../components/TopicAgendaStyles.jsx | 150 + .../topicAgenda/components/TopicItem.jsx | 38 + .../topicAgenda/hooks/useTopicData.js | 177 + .../topicAgenda/utils/archetypeExtraction.js | 129 + .../topicAgenda/utils/topicFiltering.js | 106 + .../topicAgenda/utils/topicUtils.js | 33 + client-participation-alpha/src/lib/auth.ts | 123 + client-participation-alpha/src/lib/net.js | 205 + .../src/pages/[conversation_id].astro | 137 + .../src/pages/embed.js.ts | 144 + .../src/pages/layouts/Layout.astro | 15 + client-participation-alpha/src/strings/ar.js | 184 + client-participation-alpha/src/strings/bs.js | 183 + client-participation-alpha/src/strings/cy.js | 118 + .../src/strings/da_dk.js | 108 + .../src/strings/de_de.js | 117 + .../src/strings/en_us.js | 185 + .../src/strings/es_la.js | 185 + client-participation-alpha/src/strings/fa.js | 117 + client-participation-alpha/src/strings/fr.js | 98 + .../src/strings/fy_nl.js | 143 + client-participation-alpha/src/strings/gr.js | 118 + client-participation-alpha/src/strings/he.js | 122 + client-participation-alpha/src/strings/hr.js | 182 + client-participation-alpha/src/strings/it.js | 54 + client-participation-alpha/src/strings/ja.js | 101 + client-participation-alpha/src/strings/my.js | 183 + client-participation-alpha/src/strings/nl.js | 103 + client-participation-alpha/src/strings/ps.js | 183 + .../src/strings/pt_TL.js | 66 + .../src/strings/pt_br.js | 85 + client-participation-alpha/src/strings/ro.js | 116 + client-participation-alpha/src/strings/ru.js | 104 + client-participation-alpha/src/strings/sk.js | 114 + .../src/strings/strings.js | 238 + client-participation-alpha/src/strings/sw.js | 185 + client-participation-alpha/src/strings/ta.js | 143 + client-participation-alpha/src/strings/tdt.js | 68 + client-participation-alpha/src/strings/uk.js | 117 + client-participation-alpha/src/strings/vi.js | 182 + .../src/strings/zh_Hans.js | 46 + .../src/strings/zh_Hant.js | 54 + .../src/styles/global.css | 266 + client-participation-alpha/tsconfig.json | 14 + client-report/src/components/app.jsx | 58 +- .../src/components/framework/useReportId.js | 10 +- .../src/components/participantsGraph/hull.jsx | 6 +- .../components/topicAgenda/TopicAgenda.jsx | 200 + .../topicAgenda/components/LayerHeader.jsx | 17 + .../components/ScrollableTopicsGrid.jsx | 211 + .../components/TopicAgendaStyles.jsx | 346 ++ .../topicAgenda/components/TopicItem.jsx | 38 + .../topicAgenda/hooks/useAgendaBuilder.js | 89 + .../topicAgenda/hooks/useTopicData.js | 180 + .../topicAgenda/utils/archetypeExtraction.js | 129 + .../topicAgenda/utils/topicFiltering.js | 106 + .../topicAgenda/utils/topicUtils.js | 33 + .../topicHierarchy/TopicHierarchy.jsx | 1179 ++++ .../topicPrioritize/TopicPrioritize.jsx | 1156 ++++ .../TopicPrioritizeSimple.jsx | 338 ++ .../topicReport/TopicSectionsBuilder.jsx | 3 +- client-report/webpack.common.js | 27 +- client-report/webpack.dev.js | 2 +- delphi/CLAUDE.md | 5 + delphi/create_dynamodb_tables.py | 28 + delphi/create_topic_agenda_table.py | 110 + delphi/docs/EVOC_LAYER_HIERARCHY_DEBUG.md | 214 + .../SPATIAL_TOPIC_PRIORITIZATION_SYSTEM.md | 457 ++ .../TOPIC_AGENDA_IMPLEMENTATION_SUMMARY.md | 91 + delphi/docs/TOPIC_AGENDA_MIGRATION_PLAN.md | 417 ++ delphi/docs/TOPIC_AGENDA_STORAGE_DESIGN.md | 319 + delphi/docs/UMAP_VISUALIZATION_PLAN.md | 194 + delphi/docs/topic-moderation-system.md | 311 + delphi/example.env | 6 + delphi/polismath/conversation/conversation.py | 132 - delphi/polismath/database/dynamodb.py | 4 +- delphi/run_delphi.py | 16 + delphi/run_delphi.sh | 11 +- .../500_generate_embedding_umap_cluster.py | 4 +- .../502_calculate_priorities.py | 292 + .../600_generate_llm_topic_names.py | 848 --- .../polismath_commentgraph/PROJECT_SUMMARY.md | 2 +- .../polismath_commentgraph/cli.py | 4 +- .../polismath_commentgraph/core/embedding.py | 8 +- .../polismath_commentgraph/utils/converter.py | 5 +- delphi/umap_narrative/run_pipeline.py | 19 +- docker-compose.yml | 19 + example.env | 6 + file-server/Dockerfile | 2 +- file-server/nginx/nginx-ssl.site.default.conf | 33 +- scripts/after_install.sh | 4 +- scripts/application_stop.sh | 4 +- server/app.ts | 203 + server/src/routes/delphi/index.ts | 24 +- server/src/routes/delphi/topicAgenda.ts | 361 ++ server/src/routes/delphi/topicMod.ts | 983 +++ .../routes/participation/topicPrioritize.ts | 76 + server/src/server.ts | 56 +- 128 files changed, 22052 insertions(+), 1043 deletions(-) create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/README.md create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/index.js create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/proximity-visualization.js create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/topic-detail.js create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/topic-moderation.css create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/topic-stats.js create mode 100644 client-admin/src/components/conversation-admin/topic-moderation/topic-tree.js create mode 100644 client-participation-alpha/.astro/content-assets.mjs create mode 100644 client-participation-alpha/.astro/content-modules.mjs create mode 100644 client-participation-alpha/.astro/content.d.ts create mode 100644 client-participation-alpha/.astro/data-store.json create mode 100644 client-participation-alpha/.astro/settings.json create mode 100644 client-participation-alpha/.astro/types.d.ts create mode 100644 client-participation-alpha/.gitignore create mode 100644 client-participation-alpha/Dockerfile create mode 100644 client-participation-alpha/README.md create mode 100644 client-participation-alpha/astro.config.mjs create mode 100644 client-participation-alpha/package-lock.json create mode 100644 client-participation-alpha/package.json create mode 100644 client-participation-alpha/src/components/EmailSubscribeForm.jsx create mode 100644 client-participation-alpha/src/components/Header.astro create mode 100644 client-participation-alpha/src/components/Statement.jsx create mode 100644 client-participation-alpha/src/components/Survey.jsx create mode 100644 client-participation-alpha/src/components/SurveyForm.jsx create mode 100644 client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx create mode 100644 client-participation-alpha/src/components/topicAgenda/components/LayerHeader.jsx create mode 100644 client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx create mode 100644 client-participation-alpha/src/components/topicAgenda/components/TopicAgendaStyles.jsx create mode 100644 client-participation-alpha/src/components/topicAgenda/components/TopicItem.jsx create mode 100644 client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js create mode 100644 client-participation-alpha/src/components/topicAgenda/utils/archetypeExtraction.js create mode 100644 client-participation-alpha/src/components/topicAgenda/utils/topicFiltering.js create mode 100644 client-participation-alpha/src/components/topicAgenda/utils/topicUtils.js create mode 100644 client-participation-alpha/src/lib/auth.ts create mode 100644 client-participation-alpha/src/lib/net.js create mode 100644 client-participation-alpha/src/pages/[conversation_id].astro create mode 100644 client-participation-alpha/src/pages/embed.js.ts create mode 100644 client-participation-alpha/src/pages/layouts/Layout.astro create mode 100644 client-participation-alpha/src/strings/ar.js create mode 100644 client-participation-alpha/src/strings/bs.js create mode 100644 client-participation-alpha/src/strings/cy.js create mode 100644 client-participation-alpha/src/strings/da_dk.js create mode 100644 client-participation-alpha/src/strings/de_de.js create mode 100644 client-participation-alpha/src/strings/en_us.js create mode 100644 client-participation-alpha/src/strings/es_la.js create mode 100644 client-participation-alpha/src/strings/fa.js create mode 100644 client-participation-alpha/src/strings/fr.js create mode 100644 client-participation-alpha/src/strings/fy_nl.js create mode 100644 client-participation-alpha/src/strings/gr.js create mode 100644 client-participation-alpha/src/strings/he.js create mode 100644 client-participation-alpha/src/strings/hr.js create mode 100644 client-participation-alpha/src/strings/it.js create mode 100644 client-participation-alpha/src/strings/ja.js create mode 100644 client-participation-alpha/src/strings/my.js create mode 100644 client-participation-alpha/src/strings/nl.js create mode 100644 client-participation-alpha/src/strings/ps.js create mode 100644 client-participation-alpha/src/strings/pt_TL.js create mode 100644 client-participation-alpha/src/strings/pt_br.js create mode 100644 client-participation-alpha/src/strings/ro.js create mode 100644 client-participation-alpha/src/strings/ru.js create mode 100644 client-participation-alpha/src/strings/sk.js create mode 100644 client-participation-alpha/src/strings/strings.js create mode 100644 client-participation-alpha/src/strings/sw.js create mode 100644 client-participation-alpha/src/strings/ta.js create mode 100644 client-participation-alpha/src/strings/tdt.js create mode 100644 client-participation-alpha/src/strings/uk.js create mode 100644 client-participation-alpha/src/strings/vi.js create mode 100644 client-participation-alpha/src/strings/zh_Hans.js create mode 100644 client-participation-alpha/src/strings/zh_Hant.js create mode 100644 client-participation-alpha/src/styles/global.css create mode 100644 client-participation-alpha/tsconfig.json create mode 100644 client-report/src/components/topicAgenda/TopicAgenda.jsx create mode 100644 client-report/src/components/topicAgenda/components/LayerHeader.jsx create mode 100644 client-report/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx create mode 100644 client-report/src/components/topicAgenda/components/TopicAgendaStyles.jsx create mode 100644 client-report/src/components/topicAgenda/components/TopicItem.jsx create mode 100644 client-report/src/components/topicAgenda/hooks/useAgendaBuilder.js create mode 100644 client-report/src/components/topicAgenda/hooks/useTopicData.js create mode 100644 client-report/src/components/topicAgenda/utils/archetypeExtraction.js create mode 100644 client-report/src/components/topicAgenda/utils/topicFiltering.js create mode 100644 client-report/src/components/topicAgenda/utils/topicUtils.js create mode 100644 client-report/src/components/topicHierarchy/TopicHierarchy.jsx create mode 100644 client-report/src/components/topicPrioritize/TopicPrioritize.jsx create mode 100644 client-report/src/components/topicPrioritizeSimple/TopicPrioritizeSimple.jsx create mode 100755 delphi/create_topic_agenda_table.py create mode 100644 delphi/docs/EVOC_LAYER_HIERARCHY_DEBUG.md create mode 100644 delphi/docs/SPATIAL_TOPIC_PRIORITIZATION_SYSTEM.md create mode 100644 delphi/docs/TOPIC_AGENDA_IMPLEMENTATION_SUMMARY.md create mode 100644 delphi/docs/TOPIC_AGENDA_MIGRATION_PLAN.md create mode 100644 delphi/docs/TOPIC_AGENDA_STORAGE_DESIGN.md create mode 100644 delphi/docs/UMAP_VISUALIZATION_PLAN.md create mode 100644 delphi/docs/topic-moderation-system.md create mode 100755 delphi/umap_narrative/502_calculate_priorities.py delete mode 100755 delphi/umap_narrative/600_generate_llm_topic_names.py create mode 100644 server/src/routes/delphi/topicAgenda.ts create mode 100644 server/src/routes/delphi/topicMod.ts create mode 100644 server/src/routes/participation/topicPrioritize.ts diff --git a/client-admin/package-lock.json b/client-admin/package-lock.json index 7fa06cbf87..628b234861 100644 --- a/client-admin/package-lock.json +++ b/client-admin/package-lock.json @@ -19,6 +19,7 @@ "react-oidc-context": "^3.3.0", "react-redux": "^9.2.0", "react-router": "^7.6.3", + "react-router-dom": "^7.7.1", "redux": "^5.0.1", "theme-ui": "^0.17.2", "victory": "~37.3.6" @@ -14368,9 +14369,9 @@ } }, "node_modules/react-router": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", - "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz", + "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -14389,6 +14390,22 @@ } } }, + "node_modules/react-router-dom": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.1.tgz", + "integrity": "sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.7.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-router/node_modules/cookie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", diff --git a/client-admin/package.json b/client-admin/package.json index 990b7e788a..76af39eeec 100644 --- a/client-admin/package.json +++ b/client-admin/package.json @@ -66,6 +66,7 @@ "react-oidc-context": "^3.3.0", "react-redux": "^9.2.0", "react-router": "^7.6.3", + "react-router-dom": "^7.7.1", "redux": "^5.0.1", "theme-ui": "^0.17.2", "victory": "~37.3.6" diff --git a/client-admin/src/components/conversation-admin/index.js b/client-admin/src/components/conversation-admin/index.js index bee215f768..f8b39b0bd6 100644 --- a/client-admin/src/components/conversation-admin/index.js +++ b/client-admin/src/components/conversation-admin/index.js @@ -11,6 +11,7 @@ import ConversationStats from './stats' import { useAuth } from 'react-oidc-context' import ModerateComments from './comment-moderation/' +import TopicModeration from './topic-moderation/' // import DataExport from "./data-export"; import ShareAndEmbed from './share-and-embed' @@ -61,6 +62,16 @@ const ConversationAdminContainer = () => { All + + + Topic Mod + + Configure @@ -111,6 +122,12 @@ const ConversationAdminContainer = () => { } /> } /> } /> + ( + + )} + /> {/* } /> */} diff --git a/client-admin/src/components/conversation-admin/topic-moderation/README.md b/client-admin/src/components/conversation-admin/topic-moderation/README.md new file mode 100644 index 0000000000..7f9ef67c6a --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/README.md @@ -0,0 +1,66 @@ +# Topic Moderation Components + +This directory contains the React components for the Topic-Based Moderation system in pol.is. + +## Components + +### `index.js` - TopicModeration +Main container component with tabbed navigation between different views: +- Topics Tree view +- Proximity Map visualization +- Statistics dashboard + +### `topic-tree.js` - TopicTree +Hierarchical display of topics organized by layers: +- Layer selection (0, 1, 2, or all) +- Topic cards with moderation controls +- Bulk topic-level actions (Accept/Reject/Meta) +- Navigation to detailed comment view + +### `topic-detail.js` - TopicDetail +Detailed view of comments within a specific topic: +- Individual comment display with selection +- Bulk comment selection and actions +- UMAP coordinate display +- Moderation status tracking + +### `proximity-visualization.js` - ProximityVisualization +SVG-based UMAP visualization: +- Interactive scatter plot of comment positions +- Cluster grouping visualization +- Color coding by moderation status +- Layer selection for different granularities + +### `topic-stats.js` - TopicStats +Statistics and progress tracking: +- Moderation completion rates +- Status distribution (pending/accepted/rejected/meta) +- Progress bars and visual indicators +- Overview dashboard + +## Usage + +The components are integrated into the conversation admin interface at: +`/m/:conversation_id/topics` + +## Dependencies + +- React with hooks +- theme-ui for styling +- React Router for navigation +- SVG manipulation for visualizations + +## Data Flow + +1. Components fetch data from `/api/v3/topicMod/*` endpoints +2. Real-time polling for updates (60-second intervals) +3. Optimistic UI updates for moderation actions +4. Error handling with retry mechanisms + +## Styling + +Uses theme-ui variants and custom CSS in `topic-moderation.css` for: +- Hover effects and transitions +- Status-based color coding +- Responsive design +- Loading states and animations \ No newline at end of file diff --git a/client-admin/src/components/conversation-admin/topic-moderation/index.js b/client-admin/src/components/conversation-admin/topic-moderation/index.js new file mode 100644 index 0000000000..1fe8a7b555 --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/index.js @@ -0,0 +1,134 @@ +/* eslint-disable */ +// 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 . + +import ComponentHelpers from '../../../util/component-helpers' +import NoPermission from '../no-permission' +import React from 'react' +import { connect } from 'react-redux' +import { Heading, Flex, Box } from 'theme-ui' +import { Switch, Route, Link } from 'react-router-dom' + +import TopicTree from './topic-tree' +import TopicDetail from './topic-detail' +import TopicStats from './topic-stats' +import ProximityVisualization from './proximity-visualization' + +const mapStateToProps = (state, ownProps) => { + return { + topics: state.topic_mod_topics, + stats: state.topic_mod_stats, + zid_metadata: state.zid_metadata + } +} + +const pollFrequency = 60000 + +@connect((state) => state.zid_metadata) +@connect(mapStateToProps) +class TopicModeration extends React.Component { + loadTopics() { + // Dispatch actions to load topics data + // TODO: Implement actions for loading topic moderation data + console.log('Loading topics for conversation:', this.props.conversation_id) + } + + componentDidMount() { + this.loadTopics() + // Temporarily disable polling to debug crash + // this.getTopicsRepeatedly = setInterval(() => { + // this.loadTopics() + // }, pollFrequency) + } + + componentWillUnmount() { + clearInterval(this.getTopicsRepeatedly) + } + + render() { + // Check if zid_metadata is still loading + if (!this.props.zid_metadata || this.props.zid_metadata.loading) { + return ( + +
Loading...
+
+ ) + } + + if (ComponentHelpers.shouldShowPermissionsError(this.props)) { + return + } + + const { match, location } = this.props + const url = location.pathname.split('/')[4] + + return ( + + + Topic Moderation + + + + Topics Tree + + + Proximity Map + + + Statistics + + + + + ( + + )} + /> +
Proximity Visualization Coming Soon
} + /> + ( + + )} + /> +
Topic Detail Coming Soon
} + /> +
+
+
+ ) + } +} + +export default TopicModeration diff --git a/client-admin/src/components/conversation-admin/topic-moderation/proximity-visualization.js b/client-admin/src/components/conversation-admin/topic-moderation/proximity-visualization.js new file mode 100644 index 0000000000..86d584aa43 --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/proximity-visualization.js @@ -0,0 +1,327 @@ +/* eslint-disable */ +// 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 . + +import React from 'react' +import { connect } from 'react-redux' +import { jsx, Box, Flex, Heading, Text, Button, Select } from 'theme-ui' + +const mapStateToProps = (state) => { + return { + zid_metadata: state.zid_metadata + } +} + +@connect(mapStateToProps) +class ProximityVisualization extends React.Component { + constructor(props) { + super(props) + this.state = { + proximityData: [], + selectedLayer: '0', + loading: true, + error: null, + svgRef: React.createRef() + } + } + + componentDidMount() { + this.loadProximityData() + } + + async loadProximityData() { + try { + this.setState({ loading: true, error: null }) + const { match } = this.props + const { selectedLayer } = this.state + const conversation_id = match.params.conversation_id + + // Fetch proximity data (UMAP coordinates) + const response = await fetch( + `/api/v3/topicMod/proximity?report_id=${conversation_id}&layer_id=${selectedLayer}` + ) + const data = await response.json() + + if (data.status === 'success') { + this.setState( + { + proximityData: data.proximity_data || [], + loading: false + }, + () => { + this.renderVisualization() + } + ) + } else { + this.setState({ + error: data.message || 'Failed to load proximity data', + loading: false + }) + } + } catch (err) { + this.setState({ + error: 'Network error loading proximity data', + loading: false + }) + } + } + + componentDidUpdate(prevState) { + if (prevState.selectedLayer !== this.state.selectedLayer) { + this.loadProximityData() + } + } + + renderVisualization() { + const { proximityData } = this.state + const svgElement = this.state.svgRef.current + + if (!svgElement || proximityData.length === 0) return + + // Clear previous content + svgElement.innerHTML = '' + + // Set up dimensions + const width = 800 + const height = 600 + const margin = 50 + + // Calculate bounds + const xValues = proximityData.map((d) => d.umap_x).filter((x) => x !== undefined) + const yValues = proximityData.map((d) => d.umap_y).filter((y) => y !== undefined) + + if (xValues.length === 0 || yValues.length === 0) { + svgElement.innerHTML = + 'No coordinate data available' + return + } + + const xMin = Math.min(...xValues) + const xMax = Math.max(...xValues) + const yMin = Math.min(...yValues) + const yMax = Math.max(...yValues) + + // Create scales + const xScale = (x) => margin + ((x - xMin) / (xMax - xMin)) * (width - 2 * margin) + const yScale = (y) => height - margin - ((y - yMin) / (yMax - yMin)) * (height - 2 * margin) + + // Color mapping for moderation status + const getColor = (status) => { + switch (status) { + case 'accepted': + case 1: + return '#22c55e' + case 'rejected': + case -1: + return '#ef4444' + case 'meta': + case 0: + return '#f59e0b' + default: + return '#6b7280' + } + } + + // Group by cluster for better visualization + const clusters = {} + proximityData.forEach((point) => { + const clusterId = point.cluster_id || 0 + if (!clusters[clusterId]) clusters[clusterId] = [] + clusters[clusterId].push(point) + }) + + // Render cluster backgrounds (convex hulls would be better, but this is simpler) + Object.entries(clusters).forEach(([clusterId, points]) => { + if (points.length < 3) return + + const clusterXs = points.map((p) => xScale(p.umap_x)) + const clusterYs = points.map((p) => yScale(p.umap_y)) + const centerX = clusterXs.reduce((a, b) => a + b) / clusterXs.length + const centerY = clusterYs.reduce((a, b) => a + b) / clusterYs.length + const maxRadius = + Math.max( + ...points.map((p) => + Math.sqrt( + Math.pow(xScale(p.umap_x) - centerX, 2) + Math.pow(yScale(p.umap_y) - centerY, 2) + ) + ) + ) + 20 + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + circle.setAttribute('cx', centerX) + circle.setAttribute('cy', centerY) + circle.setAttribute('r', maxRadius) + circle.setAttribute('fill', '#f3f4f6') + circle.setAttribute('stroke', '#d1d5db') + circle.setAttribute('stroke-width', '1') + circle.setAttribute('opacity', '0.3') + svgElement.appendChild(circle) + + // Add cluster label + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text') + text.setAttribute('x', centerX) + text.setAttribute('y', centerY - maxRadius + 15) + text.setAttribute('text-anchor', 'middle') + text.setAttribute('fill', '#6b7280') + text.setAttribute('font-size', '12') + text.textContent = `Cluster ${clusterId}` + svgElement.appendChild(text) + }) + + // Render points + proximityData.forEach((point, index) => { + if (point.umap_x === undefined || point.umap_y === undefined) return + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + const x = xScale(point.umap_x) + const y = yScale(point.umap_y) + + circle.setAttribute('cx', x) + circle.setAttribute('cy', y) + circle.setAttribute('r', '4') + circle.setAttribute('fill', getColor(point.moderation_status)) + circle.setAttribute('stroke', '#fff') + circle.setAttribute('stroke-width', '1') + circle.setAttribute('cursor', 'pointer') + + // Add tooltip on hover + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title') + title.textContent = `Comment ${point.comment_id}\nCluster: ${point.cluster_id}\nStatus: ${point.moderation_status || 'pending'}\n\n${point.comment_text?.substring(0, 100)}...` + circle.appendChild(title) + + svgElement.appendChild(circle) + }) + + // Add axes + const xAxis = document.createElementNS('http://www.w3.org/2000/svg', 'line') + xAxis.setAttribute('x1', margin) + xAxis.setAttribute('y1', height - margin) + xAxis.setAttribute('x2', width - margin) + xAxis.setAttribute('y2', height - margin) + xAxis.setAttribute('stroke', '#d1d5db') + xAxis.setAttribute('stroke-width', '1') + svgElement.appendChild(xAxis) + + const yAxis = document.createElementNS('http://www.w3.org/2000/svg', 'line') + yAxis.setAttribute('x1', margin) + yAxis.setAttribute('y1', margin) + yAxis.setAttribute('x2', margin) + yAxis.setAttribute('y2', height - margin) + yAxis.setAttribute('stroke', '#d1d5db') + yAxis.setAttribute('stroke-width', '1') + svgElement.appendChild(yAxis) + + // Add axis labels + const xLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text') + xLabel.setAttribute('x', width / 2) + xLabel.setAttribute('y', height - 10) + xLabel.setAttribute('text-anchor', 'middle') + xLabel.setAttribute('fill', '#6b7280') + xLabel.textContent = 'UMAP Dimension 1' + svgElement.appendChild(xLabel) + + const yLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text') + yLabel.setAttribute('x', 15) + yLabel.setAttribute('y', height / 2) + yLabel.setAttribute('text-anchor', 'middle') + yLabel.setAttribute('fill', '#6b7280') + yLabel.setAttribute('transform', `rotate(-90, 15, ${height / 2})`) + yLabel.textContent = 'UMAP Dimension 2' + svgElement.appendChild(yLabel) + } + + render() { + const { loading, error, proximityData, selectedLayer } = this.state + + if (loading) { + return ( + + Loading proximity visualization... + + ) + } + + if (error) { + return ( + + Error: {error} + + + ) + } + + return ( + + + Proximity Visualization + + Layer: + + + + + + This visualization shows comments positioned by semantic similarity using UMAP + coordinates. Comments that are closer together are more semantically similar. + + + {proximityData.length > 0 ? ( + + + + + + Pending + + + + Accepted + + + + Rejected + + + + Meta + + + + + + + + + + Hover over points to see comment details. Points are grouped by semantic clusters. + + + ) : ( + + No proximity data available for this layer. + + )} + + ) + } +} + +export default ProximityVisualization diff --git a/client-admin/src/components/conversation-admin/topic-moderation/topic-detail.js b/client-admin/src/components/conversation-admin/topic-moderation/topic-detail.js new file mode 100644 index 0000000000..1bb214f7a8 --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/topic-detail.js @@ -0,0 +1,330 @@ +/* eslint-disable */ +// 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 . + +import React from 'react' +import { connect } from 'react-redux' +import { jsx, Box, Flex, Heading, Text, Button, Checkbox, Label } from 'theme-ui' +import { Link } from 'react-router-dom' + +const mapStateToProps = (state) => { + return { + zid_metadata: state.zid_metadata + } +} + +@connect(mapStateToProps) +class TopicDetail extends React.Component { + constructor(props) { + super(props) + this.state = { + comments: [], + selectedComments: new Set(), + loading: true, + error: null, + topicInfo: null, + selectAll: false + } + } + + componentDidMount() { + this.loadTopicComments() + } + + componentDidUpdate(prevProps) { + if (prevProps.match.params.topicKey !== this.props.match.params.topicKey) { + this.loadTopicComments() + } + } + + async loadTopicComments() { + try { + this.setState({ loading: true, error: null }) + const { match } = this.props + const conversation_id = match.params.conversation_id + const topicKey = decodeURIComponent(match.params.topicKey) + + // Fetch comments for this specific topic + const response = await fetch( + `/api/v3/topicMod/topics/${encodeURIComponent(topicKey)}/comments?report_id=${conversation_id}` + ) + const data = await response.json() + + if (data.status === 'success') { + this.setState({ + comments: data.comments || [], + loading: false, + selectedComments: new Set() + }) + } else { + this.setState({ + error: data.message || 'Failed to load comments', + loading: false + }) + } + } catch (err) { + this.setState({ + error: 'Network error loading comments', + loading: false + }) + } + } + + toggleComment(commentId) { + const { selectedComments } = this.state + const newSelected = new Set(selectedComments) + + if (newSelected.has(commentId)) { + newSelected.delete(commentId) + } else { + newSelected.add(commentId) + } + + this.setState({ + selectedComments: newSelected, + selectAll: newSelected.size === this.state.comments.length + }) + } + + toggleSelectAll() { + const { selectAll, comments } = this.state + + if (selectAll) { + this.setState({ + selectedComments: new Set(), + selectAll: false + }) + } else { + this.setState({ + selectedComments: new Set(comments.map((c) => c.comment_id)), + selectAll: true + }) + } + } + + async moderateSelected(action) { + const { selectedComments } = this.state + + if (selectedComments.size === 0) { + return + } + + try { + const { match } = this.props + const conversation_id = match.params.conversation_id + + const response = await fetch('/api/v3/topicMod/moderate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + report_id: conversation_id, + comment_ids: Array.from(selectedComments), + action: action, + moderator: 'admin' // TODO: Get from auth state + }) + }) + + const data = await response.json() + + if (data.status === 'success') { + // Reload comments to reflect changes + this.loadTopicComments() + } else { + console.error('Moderation failed:', data.message) + } + } catch (err) { + console.error('Network error during moderation:', err) + } + } + + getStatusColor(status) { + switch (status) { + case 'accepted': + case 1: + return 'green' + case 'rejected': + case -1: + return 'red' + case 'meta': + case 0: + return 'orange' + default: + return 'gray' + } + } + + getStatusText(status) { + switch (status) { + case 'accepted': + case 1: + return 'Accepted' + case 'rejected': + case -1: + return 'Rejected' + case 'meta': + case 0: + return 'Meta' + default: + return 'Pending' + } + } + + renderComment(comment) { + const { selectedComments } = this.state + const isSelected = selectedComments.has(comment.comment_id) + const status = comment.moderation_status || 'pending' + + return ( + this.toggleComment(comment.comment_id)}> + + + this.toggleComment(comment.comment_id)} + sx={{ mr: 3, mt: 1 }} + onClick={(e) => e.stopPropagation()} + /> + + {comment.comment_text} + + ID: {comment.comment_id} + Cluster: {comment.cluster_id} + Layer: {comment.layer_id} + {comment.umap_x !== undefined && comment.umap_y !== undefined && ( + + Position: ({comment.umap_x?.toFixed(2)}, {comment.umap_y?.toFixed(2)}) + + )} + + + + + + + {this.getStatusText(status)} + + + + + ) + } + + render() { + const { match } = this.props + const { loading, error, comments, selectedComments, selectAll } = this.state + const topicKey = decodeURIComponent(match.params.topicKey) + + if (loading) { + return ( + + Loading comments... + + ) + } + + if (error) { + return ( + + Error: {error} + + + ) + } + + return ( + + + + + + + + Topic: {topicKey} + + + {comments.length} comments + + + {comments.length > 0 && ( + <> + + + + + + + + + + + + + {comments.map((comment) => this.renderComment(comment))} + + )} + + {comments.length === 0 && ( + + No comments found for this topic. + + )} + + ) + } +} + +export default TopicDetail diff --git a/client-admin/src/components/conversation-admin/topic-moderation/topic-moderation.css b/client-admin/src/components/conversation-admin/topic-moderation/topic-moderation.css new file mode 100644 index 0000000000..de003d593f --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/topic-moderation.css @@ -0,0 +1,229 @@ +/* Topic Moderation CSS Enhancements */ + +.topic-card { + transition: all 0.2s ease; + position: relative; +} + +.topic-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.topic-card.expanded { + border-left: 4px solid #3b82f6; +} + +.moderation-status-pending { + background-color: #f3f4f6; + border-left: 4px solid #9ca3af; +} + +.moderation-status-accepted { + background-color: #f0fdf4; + border-left: 4px solid #22c55e; +} + +.moderation-status-rejected { + background-color: #fef2f2; + border-left: 4px solid #ef4444; +} + +.moderation-status-meta { + background-color: #fffbeb; + border-left: 4px solid #f59e0b; +} + +.moderation-buttons { + display: flex; + gap: 8px; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.topic-card:hover .moderation-buttons { + opacity: 1; +} + +.proximity-point { + transition: all 0.2s ease; + cursor: pointer; +} + +.proximity-point:hover { + r: 6; + stroke-width: 2; +} + +.cluster-background { + opacity: 0.2; + transition: opacity 0.3s ease; +} + +.cluster-background:hover { + opacity: 0.4; +} + +.stats-card { + transition: transform 0.2s ease; +} + +.stats-card:hover { + transform: scale(1.05); +} + +.progress-bar { + border-radius: 10px; + overflow: hidden; + background: linear-gradient(45deg, #f3f4f6, #e5e7eb); +} + +.progress-segment { + transition: width 0.5s ease; + height: 100%; +} + +.loading-shimmer { + animation: shimmer 1.5s infinite; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.comment-card { + transition: all 0.2s ease; +} + +.comment-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.comment-card.selected { + border-color: #3b82f6; + background-color: #eff6ff; +} + +.bulk-actions-bar { + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(8px); + background-color: rgba(249, 250, 251, 0.9); +} + +.layer-selector { + display: flex; + gap: 4px; + padding: 4px; + background-color: #f3f4f6; + border-radius: 8px; +} + +.layer-button { + padding: 8px 16px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.layer-button:hover { + background-color: #e5e7eb; +} + +.layer-button.active { + background-color: #3b82f6; + color: white; +} + +.visualization-container { + position: relative; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%); +} + +.legend { + display: flex; + gap: 16px; + padding: 12px; + background-color: #f9fafb; + border-bottom: 1px solid #e5e7eb; + font-size: 12px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid #fff; +} + +.error-state { + text-align: center; + padding: 48px 24px; + color: #6b7280; +} + +.error-state svg { + width: 48px; + height: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state { + text-align: center; + padding: 48px 24px; + color: #9ca3af; +} + +.empty-state svg { + width: 64px; + height: 64px; + margin-bottom: 16px; + opacity: 0.3; +} + +/* Responsive design */ +@media (max-width: 768px) { + .moderation-buttons { + flex-direction: column; + gap: 4px; + } + + .legend { + flex-wrap: wrap; + gap: 8px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .layer-selector { + flex-wrap: wrap; + } +} \ No newline at end of file diff --git a/client-admin/src/components/conversation-admin/topic-moderation/topic-stats.js b/client-admin/src/components/conversation-admin/topic-moderation/topic-stats.js new file mode 100644 index 0000000000..bed7f5cd13 --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/topic-stats.js @@ -0,0 +1,182 @@ +/* eslint-disable */ +// 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 . + +import React from 'react' +import { connect } from 'react-redux' +import { jsx, Box, Flex, Heading, Text, Card } from 'theme-ui' + +const mapStateToProps = (state) => { + return { + zid_metadata: state.zid_metadata + } +} + +@connect(mapStateToProps) +class TopicStats extends React.Component { + constructor(props) { + super(props) + this.state = { + stats: null, + loading: true, + error: null + } + } + + componentDidMount() { + this.loadStats() + } + + async loadStats() { + try { + this.setState({ loading: true, error: null }) + const conversation_id = this.props.conversation_id + + console.log('TopicStats loadStats - conversation_id:', conversation_id) + + // Fetch moderation statistics + const response = await fetch(`/api/v3/topicMod/stats?conversation_id=${conversation_id}`) + const data = await response.json() + + if (data.status === 'success') { + this.setState({ + stats: data.stats, + loading: false + }) + } else { + this.setState({ + error: data.message || 'Failed to load statistics', + loading: false + }) + } + } catch (err) { + this.setState({ + error: 'Network error loading statistics', + loading: false + }) + } + } + + renderStatCard(title, value, color = 'primary') { + return ( + + {value} + {title} + + ) + } + + render() { + const { loading, error, stats } = this.state + + if (loading) { + return ( + + Loading statistics... + + ) + } + + if (error) { + return ( + + Error: {error} + + ) + } + + if (!stats) { + return ( + + No statistics available. + + ) + } + + const completionRate = + stats.total_topics > 0 + ? (((stats.total_topics - stats.pending) / stats.total_topics) * 100).toFixed(1) + : 0 + + return ( + + + Topic Moderation Statistics + + + + {this.renderStatCard('Total Topics', stats.total_topics)} + {this.renderStatCard('Pending', stats.pending, 'gray')} + {this.renderStatCard('Accepted', stats.accepted, 'green')} + {this.renderStatCard('Rejected', stats.rejected, 'red')} + {this.renderStatCard('Meta', stats.meta, 'orange')} + {this.renderStatCard('Completion Rate', `${completionRate}%`, 'blue')} + + + + + Moderation Progress + + + + + Overall Progress + {completionRate}% Complete + + + + + 0 ? (stats.accepted / stats.total_topics) * 100 : 0}%`, + transition: 'width 0.3s ease' + }} + /> + 0 ? (stats.rejected / stats.total_topics) * 100 : 0}%`, + transition: 'width 0.3s ease' + }} + /> + 0 ? (stats.meta / stats.total_topics) * 100 : 0}%`, + transition: 'width 0.3s ease' + }} + /> + + + + + Accepted: {stats.accepted} + Rejected: {stats.rejected} + Meta: {stats.meta} + Pending: {stats.pending} + + + + + {stats.total_topics === 0 && ( + + + No topics have been generated for this conversation yet. + + + Run the Delphi pipeline to generate topics for moderation. + + + )} + + ) + } +} + +export default TopicStats diff --git a/client-admin/src/components/conversation-admin/topic-moderation/topic-tree.js b/client-admin/src/components/conversation-admin/topic-moderation/topic-tree.js new file mode 100644 index 0000000000..439da0bc23 --- /dev/null +++ b/client-admin/src/components/conversation-admin/topic-moderation/topic-tree.js @@ -0,0 +1,287 @@ +/* eslint-disable */ +// 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 . + +import React from 'react' +import { connect } from 'react-redux' +import { jsx, Box, Flex, Heading, Text, Button } from 'theme-ui' +import { Link } from 'react-router-dom' + +const mapStateToProps = (state) => { + return { + topics: state.topic_mod_topics || {}, + zid_metadata: state.zid_metadata + } +} + +@connect(mapStateToProps) +class TopicTree extends React.Component { + constructor(props) { + super(props) + this.state = { + selectedLayer: '0', + expandedTopics: new Set(), + topicsData: null, + loading: true, + error: null + } + } + + componentDidMount() { + this.loadTopics() + } + + async loadTopics() { + try { + this.setState({ loading: true, error: null }) + const conversation_id = this.props.conversation_id + + console.log('TopicTree loadTopics - conversation_id:', conversation_id) + + // Fetch topics from API + const response = await fetch(`/api/v3/topicMod/topics?conversation_id=${conversation_id}`) + const data = await response.json() + + if (data.status === 'success') { + this.setState({ + topicsData: data.topics_by_layer || {}, + loading: false + }) + } else { + this.setState({ + error: data.message || 'Failed to load topics', + loading: false + }) + } + } catch (err) { + this.setState({ + error: 'Network error loading topics', + loading: false + }) + } + } + + toggleTopic(topicKey) { + const { expandedTopics } = this.state + const newExpanded = new Set(expandedTopics) + + if (newExpanded.has(topicKey)) { + newExpanded.delete(topicKey) + } else { + newExpanded.add(topicKey) + } + + this.setState({ expandedTopics: newExpanded }) + } + + getStatusColor(status) { + switch (status) { + case 'accepted': + return 'green' + case 'rejected': + return 'red' + case 'meta': + return 'orange' + default: + return 'gray' + } + } + + async moderateTopic(topicKey, action) { + try { + const conversation_id = this.props.conversation_id + + const response = await fetch('/api/v3/topicMod/moderate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + conversation_id: conversation_id, + topic_key: topicKey, + action: action, + moderator: 'admin' // TODO: Get from auth state + }) + }) + + const data = await response.json() + + if (data.status === 'success') { + // Reload topics to reflect changes + this.loadTopics() + } else { + console.error('Moderation failed:', data.message) + } + } catch (err) { + console.error('Network error during moderation:', err) + } + } + + renderTopic(topic, layerId, clusterId) { + const { match } = this.props + const { expandedTopics } = this.state + const topicKey = topic.topic_key || `${layerId}_${clusterId}` + const isExpanded = expandedTopics.has(topicKey) + const status = topic.moderation?.status || 'pending' + + return ( + + + + + + + Layer {layerId}, Cluster {clusterId} + + Status: {status} + + {topic.topic_name || 'Unnamed Topic'} + {topic.moderation?.comment_count && ( + + {topic.moderation.comment_count} comments + + )} + + + + + + + + + + + + + {isExpanded && ( + + + Model: {topic.model_name || 'Unknown'} + + + Created: {topic.created_at ? new Date(topic.created_at).toLocaleString() : 'Unknown'} + + {topic.moderation?.moderator && ( + + Moderated by: {topic.moderation.moderator} + + )} + + )} + + ) + } + + renderLayer(layerId, topics) { + const layerTopics = Object.entries(topics).sort(([a], [b]) => parseInt(a) - parseInt(b)) + + return ( + + + Layer {layerId} ({layerTopics.length} topics) + + {layerTopics.map(([clusterId, topic]) => this.renderTopic(topic, layerId, clusterId))} + + ) + } + + render() { + const { loading, error, topicsData, selectedLayer } = this.state + + if (loading) { + return ( + + Loading topics... + + ) + } + + if (error) { + return ( + + Error: {error} + + + ) + } + + if (!topicsData || Object.keys(topicsData).length === 0) { + return ( + + No topics available for this conversation. + + Topics are generated by the Delphi pipeline. Make sure the pipeline has been run for + this conversation. + + + ) + } + + const layers = Object.entries(topicsData).sort(([a], [b]) => parseInt(a) - parseInt(b)) + + return ( + + + View Layer: + {layers.map(([layerId]) => ( + + ))} + + + + {selectedLayer === 'all' + ? layers.map(([layerId, topics]) => this.renderLayer(layerId, topics)) + : topicsData[selectedLayer] && this.renderLayer(selectedLayer, topicsData[selectedLayer])} + + ) + } +} + +export default TopicTree diff --git a/client-participation-alpha/.astro/content-assets.mjs b/client-participation-alpha/.astro/content-assets.mjs new file mode 100644 index 0000000000..2b8b8234b9 --- /dev/null +++ b/client-participation-alpha/.astro/content-assets.mjs @@ -0,0 +1 @@ +export default new Map(); \ No newline at end of file diff --git a/client-participation-alpha/.astro/content-modules.mjs b/client-participation-alpha/.astro/content-modules.mjs new file mode 100644 index 0000000000..2b8b8234b9 --- /dev/null +++ b/client-participation-alpha/.astro/content-modules.mjs @@ -0,0 +1 @@ +export default new Map(); \ No newline at end of file diff --git a/client-participation-alpha/.astro/content.d.ts b/client-participation-alpha/.astro/content.d.ts new file mode 100644 index 0000000000..c0082cc81e --- /dev/null +++ b/client-participation-alpha/.astro/content.d.ts @@ -0,0 +1,199 @@ +declare module 'astro:content' { + export interface RenderResult { + Content: import('astro/runtime/server/index.js').AstroComponentFactory; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + } + interface Render { + '.md': Promise; + } + + export interface RenderedContent { + html: string; + metadata?: { + imagePaths: Array; + [key: string]: unknown; + }; + } +} + +declare module 'astro:content' { + type Flatten = T extends { [K: string]: infer U } ? U : never; + + export type CollectionKey = keyof AnyEntryMap; + export type CollectionEntry = Flatten; + + export type ContentCollectionKey = keyof ContentEntryMap; + export type DataCollectionKey = keyof DataEntryMap; + + type AllValuesOf = T extends any ? T[keyof T] : never; + type ValidContentEntrySlug = AllValuesOf< + ContentEntryMap[C] + >['slug']; + + export type ReferenceDataEntry< + C extends CollectionKey, + E extends keyof DataEntryMap[C] = string, + > = { + collection: C; + id: E; + }; + export type ReferenceContentEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}) = string, + > = { + collection: C; + slug: E; + }; + export type ReferenceLiveEntry = { + collection: C; + id: string; + }; + + /** @deprecated Use `getEntry` instead. */ + export function getEntryBySlug< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + // Note that this has to accept a regular string too, for SSR + entrySlug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + + /** @deprecated Use `getEntry` instead. */ + export function getDataEntryById( + collection: C, + entryId: E, + ): Promise>; + + export function getCollection>( + collection: C, + filter?: (entry: CollectionEntry) => entry is E, + ): Promise; + export function getCollection( + collection: C, + filter?: (entry: CollectionEntry) => unknown, + ): Promise[]>; + + export function getLiveCollection( + collection: C, + filter?: LiveLoaderCollectionFilterType, + ): Promise< + import('astro').LiveDataCollectionResult, LiveLoaderErrorType> + >; + + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + entry: ReferenceContentEntry, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + entry: ReferenceDataEntry, + ): E extends keyof DataEntryMap[C] + ? Promise + : Promise | undefined>; + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + slug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + collection: C, + id: E, + ): E extends keyof DataEntryMap[C] + ? string extends keyof DataEntryMap[C] + ? Promise | undefined + : Promise + : Promise | undefined>; + export function getLiveEntry( + collection: C, + filter: string | LiveLoaderEntryFilterType, + ): Promise, LiveLoaderErrorType>>; + + /** Resolve an array of entry references from the same collection */ + export function getEntries( + entries: ReferenceContentEntry>[], + ): Promise[]>; + export function getEntries( + entries: ReferenceDataEntry[], + ): Promise[]>; + + export function render( + entry: AnyEntryMap[C][string], + ): Promise; + + export function reference( + collection: C, + ): import('astro/zod').ZodEffects< + import('astro/zod').ZodString, + C extends keyof ContentEntryMap + ? ReferenceContentEntry> + : ReferenceDataEntry + >; + // Allow generic `string` to avoid excessive type errors in the config + // if `dev` is not running to update as you edit. + // Invalid collection names will be caught at build time. + export function reference( + collection: C, + ): import('astro/zod').ZodEffects; + + type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; + type InferEntrySchema = import('astro/zod').infer< + ReturnTypeOrOriginal['schema']> + >; + + type ContentEntryMap = { + + }; + + type DataEntryMap = { + + }; + + type AnyEntryMap = ContentEntryMap & DataEntryMap; + + type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< + infer TData, + infer TEntryFilter, + infer TCollectionFilter, + infer TError + > + ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } + : { data: never; entryFilter: never; collectionFilter: never; error: never }; + type ExtractDataType = ExtractLoaderTypes['data']; + type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; + type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; + type ExtractErrorType = ExtractLoaderTypes['error']; + + type LiveLoaderDataType = + LiveContentConfig['collections'][C]['schema'] extends undefined + ? ExtractDataType + : import('astro/zod').infer< + Exclude + >; + type LiveLoaderEntryFilterType = + ExtractEntryFilterType; + type LiveLoaderCollectionFilterType = + ExtractCollectionFilterType; + type LiveLoaderErrorType = ExtractErrorType< + LiveContentConfig['collections'][C]['loader'] + >; + + export type ContentConfig = typeof import("../src/content.config.mjs"); + export type LiveContentConfig = never; +} diff --git a/client-participation-alpha/.astro/data-store.json b/client-participation-alpha/.astro/data-store.json new file mode 100644 index 0000000000..e72bff0062 --- /dev/null +++ b/client-participation-alpha/.astro/data-store.json @@ -0,0 +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 diff --git a/client-participation-alpha/.astro/settings.json b/client-participation-alpha/.astro/settings.json new file mode 100644 index 0000000000..22ce6f2977 --- /dev/null +++ b/client-participation-alpha/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "_variables": { + "lastUpdateCheck": 1753121227723 + } +} \ No newline at end of file diff --git a/client-participation-alpha/.astro/types.d.ts b/client-participation-alpha/.astro/types.d.ts new file mode 100644 index 0000000000..03d7cc43f1 --- /dev/null +++ b/client-participation-alpha/.astro/types.d.ts @@ -0,0 +1,2 @@ +/// +/// \ No newline at end of file diff --git a/client-participation-alpha/.gitignore b/client-participation-alpha/.gitignore new file mode 100644 index 0000000000..d9785a612f --- /dev/null +++ b/client-participation-alpha/.gitignore @@ -0,0 +1,13 @@ +node_modules +.DS_Store +*.log +js/tmpl/ +build +dist +devel +.polis_env +.polis_s3_creds_client.json +.sass-cache/* +.bashrc +.env_always +scratch diff --git a/client-participation-alpha/Dockerfile b/client-participation-alpha/Dockerfile new file mode 100644 index 0000000000..1e3ed9f1aa --- /dev/null +++ b/client-participation-alpha/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine +WORKDIR /app + +ARG PUBLIC_SERVICE_URL +ARG INTERNAL_SERVICE_URL +ARG OIDC_CACHE_KEY_PREFIX +ARG OIDC_CACHE_KEY_ID_TOKEN_SUFFIX + +ENV PUBLIC_SERVICE_URL=$PUBLIC_SERVICE_URL +ENV INTERNAL_SERVICE_URL=$INTERNAL_SERVICE_URL +ENV PUBLIC_OIDC_CACHE_KEY_PREFIX=$OIDC_CACHE_KEY_PREFIX +ENV PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX=$OIDC_CACHE_KEY_ID_TOKEN_SUFFIX + +COPY package*.json ./ +RUN npm install +COPY . . + +RUN npm run build + +EXPOSE 4321 +CMD ["node", "dist/server/entry.mjs"] \ No newline at end of file diff --git a/client-participation-alpha/README.md b/client-participation-alpha/README.md new file mode 100644 index 0000000000..e34a99b446 --- /dev/null +++ b/client-participation-alpha/README.md @@ -0,0 +1,47 @@ +# Astro Starter Kit: Minimal + +```sh +npm create astro@latest -- --template minimal +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal) +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json) + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +├── public/ +├── src/ +│ └── pages/ +│ └── index.astro +└── package.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/client-participation-alpha/astro.config.mjs b/client-participation-alpha/astro.config.mjs new file mode 100644 index 0000000000..c2b4e9aa85 --- /dev/null +++ b/client-participation-alpha/astro.config.mjs @@ -0,0 +1,18 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +import react from '@astrojs/react'; + +import node from '@astrojs/node'; + +// https://astro.build/config +export default defineConfig({ + output: 'server', + adapter: node({ + mode: 'standalone', + }), + server: { + host: '0.0.0.0' + }, + integrations: [react()] +}); \ No newline at end of file diff --git a/client-participation-alpha/package-lock.json b/client-participation-alpha/package-lock.json new file mode 100644 index 0000000000..9b65bb189b --- /dev/null +++ b/client-participation-alpha/package-lock.json @@ -0,0 +1,5301 @@ +{ + "name": "client-participation-alpha", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "version": "0.0.1", + "dependencies": { + "@astrojs/node": "^9.3.0", + "@astrojs/react": "^4.3.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "astro": "^5.11.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz", + "integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.6.1.tgz", + "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==", + "dependencies": { + "@astrojs/internal-helpers": "0.6.1", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.1.0", + "js-yaml": "^4.1.0", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.2.1", + "smol-toml": "^1.3.1", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/node": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.3.0.tgz", + "integrity": "sha512-IV8NzGStHAsKBz1ljxxD8PBhBfnw/BEx/PZfsncTNXg9D4kQtZbSy+Ak0LvDs+rPmK0VeXLNn0HAdWuHCVg8cw==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.6.1", + "send": "^1.2.0", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^5.3.0" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/react": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/react/-/react-4.3.0.tgz", + "integrity": "sha512-N02aj52Iezn69qHyx5+XvPqgsPMEnel9mI5JMbGiRMTzzLMuNaxRVoQTaq2024Dpr7BLsxCjqMkNvelqMDhaHA==", + "license": "MIT", + "dependencies": { + "@vitejs/plugin-react": "^4.4.1", + "ultrahtml": "^1.6.0", + "vite": "^6.3.5" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", + "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", + "react": "^17.0.2 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-2.4.0.tgz", + "integrity": "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q==", + "dependencies": { + "blob-to-buffer": "^1.2.8", + "cross-fetch": "^3.0.4", + "fontkit": "^2.0.2" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", + "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", + "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", + "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", + "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", + "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", + "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", + "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", + "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", + "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", + "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", + "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", + "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", + "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", + "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", + "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", + "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", + "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", + "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", + "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", + "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", + "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", + "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", + "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", + "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", + "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", + "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", + "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", + "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", + "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", + "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", + "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", + "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", + "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", + "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", + "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", + "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", + "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", + "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", + "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", + "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", + "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", + "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", + "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", + "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", + "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", + "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "dependencies": { + "@shikijs/types": "3.7.0", + "@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==", + "dependencies": { + "@shikijs/types": "3.7.0", + "@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==", + "dependencies": { + "@shikijs/types": "3.7.0", + "@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==", + "dependencies": { + "@shikijs/types": "3.7.0" + } + }, + "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==", + "dependencies": { + "@shikijs/types": "3.7.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.7.0.tgz", + "integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "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==" + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "node_modules/@types/fontkit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/fontkit/-/fontkit-2.0.8.tgz", + "integrity": "sha512-wN+8bYxIpJf+5oZdrdtaX04qUuWHcKxcDEgRS9Qm9ZClSHjzEn13SxUC+5eRM+4yXIeTYk8mTzLAWGF64847ew==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "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==" + }, + "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==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "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==", + "dependencies": { + "@astrojs/compiler": "^2.12.2", + "@astrojs/internal-helpers": "0.6.1", + "@astrojs/markdown-remark": "6.3.2", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^2.4.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.1", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.2.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.0.2", + "cssesc": "^3.0.0", + "debug": "^4.4.0", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.1.1", + "diff": "^5.2.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.6.0", + "esbuild": "^0.25.0", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.3.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.1.1", + "import-meta-resolve": "^4.1.0", + "js-yaml": "^4.1.0", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.0", + "package-manager-detector": "^1.1.0", + "picomatch": "^4.0.2", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.1", + "shiki": "^3.2.1", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.12", + "tsconfck": "^3.1.5", + "ultrahtml": "^1.6.0", + "unifont": "~0.5.0", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.15.0", + "vfile": "^6.0.3", + "vite": "^6.3.4", + "vitefu": "^1.0.6", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.1", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.5", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.33.3" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/blob-to-buffer": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/blob-to-buffer/-/blob-to-buffer-1.2.9.tgz", + "integrity": "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "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==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "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==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==" + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.180", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", + "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==" + }, + "node_modules/esbuild": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", + "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.6", + "@esbuild/android-arm": "0.25.6", + "@esbuild/android-arm64": "0.25.6", + "@esbuild/android-x64": "0.25.6", + "@esbuild/darwin-arm64": "0.25.6", + "@esbuild/darwin-x64": "0.25.6", + "@esbuild/freebsd-arm64": "0.25.6", + "@esbuild/freebsd-x64": "0.25.6", + "@esbuild/linux-arm": "0.25.6", + "@esbuild/linux-arm64": "0.25.6", + "@esbuild/linux-ia32": "0.25.6", + "@esbuild/linux-loong64": "0.25.6", + "@esbuild/linux-mips64el": "0.25.6", + "@esbuild/linux-ppc64": "0.25.6", + "@esbuild/linux-riscv64": "0.25.6", + "@esbuild/linux-s390x": "0.25.6", + "@esbuild/linux-x64": "0.25.6", + "@esbuild/netbsd-arm64": "0.25.6", + "@esbuild/netbsd-x64": "0.25.6", + "@esbuild/openbsd-arm64": "0.25.6", + "@esbuild/openbsd-x64": "0.25.6", + "@esbuild/openharmony-arm64": "0.25.6", + "@esbuild/sunos-x64": "0.25.6", + "@esbuild/win32-arm64": "0.25.6", + "@esbuild/win32-ia32": "0.25.6", + "@esbuild/win32-x64": "0.25.6" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "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==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.3.0.tgz", + "integrity": "sha512-czoqATrcnxgWb/nAkfyIrRp6Q8biYj7nGnL6zfhTcX+JKKpWHFBnb8uNMw/kZr7u++3Y3wYSYoZgHkCcsuBpBg==", + "dependencies": { + "@types/fontkit": "^2.0.8", + "fontkit": "^2.0.4" + } + }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==" + }, + "node_modules/h3": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.3.tgz", + "integrity": "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ==", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.4", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.0", + "radix3": "^1.1.2", + "ufo": "^1.6.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "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==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5/node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-text": { + "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==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "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==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "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==", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "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==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "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==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "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==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "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==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "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==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz", + "integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==" + }, + "node_modules/node-mock-http": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.1.tgz", + "integrity": "sha512-0gJJgENizp4ghds/Ywu2FCmcRsgBTmRQzYPZm61wy+Em2sBarSka0OhQS5huLBg6od1zkNpnWMCZloQDFVvOMQ==" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ofetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", + "integrity": "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==", + "dependencies": { + "destr": "^2.0.3", + "node-fetch-native": "^1.6.4", + "ufo": "^1.5.4" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==" + }, + "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==", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.0.tgz", + "integrity": "sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==" + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "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==" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", + "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.2", + "@rollup/rollup-android-arm64": "4.44.2", + "@rollup/rollup-darwin-arm64": "4.44.2", + "@rollup/rollup-darwin-x64": "4.44.2", + "@rollup/rollup-freebsd-arm64": "4.44.2", + "@rollup/rollup-freebsd-x64": "4.44.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", + "@rollup/rollup-linux-arm-musleabihf": "4.44.2", + "@rollup/rollup-linux-arm64-gnu": "4.44.2", + "@rollup/rollup-linux-arm64-musl": "4.44.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-musl": "4.44.2", + "@rollup/rollup-linux-s390x-gnu": "4.44.2", + "@rollup/rollup-linux-x64-gnu": "4.44.2", + "@rollup/rollup-linux-x64-musl": "4.44.2", + "@rollup/rollup-win32-arm64-msvc": "4.44.2", + "@rollup/rollup-win32-ia32-msvc": "4.44.2", + "@rollup/rollup-win32-x64-msvc": "4.44.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/server-destroy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "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", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + "node_modules/smol-toml": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.1.tgz", + "integrity": "sha512-CxdwHXyYTONGHThDbq5XdwbFsuY4wlClRGejfE2NtwUtiHYsP1QtNsHb/hnj31jKYSchztJsaA8pSQoVzkfCFg==", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.5.2.tgz", + "integrity": "sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg==", + "dependencies": { + "css-tree": "^3.0.0", + "ofetch": "^1.4.1", + "ohash": "^2.0.0" + } + }, + "node_modules/unist-util-find-after": { + "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==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "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==", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "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==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.16.0.tgz", + "integrity": "sha512-WQ37/H5A7LcRPWfYOrDa1Ys02xAbpPJq6q5GkO88FBXVSQzHd7+BjEwfRqyaSWCv9MbsJy058GWjjPjcJ16GGA==", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^4.0.3", + "destr": "^2.0.5", + "h3": "^1.15.2", + "lru-cache": "^10.4.3", + "node-fetch-native": "^1.6.6", + "ofetch": "^1.4.1", + "ufo": "^1.6.1" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6.0.3 || ^7.0.0", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/kv": "^1.0.1", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.75", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", + "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "peerDependencies": { + "zod": "^3.24.1" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/client-participation-alpha/package.json b/client-participation-alpha/package.json new file mode 100644 index 0000000000..aadfca2e57 --- /dev/null +++ b/client-participation-alpha/package.json @@ -0,0 +1,20 @@ +{ + "name": "", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^9.3.0", + "@astrojs/react": "^4.3.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "astro": "^5.11.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + } +} diff --git a/client-participation-alpha/src/components/EmailSubscribeForm.jsx b/client-participation-alpha/src/components/EmailSubscribeForm.jsx new file mode 100644 index 0000000000..027e060bd6 --- /dev/null +++ b/client-participation-alpha/src/components/EmailSubscribeForm.jsx @@ -0,0 +1,75 @@ +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', + }); + + 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 }) { + const [email, setEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [feedback, setFeedback] = useState(''); + const [errorFeedback, setErrorFeedback] = useState(''); + + const handleSubmit = async (event) => { + event.preventDefault(); + if (isSubmitting || !email.includes('@')) return; + + setIsSubmitting(true); + setFeedback(''); + setErrorFeedback(''); + + try { + await subscribeAPI(email, conversation_id); + setFeedback(s.notificationsAlreadySubscribed || 'You are subscribed to updates for this conversation.'); + } catch (error) { + console.error("Subscription failed:", error); + setErrorFeedback(s.notificationsSubscribeErrorAlert || "Sorry, we couldn't subscribe you. Please try again later."); + } finally { + setIsSubmitting(false); + } + }; + + if (feedback) { + return

{feedback}

; + } + + return ( +
+

{s.notificationsGetNotified}

+
+ setEmail(e.target.value)} + disabled={isSubmitting} + required + /> + +
+ {errorFeedback &&

{errorFeedback}

} +
+ ); +} diff --git a/client-participation-alpha/src/components/Header.astro b/client-participation-alpha/src/components/Header.astro new file mode 100644 index 0000000000..04cae7790e --- /dev/null +++ b/client-participation-alpha/src/components/Header.astro @@ -0,0 +1,5 @@ +--- +--- +
+ +
\ No newline at end of file diff --git a/client-participation-alpha/src/components/Statement.jsx b/client-participation-alpha/src/components/Statement.jsx new file mode 100644 index 0000000000..92c6faf196 --- /dev/null +++ b/client-participation-alpha/src/components/Statement.jsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; + +export function Statement({ statement, onVote, isVoting, s, isStatementImportant, setIsStatmentImportant, voteError }) { + const [showImportanceDesc, setShowImportanceDesc] = useState(false); + + const handleVoteClick = (voteType) => { + if (isVoting) return; + onVote(voteType, statement.tid); + }; + + const passUnsureText = s.pass; + + return ( +
+
+
+
+ {s.anonPerson || 'Anonymous'} {s.x_wrote || 'wrote:'} +
+
+

{statement.txt}

+ +
+ + setIsStatmentImportant(prev => !prev)} + checked={isStatementImportant} + /> + setShowImportanceDesc(prev => !prev)} + viewBox="0 0 512 512" + xmlns="http://www.w3.org/2000/svg" + height="17px" + width="17px" + className="info-icon" + > + + +
+ + {showImportanceDesc && ( +

+ {s.importantCheckboxDesc || 'Marking a statement as important gives it a higher priority in the analysis.'} +

+ )} + +
+ + + +
+ {voteError && ( +

+ {voteError} +

+ )} +
+ ); +} diff --git a/client-participation-alpha/src/components/Survey.jsx b/client-participation-alpha/src/components/Survey.jsx new file mode 100644 index 0000000000..2c40fa9ec0 --- /dev/null +++ b/client-participation-alpha/src/components/Survey.jsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { Statement } from './Statement'; +import EmailSubscribeForm from './EmailSubscribeForm'; +import { getPreferredLanguages } from '../strings/strings'; +import { getConversationToken } from '../lib/auth'; + +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({ + agid: 1, + conversation_id, + high_priority, + lang: getPreferredLanguages()[0], + pid: decodedToken?.pid || "mypid", + 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; + 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; +}; + + +export default function Survey({ initialStatement, s, conversation_id }) { + const [statement, setStatement] = useState(initialStatement); + const [isFetchingNext, setIsFetchingNext] = useState(false); + const [isStatementImportant, setIsStatmentImportant] = useState(false); + const [voteError, setVoteError] = useState(null); + + const handleVote = async (voteType, tid) => { + setIsFetchingNext(true); + setVoteError(null); + + try { + const vote = { vote: voteType, tid: tid }; + const result = await submitVoteAndGetNextCommentAPI(vote, conversation_id, isStatementImportant); + + setVoteError(null); + if (result?.nextComment) { + setStatement(result.nextComment); + } else { + setStatement(undefined); + } + setIsStatmentImportant(false); + + } catch (error) { + console.error("Vote submission failed:", error.message); + 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") { + errorMessage = s.convIsClosed || "This conversation is closed. No further voting is allowed."; + } else if (error.message === "polis_err_post_votes_social_needed") { + errorMessage = "You need to sign in to vote."; + } else if (error.message === "polis_err_xid_not_whitelisted") { + errorMessage = "Sorry, you must be registered to vote. Please sign in or contact the conversation owner."; + } + + setVoteError(errorMessage); + } finally { + setIsFetchingNext(false); + } + }; + + + return ( + <> + {statement ? ( + + ) : ( + + )} + + ); +} diff --git a/client-participation-alpha/src/components/SurveyForm.jsx b/client-participation-alpha/src/components/SurveyForm.jsx new file mode 100644 index 0000000000..da1c72f9a6 --- /dev/null +++ b/client-participation-alpha/src/components/SurveyForm.jsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { getConversationToken } from '../lib/auth'; + +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', + }); + + 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); + } + } + } catch (error) { + console.error("Network error during comment submission:", error); + } +}; + + +export default function SurveyForm({ s, conversation_id }) { + const [text, setText] = useState(''); + const [feedback, setFeedback] = useState(''); + const maxLength = 400; + + const handleSubmit = (event) => { + event.preventDefault(); + if (!text.trim()) return; + setFeedback(s.commentSent); + const submittedText = text; + setText(''); + submitPerspectiveAPI(submittedText, conversation_id); + }; + + if (feedback) { + return

{feedback}

; + } + + return ( +
+
+

+

{s.helpWriteListIntro}

+
    +
  • {s.helpWriteListStandalone}
  • +
  • {s.helpWriteListRaisNew}
  • +
  • {s.helpWriteListShort}
  • +
+

+
+
+
+