From 3980318d786e22ee4d868d3037eee694b191ae32 Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Tue, 25 Nov 2025 22:38:18 +0000 Subject: [PATCH 01/11] Deploy: added env var checkup script. --- deploy/elasticsearch.env | 3 - deploy/export_env_vars.sh | 21 ++++- nifi/user-scripts/utils/lint_env.py | 115 ++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 nifi/user-scripts/utils/lint_env.py diff --git a/deploy/elasticsearch.env b/deploy/elasticsearch.env index b084dba38..efe5c647e 100644 --- a/deploy/elasticsearch.env +++ b/deploy/elasticsearch.env @@ -140,9 +140,6 @@ ELASTICSEARCH_HOSTS='["https://elasticsearch-1:9200","https://elasticsearch-2:92 KIBANA_HOST="https://kibana:5601" -KIBANA_SERVER_NAME="cogstack-kibana" - - ########################################################################## KIBANA Env vars ########################################################################### # NOTE: some variables from the Elasticsearch section are used # - ${ELASTICSEARCH_VERSION} is used for certificate paths, as well as kibana.yml config path. diff --git a/deploy/export_env_vars.sh b/deploy/export_env_vars.sh index ea8266095..5064a0d4b 100755 --- a/deploy/export_env_vars.sh +++ b/deploy/export_env_vars.sh @@ -3,12 +3,15 @@ # Enable strict mode (without -e to avoid exit-on-error) set -uo pipefail +# Support being sourced in shells where BASH_SOURCE is unset (e.g. zsh) +SCRIPT_SOURCE="${BASH_SOURCE[0]-$0}" +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)" +SCRIPT_NAME="$(basename "$SCRIPT_SOURCE")" -echo "🔧 Running $(basename "${BASH_SOURCE[0]}")..." +echo "🔧 Running $SCRIPT_NAME..." set -a -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEPLOY_DIR="$SCRIPT_DIR" SECURITY_DIR="$SCRIPT_DIR/../security/env" SERVICES_DIR="$SCRIPT_DIR/../services" @@ -38,6 +41,18 @@ env_files=( "$SERVICES_DIR/cogstack-nlp/medcat-service/env/medcat.env" ) +LINT_SCRIPT="$SCRIPT_DIR/../nifi/user-scripts/utils/lint_env.py" + +if [ -x "$LINT_SCRIPT" ]; then + echo "🔍 Validating env files..." + if ! python3 "$LINT_SCRIPT" "${env_files[@]}"; then + echo "❌ Env validation failed. Fix the errors above before continuing." + exit 1 + fi +else + echo "⚠️ Skipping env validation; $LINT_SCRIPT not found or not executable." +fi + for env_file in "${env_files[@]}"; do if [ -f "$env_file" ]; then echo "✅ Sourcing $env_file" @@ -56,4 +71,4 @@ set +a # Restore safe defaults for interactive/dev shell set +u -set +o pipefail \ No newline at end of file +set +o pipefail diff --git a/nifi/user-scripts/utils/lint_env.py b/nifi/user-scripts/utils/lint_env.py new file mode 100644 index 000000000..8918e9152 --- /dev/null +++ b/nifi/user-scripts/utils/lint_env.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" + Lightweight env file validator used by deploy/export_env_vars.sh. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable +from pathlib import Path + +PORT_SUFFIXES = ("_PORT", "_OUTPUT_PORT", "_INPUT_PORT") +BOOL_SUFFIXES = ("_ENABLED", "_SSL_ENABLED", "_BAKE") +BOOL_VALUES = {"true", "false", "1", "0", "yes", "no", "on", "off"} + + +def strip_quotes(value: str) -> str: + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + return value[1:-1] + return value + + +def parse_env_file(path: Path) -> tuple[list[str], list[str], list[tuple[str, str, int]]]: + errors: list[str] = [] + warnings: list[str] = [] + entries: list[tuple[str, str, int]] = [] + + for lineno, raw_line in enumerate(path.read_text().splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + if line.startswith("export "): + line = line[len("export ") :].strip() + + if "=" not in line: + errors.append(f"{path}:{lineno}: missing '=' (got: {raw_line})") + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + + if not key: + errors.append(f"{path}:{lineno}: empty key (got: {raw_line})") + continue + + entries.append((key, value, lineno)) + + seen = {} + for key, _, lineno in entries: + if key in seen: + warnings.append(f"{path}:{lineno}: duplicate key '{key}' (also on line {seen[key]})") + else: + seen[key] = lineno + + return errors, warnings, entries + + +def validate_entries(path: Path, entries: Iterable[tuple[str, str, int]]) -> list[str]: + errors: list[str] = [] + + for key, value, lineno in entries: + normalized = strip_quotes(value) + + if any(key.endswith(suffix) for suffix in PORT_SUFFIXES): + if not normalized.isdigit(): + errors.append(f"{path}:{lineno}: '{key}' should be an integer port (got '{value}')") + + if any(key.endswith(suffix) for suffix in BOOL_SUFFIXES): + if normalized.lower() not in BOOL_VALUES: + errors.append( + f"{path}:{lineno}: '{key}' should be one of {sorted(BOOL_VALUES)} (got '{value}')" + ) + + return errors + + +def main(args: list[str]) -> int: + if not args: + script = Path(__file__).name + print(f"Usage: {script} [ ...]") + return 1 + + warnings: list[str] = [] + errors: list[str] = [] + checked_files = 0 + + for path_str in args: + path = Path(path_str).resolve() + if not path.exists(): + warnings.append(f"Skipping missing env file: {path}") + continue + + checked_files += 1 + parse_errors, parse_warnings, entries = parse_env_file(path) + errors.extend(parse_errors) + warnings.extend(parse_warnings) + errors.extend(validate_entries(path, entries)) + + for warning in warnings: + print(f"⚠️ {warning}") + + if errors: + print("❌ Env validation failed:") + for err in errors: + print(f" - {err}") + return 1 + + print(f"✅ Env validation passed ({checked_files} files checked)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From 3318a4e038e19855d4b61479c2d4a897597f9ebc Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Wed, 26 Nov 2025 15:09:15 +0000 Subject: [PATCH 02/11] NiFi: updated docker image + packge list to install. --- .gitignore | 1 + nifi/Dockerfile | 99 ++++++++++++++++++++++++--------------- nifi/requirements-dev.txt | 8 ++++ nifi/requirements.txt | 42 ++++++----------- 4 files changed, 83 insertions(+), 67 deletions(-) create mode 100644 nifi/requirements-dev.txt diff --git a/.gitignore b/.gitignore index 564452318..892ee811e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .vscode .venv .ruff_cache +.mypy_cache venv **__pycache__ **/venv diff --git a/nifi/Dockerfile b/nifi/Dockerfile index 66ad8fc89..f2074902c 100644 --- a/nifi/Dockerfile +++ b/nifi/Dockerfile @@ -2,6 +2,8 @@ ARG NIFI_VERSION=2.6.0 FROM apache/nifi:${NIFI_VERSION} +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + ARG HTTP_PROXY="" ARG HTTPS_PROXY="" ARG no_proxy="" @@ -30,29 +32,53 @@ ENV PIP_NO_CACHE_DIR=1 ENV NIFI_WEB_HTTP_PORT="" ENV NIFI_WEB_HTTP_HOST="" -RUN echo "GID=${GID}" -RUN echo "UID=${UID}" - USER root -# run updates and install some base utility packages along with python support -RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends iputils-ping libssl-dev openssl apt-transport-https apt-utils curl software-properties-common wget git build-essential make cmake ca-certificates zip unzip tzdata jq - -RUN echo "deb http://deb.debian.org/debian/ bookworm main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources -RUN echo "deb http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources -RUN echo "deb http://deb.debian.org/debian/ bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources -RUN echo "deb http://security.debian.org/debian-security/ bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources - -RUN echo "deb-src http://deb.debian.org/debian/ bookworm main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources -RUN echo "deb-src http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources -RUN echo "deb-src http://deb.debian.org/debian/ bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources -RUN echo "deb-src http://security.debian.org/debian-security/ bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources - -# Microsoft repos -RUN wget -q -O- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/packages.microsoft.gpg -RUN echo "deb [arch=amd64,armhf,arm64] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" | tee -a /etc/apt/sources.list - -RUN apt-get update && apt-get install --no-install-recommends -y ssl-cert libsqlite3-dev python3-dev python3-pip python3.11 python3.11-dev python3-venv sqlite3 postgresql-server-dev-all +# add repositories, install tooling, and clean up apt metadata in one layer +RUN set -eux; \ + printf '%s\n' \ + "deb http://deb.debian.org/debian/ bookworm main contrib non-free non-free-firmware" \ + "deb http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware" \ + "deb http://deb.debian.org/debian/ bookworm-backports main contrib non-free non-free-firmware" \ + "deb http://security.debian.org/debian-security/ bookworm-security main contrib non-free non-free-firmware" \ + "deb-src http://deb.debian.org/debian/ bookworm main contrib non-free non-free-firmware" \ + "deb-src http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware" \ + "deb-src http://deb.debian.org/debian/ bookworm-backports main contrib non-free non-free-firmware" \ + "deb-src http://security.debian.org/debian-security/ bookworm-security main contrib non-free non-free-firmware" \ + > /etc/apt/sources.list.d/debian.sources; \ + wget -qO /etc/apt/trusted.gpg.d/packages.microsoft.gpg https://packages.microsoft.com/keys/microsoft.asc; \ + echo "deb [arch=amd64,armhf,arm64] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" > /etc/apt/sources.list.d/microsoft.list; \ + apt-get update; \ + apt-get upgrade -y --no-install-recommends; \ + apt-get install -y --no-install-recommends \ + apt-transport-https \ + apt-utils \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + iputils-ping \ + jq \ + libsqlite3-dev \ + libssl-dev \ + make \ + openssl \ + postgresql-server-dev-all \ + python3-dev \ + python3-pip \ + python3-venv \ + python3.11 \ + python3.11-dev \ + software-properties-common \ + sqlite3 \ + ssl-cert \ + tzdata \ + unzip \ + wget \ + zip; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* # bust cache ENV UV_VERSION=latest @@ -60,36 +86,31 @@ ENV UV_VERSION=latest # install rust, medcat requirement, install UV ENV HOME=/root ENV PATH="/root/.cargo/bin:${PATH}" +ENV UV_INSTALL_DIR=/usr/local/bin -RUN curl -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh \ - && chmod +x /tmp/rustup-init.sh \ - && /tmp/rustup-init.sh -y \ - && rm /tmp/rustup-init.sh - -RUN curl -Ls https://astral.sh/uv/install.sh -o /tmp/install_uv.sh \ - && bash /tmp/install_uv.sh - -RUN UV_PATH=$(find / -name uv -type f | head -n1) && \ - ln -s "$UV_PATH" /usr/local/bin/uv +RUN set -eux; \ + curl -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh; \ + chmod +x /tmp/rustup-init.sh; \ + /tmp/rustup-init.sh -y; \ + rm /tmp/rustup-init.sh -# clean up apt -RUN apt-get clean autoclean && apt-get autoremove --purge -y +RUN set -eux; \ + curl -Ls https://astral.sh/uv/install.sh -o /tmp/install_uv.sh; \ + bash /tmp/install_uv.sh; \ + rm /tmp/install_uv.sh ######################################## Python / PIP SECTION ######################################## RUN uv pip install --no-cache-dir --break-system-packages --system --upgrade pip setuptools wheel -# install util packages used in NiFi scripts (such as MedCAT, avro, nifyapi, etc.) +# install util packages used in NiFi scripts (such as avro, nifyapi, etc.) COPY ./requirements.txt ./requirements.txt -RUN uv pip install --no-cache-dir --break-system-packages --target=${NIFI_PYTHON_FRAMEWORK_SOURCE_DIRECTORY} -r "./requirements.txt" +RUN uv pip install --no-cache-dir --break-system-packages --target=${NIFI_PYTHON_FRAMEWORK_SOURCE_DIRECTORY} -r "./requirements.txt" --index-url https://pypi.org/simple -# install MedCAT from the working with cogstack repo (as it is assumed to be stable and the latest version) -RUN uv pip install --no-cache-dir --break-system-packages --target=${NIFI_PYTHON_FRAMEWORK_SOURCE_DIRECTORY} -r https://raw.githubusercontent.com/CogStack/working_with_cogstack/refs/heads/main/requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu/ - ####################################################################################################### # solve groovy grape proxy issues, grape ignores the current environment's proxy settings -RUN export JAVA_OPTS="-Dhttp.proxyHost=$HTTP_PROXY -Dhttps.proxyHost=$HTTPS_PROXY -Dhttp.nonProxyHosts=$no_proxy" +ENV JAVA_OPTS="-Dhttp.proxyHost=${HTTP_PROXY} -Dhttps.proxyHost=${HTTPS_PROXY} -Dhttp.nonProxyHosts=${no_proxy}" # INSTALL NAR extensions WORKDIR /opt/nifi/nifi-current/lib/ diff --git a/nifi/requirements-dev.txt b/nifi/requirements-dev.txt new file mode 100644 index 000000000..e4219ea3b --- /dev/null +++ b/nifi/requirements-dev.txt @@ -0,0 +1,8 @@ + +ruff==0.12.12 +mypy==1.17.0 +mypy-extensions==1.1.0 +types-aiofiles==24.1.0.20250708 +types-PyYAML==6.0.12.20250516 +types-setuptools==80.9.0.20250529 +timeout-decorator==0.5.0 diff --git a/nifi/requirements.txt b/nifi/requirements.txt index a424dbc9d..df5c2331f 100644 --- a/nifi/requirements.txt +++ b/nifi/requirements.txt @@ -1,45 +1,31 @@ +wheel==0.45.1 + +uv==0.9.12 + # data science pkgs -seaborn==0.13.2 -matplotlib==3.10.6 -graphviz==0.21 -plotly==6.3.0 -keras==3.12.0 nltk==3.9.1 -numpy>=1.26.0,<2.0.0 -pandas==1.5.3 -dill>=0.3.6,<1.0.0 -bokeh==3.8.0 -psycopg[c,binary]==3.2.9 +numpy==2.3.5 +pandas==2.3.3 # used in NiFi scripts: geolocation, avro conversion etc. py4j==0.10.9.9 rancoord==0.0.6 geocoder==1.38.1 -avro==1.12.0 +avro==1.12.1 nipyapi==1.0.0 py7zr==1.0.0 -ipyparallel==9.0.1 -cython==3.1.3 -tqdm==4.67.1 jsonpickle==4.1.1 -certifi==2025.8.3 -xlsxwriter==3.2.5 -mysql-connector-python==9.4.0 -pymssql==2.3.7 +xlsxwriter==3.2.9 +mysql-connector-python==9.5.0 +pymssql==2.3.9 +psycopg[c,binary]==3.2.9 +requests==2.32.5 +PyYAML==6.0.3 # other utils xnat==0.7.2 # ElasticSearch/OpenSearch packages -opensearch-py==3.0.0 elasticsearch9==9.1.0 +opensearch-py==3.0.0 neo4j==5.28.2 -eland==9.0.1 - -# git utils -dvc==3.62.0 -GitPython==3.1.45 -PyYAML==6.0.2 - -# code utils -ruff==0.12.12 From aa306b9a64d8b222c8ef159803f62790757aba5c Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Wed, 26 Nov 2025 16:18:13 +0000 Subject: [PATCH 03/11] NiFi: dockerfile fix. --- nifi/Dockerfile | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/nifi/Dockerfile b/nifi/Dockerfile index f2074902c..e8c6eab63 100644 --- a/nifi/Dockerfile +++ b/nifi/Dockerfile @@ -36,6 +36,13 @@ USER root # add repositories, install tooling, and clean up apt metadata in one layer RUN set -eux; \ + apt-get update -y; \ + apt-get install -y --no-install-recommends \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg \ + wget; \ printf '%s\n' \ "deb http://deb.debian.org/debian/ bookworm main contrib non-free non-free-firmware" \ "deb http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware" \ @@ -45,18 +52,15 @@ RUN set -eux; \ "deb-src http://deb.debian.org/debian/ bookworm-updates main contrib non-free non-free-firmware" \ "deb-src http://deb.debian.org/debian/ bookworm-backports main contrib non-free non-free-firmware" \ "deb-src http://security.debian.org/debian-security/ bookworm-security main contrib non-free non-free-firmware" \ - > /etc/apt/sources.list.d/debian.sources; \ - wget -qO /etc/apt/trusted.gpg.d/packages.microsoft.gpg https://packages.microsoft.com/keys/microsoft.asc; \ + > /etc/apt/sources.list.d/debian.list; \ + wget -q -O- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/packages.microsoft.gpg; \ echo "deb [arch=amd64,armhf,arm64] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" > /etc/apt/sources.list.d/microsoft.list; \ - apt-get update; \ + apt-get update -y; \ apt-get upgrade -y --no-install-recommends; \ apt-get install -y --no-install-recommends \ - apt-transport-https \ apt-utils \ build-essential \ - ca-certificates \ cmake \ - curl \ git \ iputils-ping \ jq \ @@ -75,7 +79,6 @@ RUN set -eux; \ ssl-cert \ tzdata \ unzip \ - wget \ zip; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* From b4ef495aa2536e40cab079f60d189f280079fc3e Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Wed, 26 Nov 2025 17:13:34 +0000 Subject: [PATCH 04/11] Deploy refactioring + pyproject. --- deploy/services-dev.yml | 183 ++++++++++++++++++-------------------- nifi/Dockerfile | 4 +- nifi/conf/nifi.properties | 5 +- pyproject.toml | 10 +-- 4 files changed, 93 insertions(+), 109 deletions(-) diff --git a/deploy/services-dev.yml b/deploy/services-dev.yml index 24d944bd3..2496c103a 100644 --- a/deploy/services-dev.yml +++ b/deploy/services-dev.yml @@ -1,3 +1,67 @@ +#---------------------------------------------------------------------------# +# Common snippets / anchors # +#---------------------------------------------------------------------------# +x-nifi-logging-common: &nifi-logging-common + driver: "json-file" + options: + max-size: ${NIFI_DOCKER_LOG_SIZE_PER_FILE:-250m} + max-file: ${NIFI_DOCKER_LOG_NUM_FILES:-10} + +x-logging-common: &logging-common + driver: "json-file" + options: + max-size: ${DOCKER_LOG_SIZE_PER_FILE:-100m} + max-file: ${DOCKER_LOG_NUM_FILES:-10} + +x-all-env: &all-env + - ./project.env + - ./general.env + - ./nifi.env + - ./gitea.env + - ./nginx.env + - ./database.env + - ./elasticsearch.env + - ./network_settings.env + - ../security/env/users_nifi.env + - ../security/env/users_database.env + - ../security/env/users_nginx.env + - ../security/env/users_elasticsearch.env + - ../security/env/certificates_general.env + - ../security/env/certificates_elasticsearch.env + - ../security/env/certificates_nifi.env + +x-es-env: &es-env + - ./network_settings.env + - ./elasticsearch.env + - ../security/env/users_elasticsearch.env + - ../security/env/certificates_elasticsearch.env + +x-common-hosts: &common-hosts + - ${ELASTICSEARCH_1_HOST_NAME:-test-1:0.0.0.0} + - ${ELASTICSEARCH_2_HOST_NAME:-test-2:0.0.0.0} + - ${ELASTICSEARCH_3_HOST_NAME:-test-3:0.0.0.0} + - ${KIBANA_HOST_NAME:-test-4:0.0.0.0} + - ${NIFI_HOST_NAME:-test-5:0.0.0.0} + - ${NIFI_REGISTRY_HOST_NAME:-test-6:0.0.0.0} + +x-common-ulimits: &common-ulimits + ulimits: + nofile: + soft: 65535 + hard: 65535 + nproc: 65535 + memlock: + soft: -1 + hard: -1 + +x-nifi-common: &nifi-common + <<: *common-ulimits + restart: always + env_file: *all-env + extra_hosts: *common-hosts + networks: + - cognet + #---------------------------------------------------------------------------# # Used services # #---------------------------------------------------------------------------# @@ -7,7 +71,7 @@ services: # NiFi webapp # #---------------------------------------------------------------------------# nifi: - # image: cogstacksystems/cogstack-nifi:latest + <<: *nifi-common build: context: ../nifi/ args: @@ -16,19 +80,7 @@ services: no_proxy: $no_proxy container_name: cogstack-nifi hostname: nifi - restart: always - env_file: - - ./general.env - - ./project.env - - ./nifi.env - - ./elasticsearch.env - - ./network_settings.env - - ../security/users_nifi.env - - ../security/users_elasticsearch.env - - ../security/certificates_general.env - - ../security/certificates_elasticsearch.env - - ../security/certificates_nifi.env - shm_size: 1024mb + shm_size: ${NIFI_SHM_SIZE:-"1g"} environment: - USER_ID=${NIFI_UID:-1000} - GROUP_ID=${NIFI_GID:-1000} @@ -37,12 +89,10 @@ services: - NIFI_INTERNAL_PORT=${NIFI_INTERNAL_PORT:-8443} - NIFI_OUTPUT_PORT=${NIFI_OUTPUT_PORT:-8082} - NIFI_INPUT_SOCKET_PORT=${NIFI_INPUT_SOCKET_PORT:-10000} - - NIFI_SECURITY_DIR=${NIFI_SECURITY_DIR:-../security/nifi_certificates/} - - ELASTICSEARCH_SECURITY_DIR=${ELASTICSEARCH_SECURITY_DIR:-../security/es_certificates/} volumes: # INFO: drivers folder - ../nifi/drivers:/opt/nifi/drivers - + # INFO: if there are local changes, map these content from local host to container # (normally, these 3 directories below are bundled with our NiFi image) # N.B. The container user may not have the permission to read these directories/files. @@ -50,17 +100,14 @@ services: - ../nifi/user-scripts:/opt/nifi/user-scripts:rw - ../nifi/user-schemas:/opt/nifi/user-schemas:rw - # this is a direct mapping to where we store the NiFi python processors as of NiFi 2.0.x + # this is a direct mapping to where we store the NiFi python processors as of NiFi 2.x.x - ../nifi/user-python-extensions:/opt/nifi/nifi-current/python_extensions:rw # INFO: uncomment below to map security certificates if need to secure NiFi endpoints - - ./${NIFI_SECURITY_DIR:-../security/nifi_certificates/}:/opt/nifi/nifi-current/nifi_certificates:ro - - ./${ELASTICSEARCH_SECURITY_DIR:-../security/es_certificates/}:/opt/nifi/nifi-current/es_certificates:ro - - ./${NIFI_SECURITY_DIR:-../security/nifi_certificates/}nifi-keystore.jks:/opt/nifi/nifi-current/conf/keystore.jks - - ./${NIFI_SECURITY_DIR:-../security/nifi_certificates/}nifi-truststore.jks:/opt/nifi/nifi-current/conf/truststore.jks + - ../security:/security:ro # Security credentials scripts - - ../security/nifi_create_single_user_auth.sh:/opt/nifi/nifi-current/security_scripts/nifi_create_single_user_auth.sh:ro + - ../security/scripts/nifi_create_single_user_auth.sh:/opt/nifi/nifi-current/security_scripts/nifi_create_single_user_auth.sh:ro # # Nifi properties file: - ../nifi/conf/:/opt/nifi/nifi-current/conf/:rw @@ -72,7 +119,7 @@ services: - ../services/cogstack-db/:/opt/cogstack-db/:rw # medcat models - - ./${RES_MEDCAT_SERVICE_MODEL_PRODUCTION_PATH:-../services/nlp-services/medcat-service/models/}:/opt/models:rw + - ./${RES_MEDCAT_SERVICE_MODEL_PRODUCTION_PATH:-../services/cogstack-nlp/medcat-service/models/}:/opt/models:rw # rest of volumes to persist the state - nifi-vol-logs:/opt/nifi/nifi-current/logs @@ -85,51 +132,23 @@ services: # errors generated during data processing - nifi-vol-errors:/opt/nifi/pipeline/flowfile-errors - extra_hosts: - - ${ELASTICSEARCH_1_HOST_NAME:-test-1:0.0.0.0} - - ${ELASTICSEARCH_2_HOST_NAME:-test-2:0.0.0.0} - - ${ELASTICSEARCH_3_HOST_NAME:-test-3:0.0.0.0} - - ${KIBANA_HOST_NAME:-test-4:0.0.0.0} - - ${NIFI_HOST_NAME:-test-5:0.0.0.0} - - ${NIFI_REGISTRY_HOST_NAME:-test-6:0.0.0.0} - - # user: "${NIFI_UID:-1000}:${NIFI_GID:-1000}" - ulimits: - memlock: - soft: -1 - hard: -1 - nofile: - soft: 65536 - hard: 262144 - # INFO : Uncomment the below line to generate your own USERNAME and PASSWORD, # a bit messy this way as you will need to copy the credentials back # to the "login-identity-providers.xml" section. # entrypoint: bash -c "/opt/nifi/nifi-current/bin/nifi.sh set-single-user-credentials admin admincogstacknifi" - tty: true ports: - "${NIFI_OUTPUT_PORT:-8082}:${NIFI_INTERNAL_PORT:-8443}" - "${NIFI_INPUT_SOCKET_PORT:-10000}" - networks: - - cognet - + logging: *nifi-logging-common + nifi-registry-flow: + <<: *nifi-common image: apache/nifi-registry:${NIFI_REGISTRY_VERSION:-2.6.0} hostname: nifi-registry container_name: cogstack-nifi-registry-flow - restart: always + shm_size: ${NIFI_REGISTRY_SHM_SIZE:-"1g"} user: root - env_file: - - ./general.env - - ./network_settings.env - - ./nifi.env - - ./project.env - - ../security/users_nifi.env - - ../security/users_elasticsearch.env - - ../security/certificates_general.env - - ../security/certificates_elasticsearch.env - - ../security/certificates_nifi.env environment: - http_proxy=$HTTP_PROXY - https_proxy=$HTTPS_PROXY @@ -143,31 +162,20 @@ services: - TRUSTSTORE_PATH=${NIFI_REGISTRY_TRUSTSTORE_PATH:-./conf/truststore.jks} - TRUSTSTORE_TYPE=${NIFI_TRUSTSTORE_TYPE:-jks} - - INITIAL_ADMIN_IDENTITY=${NIFI_INITIAL_ADMIN_IDENTITY:-"CN=admin, OU=nifi"} + - INITIAL_ADMIN_IDENTITY=${NIFI_INITIAL_ADMIN_IDENTITY:-"cogstack"} - AUTH=${NIFI_AUTH:-"tls"} - NIFI_REGISTRY_DB_DIR=${NIFI_REGISTRY_DB_DIR:-/opt/nifi-registry/nifi-registry-current/database} #- NIFI_REGISTRY_FLOW_PROVIDER=${NIFI_REGISTRY_FLOW_PROVIDER:-file} - NIFI_REGISTRY_FLOW_STORAGE_DIR=${NIFI_REGISTRY_FLOW_STORAGE_DIR:-/opt/nifi-registry/nifi-registry-current/flow_storage} volumes: - ../nifi/nifi-registry/:/opt/nifi-registry/nifi-registry-current/conf/:rw - - ./${NIFI_SECURITY_DIR:-../security/nifi_certificates/}nifi-keystore.jks:/opt/nifi-registry/nifi-registry-current/conf/keystore.jks:ro - - ./${NIFI_SECURITY_DIR:-../security/nifi_certificates/}nifi-truststore.jks://opt/nifi-registry/nifi-registry-current/conf/truststore.jks:ro + - ./${NIFI_SECURITY_DIR:-../security/certificates/nifi/}nifi-keystore.jks:/opt/nifi-registry/nifi-registry-current/conf/keystore.jks:ro + - ./${NIFI_SECURITY_DIR:-../security/certificates/nifi/}nifi-truststore.jks://opt/nifi-registry/nifi-registry-current/conf/truststore.jks:ro - nifi-registry-vol-database:/opt/nifi-registry/nifi-registry-current/database - nifi-registry-vol-flow-storage:/opt/nifi-registry/nifi-registry-current/flow_storage - nifi-registry-vol-work:/opt/nifi-registry/nifi-registry-current/work - nifi-registry-vol-logs:/opt/nifi-registry/nifi-registry-current/logs - extra_hosts: - - ${NIFI_HOST_NAME:-test-5:0.0.0.0} - - ${NIFI_REGISTRY_HOST_NAME:-test-6:0.0.0.0} - - ulimits: - memlock: - soft: -1 - hard: -1 - nofile: - soft: 65536 - hard: 262144 - + extra_hosts: *common-hosts tty: true ports: - "${NIFI_REGISTRY_FLOW_OUTPUT_PORT:-8083}:${NIFI_REGISTRY_FLOW_INPUT_PORT:-18443}" @@ -177,43 +185,27 @@ services: chown -R nifi:nifi /opt/nifi-registry/nifi-registry-current/work && \ chown -R nifi:nifi /opt/nifi-registry/nifi-registry-current/logs && \ bash /opt/nifi-registry/scripts/start.sh" - - networks: - - cognet - + logging: *nifi-logging-common + nifi-nginx: - # image: cogstacksystems/nifi-nginx:latest - build: - context: ../services/nginx/ - args: - HTTP_PROXY: $HTTP_PROXY - HTTPS_PROXY: $HTTPS_PROXY - no_proxy: $no_proxy + image: cogstacksystems/nifi-nginx:latest container_name: cogstack-nifi-nginx restart: always - env_file: - - ./network_settings.env - - ./nginx.env - - ./nifi.env - - ./elasticsearch.env - - ./project.env - - ./nlp_service.env + shm_size: 512mb + env_file: *all-env volumes: - ../services/nginx/sites-enabled:/etc/nginx/sites-enabled:ro - ../services/nginx/config/nginx.conf.template:/etc/nginx/config/nginx.conf.template:rw - ../services/nginx/config/nginx.conf:/etc/nginx/nginx.conf:rw - - ../security/root_certificates:/etc/nginx/root_certificates:ro - - ../security/nifi_certificates:/etc/nginx/nifi_certificates:ro - - - ../security/es_certificates/${ELASTICSEARCH_VERSION:-opensearch}/elastic-stack-ca.crt.pem:/etc/nginx/es_certificates/elastic-stack-ca.crt.pem:ro - - ../security/es_certificates/${ELASTICSEARCH_VERSION:-opensearch}/elastic-stack-ca.key.pem:/etc/nginx/es_certificates/elastic-stack-ca.key.pem:ro - # - ../security/es_certificates/:/etc/nginx/es_certificates/:ro + - ../security/certificates:/certificates:ro ports: - "${NIFI_EXTERNAL_PORT_NGINX:-8443}:${NIFI_INTERNAL_PORT_NGINX:-8443}" - "${NIFI_REGISTRY_EXTERNAL_PORT_NGINX:-18443}:${NIFI_REGISTRY_INTERNAL_PORT_NGINX:-18443}" networks: - cognet command: /bin/bash -c "envsubst < /etc/nginx/config/nginx.conf.template > /etc/nginx/config/nginx.conf && nginx -g 'daemon off;'" + extra_hosts: *common-hosts + logging: *nifi-logging-common #---------------------------------------------------------------------------# # Docker named volumes # @@ -249,7 +241,6 @@ volumes: driver: local nifi-registry-vol-logs: driver: local - #---------------------------------------------------------------------------# # Docker networks. # #---------------------------------------------------------------------------# diff --git a/nifi/Dockerfile b/nifi/Dockerfile index e8c6eab63..2cdc1dd40 100644 --- a/nifi/Dockerfile +++ b/nifi/Dockerfile @@ -69,11 +69,11 @@ RUN set -eux; \ make \ openssl \ postgresql-server-dev-all \ + python3.11 \ + python3.11-dev \ python3-dev \ python3-pip \ python3-venv \ - python3.11 \ - python3.11-dev \ software-properties-common \ sqlite3 \ ssl-cert \ diff --git a/nifi/conf/nifi.properties b/nifi/conf/nifi.properties index 1363e7deb..1b8f10550 100644 --- a/nifi/conf/nifi.properties +++ b/nifi/conf/nifi.properties @@ -49,8 +49,8 @@ nifi.python.command=python3.11 nifi.python.framework.source.directory=/opt/nifi/nifi-current/python/framework nifi.python.extensions.source.directory.default=/opt/nifi/nifi-current/python_extensions nifi.python.working.directory=/opt/nifi/user-scripts -nifi.python.logs.directory=./logs -nifi.python.max.processes.per.extension.type=10 +nifi.python.logs.directory=./logs +nifi.python.max.processes.per.extension.type=10 nifi.python.max.processes=100 #################### @@ -362,4 +362,3 @@ nifi.diagnostics.on.shutdown.max.filecount=10 # The diagnostics folder's maximum permitted size in bytes. If the limit is exceeded, the oldest files are deleted. nifi.diagnostics.on.shutdown.max.directory.size=10 MB - diff --git a/pyproject.toml b/pyproject.toml index 2f17afdc7..34a1e0877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,15 +25,9 @@ fixable = ["ALL"] [tool.mypy] plugins = ["pydantic.mypy"] +python_version = "3.11" ignore_missing_imports = true strict = false files = "." mypy_path = "./typings/" - -[tool.isort] -line_length = 120 -skip = ["venv", "venv-test", "envs", "docker", "models"] - -[tool.flake8] -max-line-length = 120 -exclude = ["venv", "venv-test", "envs", "docker", "models"] +warn_unused_configs = true From 496c612ab82e0e4a8c63c65027455567b5512f5b Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Thu, 27 Nov 2025 15:18:23 +0000 Subject: [PATCH 05/11] .gitignore update. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 892ee811e..306df23c5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ security/templates/** docs/build/* # Ignore all .env files at any level +.env *.env **/*.env !*.env.template From 3e0f84835f176ad1abf53aa54ef6b1cdcd457bc2 Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Thu, 27 Nov 2025 16:44:58 +0000 Subject: [PATCH 06/11] Deploy: added resource limits to core services (NiFi/NiFiRegistry/Nginx/Kibana/ES/Metricbeat/FileBeat} --- deploy/elasticsearch.env | 35 +++++++++++++---- deploy/nginx.env | 6 +++ deploy/nifi.env | 31 ++++++++++++--- deploy/services.yml | 82 ++++++++++++++++++++++++++++++++++------ 4 files changed, 129 insertions(+), 25 deletions(-) diff --git a/deploy/elasticsearch.env b/deploy/elasticsearch.env index efe5c647e..387409be5 100644 --- a/deploy/elasticsearch.env +++ b/deploy/elasticsearch.env @@ -9,10 +9,10 @@ ELASTICSEARCH_VERSION=opensearch # possible values : # - elasticsearch : docker.elastic.co/elasticsearch/elasticsearch:8.18.2 # - elasticsearch (custom cogstack image) : cogstacksystems/cogstack-elasticsearch:latest -# - opensearch : opensearchproject/opensearch:3.2.0 +# - opensearch : opensearchproject/opensearch:3.3.0 # the custom cogstack image is always based on the last image of ES native -ELASTICSEARCH_DOCKER_IMAGE=opensearchproject/opensearch:3.2.0 +ELASTICSEARCH_DOCKER_IMAGE=opensearchproject/opensearch:3.3.0 ELASTICSEARCH_LOG_LEVEL=INFO @@ -88,9 +88,14 @@ ELASTICSEARCH_BACKUP_PARTITION_CONFIG=../data/es_snapshot_backups/config_backup ELASTICSEARCH_SECURITY_DIR=../security/certificates/elastic/ # MEMORY CONFIG -ELASTICSEARCH_JAVA_OPTS="-Xms2048m -Xmx2048m -Des.failure_store_feature_flag_enabled=true" +ELASTICSEARCH_JAVA_OPTS="-Xms512m -Xmx512m -Des.failure_store_feature_flag_enabled=true" + +ELASTICSEARCH_DOCKER_CPU_MIN=1 +ELASTICSEARCH_DOCKER_CPU_MAX=1 +ELASTICSEARCH_DOCKER_RAM=1g + +ELASTICSEARCH_DOCKER_SHM_SIZE=512m -ELASTICSEARCH_SHM_SIZE="1g" ELASTICSEARCH_DOCKER_LOG_SIZE_PER_FILE="1000m" ELASTICSEARCH_DOCKER_LOG_NUM_FILES=10 @@ -155,15 +160,15 @@ KIBANA_VERSION=opensearch-dashboards # - kibana # - opensearch_dashboards # make note of the underscore... -KIBANA_CONFIG_FILE_VERSION=opensearch_dashboards +KIBANA_CONFIG_FILE_VERSION=opensearch_dashboards # possible values: # - elasticsearch : docker.elastic.co/kibana/kibana:8.18.2 # - elasticsearch (custom cogstack image) : cogstacksystems/cogstack-kibana:latest -# - opensearch : opensearchproject/opensearch-dashboards:3.2.0 +# - opensearch : opensearchproject/opensearch-dashboards:3.3.0 # the custom cogstack image is always based on the last image of ES native -ELASTICSEARCH_KIBANA_DOCKER_IMAGE=opensearchproject/opensearch-dashboards:3.2.0 +ELASTICSEARCH_KIBANA_DOCKER_IMAGE=opensearchproject/opensearch-dashboards:3.3.0 KIBANA_SERVER_NAME="cogstack-kibana" KIBANA_PUBLIC_BASE_URL="https://elasticsearch-1:5601" @@ -171,7 +176,11 @@ KIBANA_PUBLIC_BASE_URL="https://elasticsearch-1:5601" KIBANA_SERVER_HOST="0.0.0.0" KIBANA_SERVER_OUTPUT_PORT=5601 -KIBANA_SHM_SIZE="1g" +KIBANA_DOCKER_SHM_SIZE=512m +KIBANA_DOCKER_CPU_MIN=1 +KIBANA_DOCKER_CPU_MAX=1 +KIBANA_DOCKER_RAM=1g + # this is used in Kibana # it needs to be generated via the API @@ -198,6 +207,10 @@ ELASTICSEARCH_XPACK_SECURITY_REPORTING_ENCRYPTION_KEY="e0Y1gTxHWOopIWMTtpjQsDS6K METRICBEAT_IMAGE="docker.elastic.co/beats/metricbeat:8.18.2" +METRICBEAT_DOCKER_SHM=512m +METRICBEAT_DOCKER_CPU_MIN=1 +METRICBEAT_DOCKER_CPU_MAX=1 +METRICBEAT_DOCKER_RAM=1g ########################################################################## FILEBEAT Env vars ########################################################################### @@ -210,3 +223,9 @@ FILEBEAT_STARTUP_COMMAND="-e --strict.perms=false" FILEBEAT_HOST="https://elasticsearch-1:9200" FILEBEAT_IMAGE="docker.elastic.co/beats/filebeat:8.18.2" + + +FILEBEAT_DOCKER_SHM=512m +FILEBEAT_DOCKER_CPU_MIN=1 +FILEBEAT_DOCKER_CPU_MAX=1 +FILEBEAT_DOCKER_RAM=1g diff --git a/deploy/nginx.env b/deploy/nginx.env index aae2c825d..a08762ca2 100644 --- a/deploy/nginx.env +++ b/deploy/nginx.env @@ -1,3 +1,9 @@ NGINX_KIBANA_HOST=kibana NGINX_KIBANA_PROXY_PORT=5601 NGINX_ES_NODE_SOURCE_INSTANCE_NAME="elasticsearch-1" + + +NGINX_SHM_SIZE=1g +NGINX_DOCKER_CPU_MIN=1 +NGINX_DOCKER_CPU_MAX=1 +NGINX_DOCKER_RAM=1g diff --git a/deploy/nifi.env b/deploy/nifi.env index 35826b8da..fd7e83062 100644 --- a/deploy/nifi.env +++ b/deploy/nifi.env @@ -1,3 +1,29 @@ + + +############################################################################################################################## +# IMPORTANT SETTINGS FOR DEPLOYMENTS RESOURCE SCOPED +############################################################################################################################## +NIFI_JVM_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=250 -XX:+ParallelRefProcEnabled -Djava.security.egd=file:/dev/./urandom" +NIFI_JVM_HEAP_INIT=768m +NIFI_JVM_HEAP_MAX=1g + + +NIFI_DOCKER_SHM_SIZE=1g +NIFI_DOCKER_REGISTRY_SHM_SIZE=1g + +NIFI_DOCKER_CPU_MIN=1 +NIFI_DOCKER_CPU_MAX=1 +NIFI_DOCKER_RAM=1g + +NIFI_REGISTRY_DOCKER_CPU_MIN=1 +NIFI_REGISTRY_DOCKER_CPU_MAX=1 +NIFI_REGISTRY_DOCKER_RAM=1g + +NIFI_DOCKER_LOG_SIZE_PER_FILE="250m" +NIFI_DOCKER_LOG_NUM_FILES=10 + +############################################################################################################################## + # NiFi NIFI_ENV_FILE="./nifi.env" NIFI_SECURITY_DIR="../security/certificates/nifi/" @@ -6,11 +32,6 @@ NIFI_DATA_PATH="../data/" NIFI_VERSION="2.6.0" NIFI_TOOLKIT_VERSION=$NIFI_VERSION -NIFI_SHM_SIZE="1g" -NIFI_REGISTRY_SHM_SIZE="1g" -NIFI_DOCKER_LOG_SIZE_PER_FILE="250m" -NIFI_DOCKER_LOG_NUM_FILES=10 - #### Port and network settings NIFI_WEB_PROXY_CONTEXT_PATH="/nifi" diff --git a/deploy/services.yml b/deploy/services.yml index 362308a57..7bc8f93be 100644 --- a/deploy/services.yml +++ b/deploy/services.yml @@ -93,8 +93,8 @@ x-es-common-volumes: &es-common-volumes x-es-common: &es-common <<: *common-ulimits - image: ${ELASTICSEARCH_DOCKER_IMAGE:-opensearchproject/opensearch:3.2.0} - shm_size: ${ELASTICSEARCH_SHM_SIZE:-"1g"} + image: ${ELASTICSEARCH_DOCKER_IMAGE:-opensearchproject/opensearch:3.3.0} + shm_size: ${ELASTICSEARCH_DOCKER_SHM_SIZE:-1g} restart: always env_file: *es-env networks: @@ -108,12 +108,21 @@ x-es-common: &es-common OPENSEARCH_INITIAL_ADMIN_PASSWORD: ${OPENSEARCH_INITIAL_ADMIN_PASSWORD:-kibanaserver} ELASTICSEARCH_VERSION: ${ELASTICSEARCH_VERSION:-opensearch} logging: *es-logging-common + deploy: + resources: + limits: + cpus: "${ELASTICSEARCH_DOCKER_CPU_MAX}" + memory: "${ELASTICSEARCH_DOCKER_RAM}" + reservations: + cpus: "${ELASTICSEARCH_DOCKER_CPU_MIN}" + memory: "${ELASTICSEARCH_DOCKER_RAM}" x-metricbeat-common: &metricbeat-common <<: *common-ulimits image: ${METRICBEAT_IMAGE:-docker.elastic.co/beats/metricbeat:8.18.2} command: -e --strict.perms=false restart: unless-stopped + shm_size: ${METRICBEAT_DOCKER_SHM:-1g} env_file: - ./elasticsearch.env - ../security/env/users_elasticsearch.env @@ -122,6 +131,14 @@ x-metricbeat-common: &metricbeat-common - METRICBEAT_USER=${METRICBEAT_USER:-elastic} - METRICBEAT_PASSWORD=${METRICBEAT_PASSWORD:-kibanaserver} - KIBANA_HOST=${KIBANA_HOST:-"https://kibana:5601"} + deploy: + resources: + limits: + cpus: "${METRICBEAT_DOCKER_CPU_MAX}" + memory: "${METRICBEAT_DOCKER_RAM}" + reservations: + cpus: "${METRICBEAT_DOCKER_CPU_MIN}" + memory: "${METRICBEAT_DOCKER_RAM}" volumes: - ../services/metricbeat/metricbeat.yml:/usr/share/metricbeat/metricbeat.yml:ro - ../security/certificates/elastic/elasticsearch/elastic-stack-ca.crt.pem:/usr/share/metricbeat/root-ca.crt:ro @@ -136,6 +153,7 @@ x-filebeat-common: &filebeat-common image: ${FILEBEAT_IMAGE:-docker.elastic.co/beats/filebeat:8.18.2} command: ${FILEBEAT_STARTUP_COMMAND:-'-e --strict.perms=false'} restart: unless-stopped + shm_size: ${FILEBEAT_DOCKER_SHM:-1g} env_file: - ./elasticsearch.env - ../security/env/users_elasticsearch.env @@ -144,6 +162,14 @@ x-filebeat-common: &filebeat-common - FILEBEAT_USER=${FILEBEAT_USER:-elastic} - FILEBEAT_PASSWORD=${FILEBEAT_PASSWORD:-kibanaserver} - KIBANA_HOST=${KIBANA_HOST:-"https://kibana:5601"} + deploy: + resources: + limits: + cpus: "${FILEBEAT_DOCKER_CPU_MAX}" + memory: "${FILEBEAT_DOCKER_RAM}" + reservations: + cpus: "${FILEBEAT_DOCKER_CPU_MIN}" + memory: "${FILEBEAT_DOCKER_RAM}" volumes: - ../services/filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:rw - ../security/certificates/elastic/elasticsearch/elastic-stack-ca.crt.pem:/etc/pki/root/root-ca.crt:ro @@ -252,7 +278,7 @@ services: es_native_create_certs: container_name: es_create_certs image: docker.elastic.co/elasticsearch/elasticsearch:8.18.2 - shm_size: ${ELASTICSEARCH_SHM_SIZE:-"1g"} + shm_size: ${ELASTICSEARCH_DOCKER_SHM_SIZE:-1g} env_file: *es-env restart: "no" command: bash -c "bash /usr/share/elasticsearch/es_native_cert_generator.sh" @@ -287,7 +313,7 @@ services: ports: - "${ELASTICSEARCH_NODE_1_OUTPUT_PORT:-9200}:9200" - "${ELASTICSEARCH_NODE_1_COMM_OUTPUT_PORT:-9300}:9300" - - "${ELASTICSEARCH_NODE_1_ANALYZER_OUTPUT_PORT:-9600}:9600" # required for Performance Analyzer + - "${ELASTICSEARCH_NODE_1_ANALYZER_OUTPUT_PORT:-9600}:9600" elasticsearch-2: extends: @@ -306,7 +332,7 @@ services: ports: - "${ELASTICSEARCH_NODE_2_OUTPUT_PORT:-9201}:9200" - "${ELASTICSEARCH_NODE_2_COMM_OUTPUT_PORT:-9301}:9300" - - "${ELASTICSEARCH_NODE_2_ANALYZER_OUTPUT_PORT:-9601}:9600" # required for Performance Analyzer + - "${ELASTICSEARCH_NODE_2_ANALYZER_OUTPUT_PORT:-9601}:9600" elasticsearch-3: extends: @@ -325,7 +351,7 @@ services: ports: - "${ELASTICSEARCH_NODE_3_OUTPUT_PORT:-9202}:9200" - "${ELASTICSEARCH_NODE_3_COMM_OUTPUT_PORT:-9302}:9300" - - "${ELASTICSEARCH_NODE_3_ANALYZER_OUTPUT_PORT:-9602}:9600" # required for Performance Analyzer + - "${ELASTICSEARCH_NODE_3_ANALYZER_OUTPUT_PORT:-9602}:9600" metricbeat-1: <<: *metricbeat-common @@ -389,9 +415,9 @@ services: #---------------------------------------------------------------------------# kibana: <<: *common-ulimits - image: ${ELASTICSEARCH_KIBANA_DOCKER_IMAGE:-opensearchproject/opensearch-dashboards:3.2.0} + image: ${ELASTICSEARCH_KIBANA_DOCKER_IMAGE:-opensearchproject/opensearch-dashboards:3.3.0} container_name: cogstack-kibana - shm_size: ${KIBANA_SHM_SIZE:-"1g"} + shm_size: ${KIBANA_DOCKER_SHM_SIZE:-1g} restart: always env_file: *es-env environment: @@ -401,7 +427,14 @@ services: # INFO: uncomment below to enable SSL keys SERVER_SSL_ENABLED: ${ELASTICSEARCH_SSL_ENABLED:-"true"} OPENSEARCH_INITIAL_ADMIN_PASSWORD: ${OPENSEARCH_INITIAL_ADMIN_PASSWORD:-kibanaserver} - + deploy: + resources: + limits: + cpus: "${KIBANA_DOCKER_CPU_MAX}" + memory: "${KIBANA_DOCKER_RAM}" + reservations: + cpus: "${KIBANA_DOCKER_CPU_MIN}" + memory: "${KIBANA_DOCKER_RAM}" volumes: # INFO: Kibana configuration mapped via volume (make sure to comment this and uncomment the next line if you are using NATIVE kibana deployment) - ../services/kibana/config/${ELASTICSEARCH_VERSION:-opensearch}.yml:/usr/share/${KIBANA_VERSION:-opensearch-dashboards}/config/${KIBANA_CONFIG_FILE_VERSION:-opensearch_dashboards}.yml:ro @@ -434,7 +467,7 @@ services: image: cogstacksystems/cogstack-nifi:latest container_name: cogstack-nifi hostname: nifi - shm_size: ${NIFI_SHM_SIZE:-"1g"} + shm_size: ${NIFI_DOCKER_SHM_SIZE:-"1g"} environment: - USER_ID=${NIFI_UID:-1000} - GROUP_ID=${NIFI_GID:-1000} @@ -443,6 +476,15 @@ services: - NIFI_INTERNAL_PORT=${NIFI_INTERNAL_PORT:-8443} - NIFI_OUTPUT_PORT=${NIFI_OUTPUT_PORT:-8082} - NIFI_INPUT_SOCKET_PORT=${NIFI_INPUT_SOCKET_PORT:-10000} + - JVM_OPTS="${NIFI_JVM_OPTS:--XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+ParallelRefProcEnabled -Djava.security.egd=file:/dev/./urandom}" + deploy: + resources: + limits: + cpus: "${NIFI_DOCKER_CPU_MAX}" + memory: "${NIFI_DOCKER_RAM}" + reservations: + cpus: "${NIFI_DOCKER_CPU_MIN}" + memory: "${NIFI_DOCKER_RAM}" volumes: # INFO: drivers folder - ../nifi/drivers:/opt/nifi/drivers @@ -501,7 +543,7 @@ services: image: apache/nifi-registry:${NIFI_REGISTRY_VERSION:-2.6.0} hostname: nifi-registry container_name: cogstack-nifi-registry-flow - shm_size: ${NIFI_REGISTRY_SHM_SIZE:-"1g"} + shm_size: ${NIFI_DOCKER_REGISTRY_SHM_SIZE:-1g} user: root environment: - http_proxy=$HTTP_PROXY @@ -521,6 +563,14 @@ services: - NIFI_REGISTRY_DB_DIR=${NIFI_REGISTRY_DB_DIR:-/opt/nifi-registry/nifi-registry-current/database} #- NIFI_REGISTRY_FLOW_PROVIDER=${NIFI_REGISTRY_FLOW_PROVIDER:-file} - NIFI_REGISTRY_FLOW_STORAGE_DIR=${NIFI_REGISTRY_FLOW_STORAGE_DIR:-/opt/nifi-registry/nifi-registry-current/flow_storage} + deploy: + resources: + limits: + cpus: "${NIFI_REGISTRY_DOCKER_CPU_MAX}" + memory: "${NIFI_REGISTRY_DOCKER_RAM}" + reservations: + cpus: "${NIFI_REGISTRY_DOCKER_CPU_MIN}" + memory: "${NIFI_REGISTRY_DOCKER_RAM}" volumes: - ../nifi/nifi-registry/:/opt/nifi-registry/nifi-registry-current/conf/:rw - ./${NIFI_SECURITY_DIR:-../security/certificates/nifi/}nifi-keystore.jks:/opt/nifi-registry/nifi-registry-current/conf/keystore.jks:ro @@ -545,8 +595,16 @@ services: image: cogstacksystems/nifi-nginx:latest container_name: cogstack-nifi-nginx restart: always - shm_size: 512mb + shm_size: ${NGINX_SHM_SIZE:-1g} env_file: *all-env + deploy: + resources: + limits: + cpus: "${NGINX_DOCKER_CPU_MAX}" + memory: "${NGINX_DOCKER_RAM}" + reservations: + cpus: "${NGINX_DOCKER_CPU_MIN}" + memory: "${NGINX_DOCKER_RAM}" volumes: - ../services/nginx/sites-enabled:/etc/nginx/sites-enabled:ro - ../services/nginx/config/nginx.conf.template:/etc/nginx/config/nginx.conf.template:rw From c0f81b892b71c6d855f1f3eca1d726367325d660 Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Thu, 27 Nov 2025 20:45:57 +0000 Subject: [PATCH 07/11] Deploy: env + services def updates (resource constraints per service). --- deploy/database.env | 6 +++++- deploy/gitea.env | 7 ++++++- deploy/services.yml | 51 +++++++++++++++++++++++++++------------------ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/deploy/database.env b/deploy/database.env index d4ad0d7fc..ad8b4b0c3 100644 --- a/deploy/database.env +++ b/deploy/database.env @@ -6,4 +6,8 @@ POSTGRES_DB_MAX_CONNECTIONS=100 # Prefix of file names to load the DB schema for in /services/cogstack-db/(pgsql/mssql)/schemas/ folder POSTGRES_DB_SCHEMA_PREFIX="cogstack_db" -POSTGRES_SHM_SIZE="1g" +DATABASE_DOCKER_SHM_SIZE="1g" + +DATABASE_DOCKER_CPU_MIN=1 +DATABASE_DOCKER_CPU_MAX=1 +DATABASE_DOCKER_RAM=1g diff --git a/deploy/gitea.env b/deploy/gitea.env index 0009d5759..599e9e748 100644 --- a/deploy/gitea.env +++ b/deploy/gitea.env @@ -15,7 +15,7 @@ GITEA_BASE_URL="${GITEA_NAMESPACE_URL}/${GITEA_MAIN_REPO_NAME}.git" # this token is a sample, create yours # navigate to https://localhost:3000/user/settings/applications/ # this is a sample token, you should create your own -GITEA_TOKEN="" +GITEA_TOKEN="711c8c6fad00f3fc082e4bffce2947f78ec12f4e" GITEA_SUBMODULE_DIR="services" GITEA_LOCAL_KEY_PATH="$HOME/.ssh/id_rsa_gitea_cogstack" @@ -23,3 +23,8 @@ GITEA_LOCAL_PUB_KEY_PATH="$GITEA_LOCAL_KEY_PATH.pub" GITEA_LOCAL_KEY_TITLE="gitea-cogstack-$(hostname)-$(date +%s)" GITEA_DEFAULT_MAIN_REMOTE_NAME="cogstack-gitea" + +GITEA_DOCKER_SHM_SIZE=512m +GITEA_DOCKER_CPU_MIN=1 +GITEA_DOCKER_CPU_MAX=1 +GITEA_DOCKER_RAM=1g diff --git a/deploy/services.yml b/deploy/services.yml index 7bc8f93be..c864c6c48 100644 --- a/deploy/services.yml +++ b/deploy/services.yml @@ -42,6 +42,10 @@ x-es-env: &es-env - ../security/env/users_elasticsearch.env - ../security/env/certificates_elasticsearch.env +x-db-env: &db-env + - ./database.env + - ../security/env/users_database.env + x-common-hosts: &common-hosts - ${ELASTICSEARCH_1_HOST_NAME:-test-1:0.0.0.0} - ${ELASTICSEARCH_2_HOST_NAME:-test-2:0.0.0.0} @@ -68,6 +72,20 @@ x-nifi-common: &nifi-common networks: - cognet +x-db-common: &db-common + <<: *common-ulimits + shm_size: ${DATABASE_DOCKER_SHM_SIZE:-"1g"} + restart: unless-stopped + env_file: *db-env + deploy: + resources: + limits: + cpus: "${DATABASE_DOCKER_CPU_MAX}" + memory: "${DATABASE_DOCKER_RAM}" + reservations: + cpus: "${DATABASE_DOCKER_CPU_MIN}" + memory: "${DATABASE_DOCKER_RAM}" + x-es-common-volumes: &es-common-volumes # Shared configs - ../services/elasticsearch/config/${ELASTICSEARCH_VERSION:-opensearch}.yml:/usr/share/${ELASTICSEARCH_VERSION:-opensearch}/config/${ELASTICSEARCH_VERSION:-opensearch}.yml:ro @@ -95,7 +113,7 @@ x-es-common: &es-common <<: *common-ulimits image: ${ELASTICSEARCH_DOCKER_IMAGE:-opensearchproject/opensearch:3.3.0} shm_size: ${ELASTICSEARCH_DOCKER_SHM_SIZE:-1g} - restart: always + restart: unless-stopped env_file: *es-env networks: - cognet @@ -188,15 +206,10 @@ services: # Postgres container with sample data # #---------------------------------------------------------------------------# samples-db: - <<: *common-ulimits + <<: *db-common image: postgres:17.5-alpine container_name: cogstack-samples-db - shm_size: ${POSTGRES_SHM_SIZE:-"1g"} - restart: always platform: linux/amd64 - env_file: - - ./database.env - - ../security/env/users_database.env environment: # PG env vars - POSTGRES_USER=${POSTGRES_USER_SAMPLES:-test} @@ -220,15 +233,10 @@ services: # CogStack Databank / Cogstack-DB, production database # #---------------------------------------------------------------------------# cogstack-databank-db: - <<: *common-ulimits + <<: *db-common image: postgres:17.5-alpine container_name: cogstack-production-databank-db - shm_size: ${POSTGRES_SHM_SIZE:-"1g"} - restart: always platform: linux/amd64 - env_file: - - ./database.env - - ../security/env/users_database.env environment: - POSTGRES_USER=${POSTGRES_USER:-admin} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-admin} @@ -248,14 +256,9 @@ services: - cognet cogstack-databank-db-mssql: - <<: *common-ulimits + <<: *db-common image: mcr.microsoft.com/mssql/server:2019-latest container_name: cogstack-production-databank-db-mssql - shm_size: ${POSTGRES_SHM_SIZE:-"1g"} - restart: always - env_file: - - ./database.env - - ../security/env/users_database.env environment: - ACCEPT_EULA=y - MSSQL_SA_USER=${MSSQL_SA_USER:-sa} @@ -645,12 +648,20 @@ services: <<: *common-ulimits container_name: cogstack-gitea image: gitea/gitea:1.23-rootless - shm_size: ${DOCKER_SHM_SIZE:-"1g"} + shm_size: ${GITEA_DOCKER_SHM_SIZE:-"1g"} restart: always environment: - http_proxy=$HTTP_PROXY - https_proxy=$HTTPS_PROXY - no_proxy=$no_proxy + deploy: + resources: + limits: + cpus: "${GITEA_DOCKER_CPU_MAX}" + memory: "${GITEA_DOCKER_RAM}" + reservations: + cpus: "${GITEA_DOCKER_CPU_MIN}" + memory: "${GITEA_DOCKER_RAM}" volumes: # app config - ../services/gitea/app.ini:/etc/gitea/app.ini:rw From 5e0894c68f363c57c7e5d94ba64e849ab9dafc46 Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Thu, 27 Nov 2025 21:15:32 +0000 Subject: [PATCH 08/11] NiFi: dockerfile update. --- nifi/Dockerfile | 4 ++++ nifi/requirements.txt | 1 + 2 files changed, 5 insertions(+) diff --git a/nifi/Dockerfile b/nifi/Dockerfile index 2cdc1dd40..63df1e84f 100644 --- a/nifi/Dockerfile +++ b/nifi/Dockerfile @@ -27,6 +27,10 @@ ENV NIFI_PYTHON_WORKING_DIRECTORY=${NIFI_PYTHON_WORKING_DIRECTORY} ENV PIP_PREFER_BINARY=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PIP_NO_CACHE_DIR=1 +# Enables Python to generate .pyc files in the container +ENV PYTHONDONTWRITEBYTECODE=0 +# Turns off buffering for easier container logging +ENV PYTHONUNBUFFERED=1 # default env vars to prevent NiFi from running on HTTP ENV NIFI_WEB_HTTP_PORT="" diff --git a/nifi/requirements.txt b/nifi/requirements.txt index df5c2331f..5cde6aa3f 100644 --- a/nifi/requirements.txt +++ b/nifi/requirements.txt @@ -21,6 +21,7 @@ pymssql==2.3.9 psycopg[c,binary]==3.2.9 requests==2.32.5 PyYAML==6.0.3 +orjson=3.11.4 # other utils xnat==0.7.2 From 9eec6296e8dad865d83c024bf3b5d9025683a7f7 Mon Sep 17 00:00:00 2001 From: vladd-bit Date: Fri, 28 Nov 2025 11:45:49 +0000 Subject: [PATCH 09/11] Transioning NiFi scripts to python package. --- .gitignore | 7 + deploy/database.env | 8 +- deploy/export_env_vars.sh | 2 +- deploy/gitea.env | 2 +- deploy/services.yml | 14 +- docs/nifi/main.md | 6 +- nifi/{user-schemas/avro/.keep => __init__.py} | 0 nifi/requirements.txt | 2 +- .../convert_avro_binary_field_to_base64.py | 171 - .../convert_json_record_schema.py | 155 - .../parse_service_response.py | 202 - .../prepare_record_for_nlp.py | 124 - .../prepare_record_for_ocr.py | 145 - .../record_add_geolocation.py | 247 - .../record_decompress_cerner_blob.py | 193 - .../sample_processor.py | 166 - .../elasticsearch/base_index_settings.json | 65 - nifi/user-schemas/elasticsearch/indices/.keep | 0 .../elasticsearch/templates/.keep | 0 nifi/user-schemas/json/.keep | 0 .../legacy/annotation-medcat.avsc | 24 - ...nnotation_elasticsearch_index_mapping.json | 140 - .../legacy/cogstack_common_schema.avsc | 93 - ..._elasticsearch_index_mapping_template.json | 433 -- .../legacy/cogstack_common_schema_full.avsc | 330 - .../cogstack_common_schema_mapping.json | 326 - nifi/user-schemas/legacy/document.avsc | 18 - .../legacy/document_all_fields.avsc | 19 - .../bootstrap_external_lib_imports.py | 19 - nifi/user-scripts/clean_doc.py | 39 - .../cogstack_cohort_generate_data.py | 436 -- .../cogstack_cohort_generate_random_data.py | 149 - nifi/user-scripts/db/.gitignore | 0 nifi/user-scripts/dto/nifi_api_config.py | 45 - nifi/user-scripts/dto/pg_config.py | 10 - nifi/user-scripts/dto/service_health.py | 51 - nifi/user-scripts/generate_location.py | 69 - nifi/user-scripts/get_files_from_storage.py | 207 - .../legacy_scripts/annotation_creator.py | 56 - .../legacy_scripts/annotation_manager.py | 136 - .../legacy_scripts/annotation_manager_docs.py | 70 - .../legacy_scripts/anonymise_doc.py | 59 - .../flowfile_to_attribute_with_content.py | 99 - .../legacy_scripts/ingest_into_es.py | 45 - .../parse-anns-from-nlp-response-bulk.py | 166 - ...parse-es-db-result-for-nlp-request-bulk.py | 110 - .../legacy_scripts/parse-json-to-avro.py | 62 - .../parse-tika-result-json-to-avro.py | 69 - ...epare-db-record-for-tika-request-single.py | 96 - ...-for-tika-request-single-keep-db-fields.py | 77 - nifi/user-scripts/logs/.gitignore | 1 - nifi/user-scripts/logs/parse_json/.gitkeep | 0 .../tests/generate_big_ann_file.py | 23 - nifi/user-scripts/tests/generate_files.py | 61 - nifi/user-scripts/tests/get_ingested_files.py | 10 - nifi/user-scripts/tests/test_files/ex1.pdf | Bin 194005 -> 0 bytes nifi/user-scripts/tmp/.gitignore | 0 nifi/user-scripts/utils/cerner_blob.py | 125 - nifi/user-scripts/utils/ethnicity_map.py | 153 - nifi/user-scripts/utils/generic.py | 159 - .../utils/helpers/avro_json_encoder.py | 9 - .../utils/helpers/base_nifi_processor.py | 156 - .../utils/helpers/nifi_api_client.py | 82 - nifi/user-scripts/utils/helpers/service.py | 33 - nifi/user-scripts/utils/lint_env.py | 115 - nifi/user-scripts/utils/pgsql_query.py | 8 - nifi/user-scripts/utils/sqlite_query.py | 97 - .../dt4h/annotate_dt4h_ann_manager.xml | 2322 ------ nifi/user-templates/dt4h/raw_ingest_dt4h.xml | 5940 ---------------- .../CogStack_Cohort_create_source_docs.xml | 6226 ----------------- .../legacy/Common_schema_example_ingest.xml | 3262 --------- .../legacy/DEID_sample_pipeline.xml | 1148 --- .../legacy/Generate_location_ES.xml | 1927 ----- .../legacy/Grab_non_annotated_docs.xml | 1631 ----- nifi/user-templates/legacy/HealTAC_23.xml | 3547 ---------- .../legacy/OS_annotate_per_doc.xml | 2754 -------- ...OpenSearch_Ingest_DB_OCR_service_to_ES.xml | 2196 ------ ...ingest_annotate_DB_MedCATService_to_ES.xml | 2422 ------- ...t_annotate_DB_to_ES_and_DB_ann_manager.xml | 2929 -------- ...ingest_annotate_ES_MedCATService_to_ES.xml | 1785 ----- .../OpenSearch_ingest_docs_DB_to_ES.xml | 1497 ---- .../Raw_file_read_from_disk_ocr_custom.xml | 2688 ------- ...nsearch_docs_ingest_annotations_to_es.json | 1 - ...arch_ingest_docs_db_ocr_service_to_es.json | 1 - .../opensearch_ingest_docs_db_to_es.json | 1 - pyproject.toml | 16 +- security/env/users_database.env | 4 +- security/env/users_elasticsearch.env | 1 - 88 files changed, 41 insertions(+), 48251 deletions(-) rename nifi/{user-schemas/avro/.keep => __init__.py} (100%) delete mode 100644 nifi/user-python-extensions/convert_avro_binary_field_to_base64.py delete mode 100644 nifi/user-python-extensions/convert_json_record_schema.py delete mode 100644 nifi/user-python-extensions/parse_service_response.py delete mode 100644 nifi/user-python-extensions/prepare_record_for_nlp.py delete mode 100644 nifi/user-python-extensions/prepare_record_for_ocr.py delete mode 100644 nifi/user-python-extensions/record_add_geolocation.py delete mode 100644 nifi/user-python-extensions/record_decompress_cerner_blob.py delete mode 100644 nifi/user-python-extensions/sample_processor.py delete mode 100644 nifi/user-schemas/elasticsearch/base_index_settings.json delete mode 100644 nifi/user-schemas/elasticsearch/indices/.keep delete mode 100644 nifi/user-schemas/elasticsearch/templates/.keep delete mode 100644 nifi/user-schemas/json/.keep delete mode 100644 nifi/user-schemas/legacy/annotation-medcat.avsc delete mode 100644 nifi/user-schemas/legacy/annotation_elasticsearch_index_mapping.json delete mode 100644 nifi/user-schemas/legacy/cogstack_common_schema.avsc delete mode 100644 nifi/user-schemas/legacy/cogstack_common_schema_elasticsearch_index_mapping_template.json delete mode 100644 nifi/user-schemas/legacy/cogstack_common_schema_full.avsc delete mode 100644 nifi/user-schemas/legacy/cogstack_common_schema_mapping.json delete mode 100644 nifi/user-schemas/legacy/document.avsc delete mode 100644 nifi/user-schemas/legacy/document_all_fields.avsc delete mode 100644 nifi/user-scripts/bootstrap_external_lib_imports.py delete mode 100644 nifi/user-scripts/clean_doc.py delete mode 100644 nifi/user-scripts/cogstack_cohort_generate_data.py delete mode 100644 nifi/user-scripts/cogstack_cohort_generate_random_data.py delete mode 100644 nifi/user-scripts/db/.gitignore delete mode 100644 nifi/user-scripts/dto/nifi_api_config.py delete mode 100644 nifi/user-scripts/dto/pg_config.py delete mode 100644 nifi/user-scripts/dto/service_health.py delete mode 100644 nifi/user-scripts/generate_location.py delete mode 100644 nifi/user-scripts/get_files_from_storage.py delete mode 100644 nifi/user-scripts/legacy_scripts/annotation_creator.py delete mode 100644 nifi/user-scripts/legacy_scripts/annotation_manager.py delete mode 100644 nifi/user-scripts/legacy_scripts/annotation_manager_docs.py delete mode 100644 nifi/user-scripts/legacy_scripts/anonymise_doc.py delete mode 100644 nifi/user-scripts/legacy_scripts/flowfile_to_attribute_with_content.py delete mode 100644 nifi/user-scripts/legacy_scripts/ingest_into_es.py delete mode 100644 nifi/user-scripts/legacy_scripts/parse-anns-from-nlp-response-bulk.py delete mode 100644 nifi/user-scripts/legacy_scripts/parse-es-db-result-for-nlp-request-bulk.py delete mode 100644 nifi/user-scripts/legacy_scripts/parse-json-to-avro.py delete mode 100644 nifi/user-scripts/legacy_scripts/parse-tika-result-json-to-avro.py delete mode 100644 nifi/user-scripts/legacy_scripts/prepare-db-record-for-tika-request-single.py delete mode 100644 nifi/user-scripts/legacy_scripts/prepare-file-for-tika-request-single-keep-db-fields.py delete mode 100644 nifi/user-scripts/logs/.gitignore delete mode 100644 nifi/user-scripts/logs/parse_json/.gitkeep delete mode 100644 nifi/user-scripts/tests/generate_big_ann_file.py delete mode 100644 nifi/user-scripts/tests/generate_files.py delete mode 100644 nifi/user-scripts/tests/get_ingested_files.py delete mode 100755 nifi/user-scripts/tests/test_files/ex1.pdf delete mode 100644 nifi/user-scripts/tmp/.gitignore delete mode 100644 nifi/user-scripts/utils/cerner_blob.py delete mode 100644 nifi/user-scripts/utils/ethnicity_map.py delete mode 100644 nifi/user-scripts/utils/generic.py delete mode 100644 nifi/user-scripts/utils/helpers/avro_json_encoder.py delete mode 100644 nifi/user-scripts/utils/helpers/base_nifi_processor.py delete mode 100644 nifi/user-scripts/utils/helpers/nifi_api_client.py delete mode 100644 nifi/user-scripts/utils/helpers/service.py delete mode 100644 nifi/user-scripts/utils/lint_env.py delete mode 100644 nifi/user-scripts/utils/pgsql_query.py delete mode 100644 nifi/user-scripts/utils/sqlite_query.py delete mode 100644 nifi/user-templates/dt4h/annotate_dt4h_ann_manager.xml delete mode 100644 nifi/user-templates/dt4h/raw_ingest_dt4h.xml delete mode 100644 nifi/user-templates/legacy/CogStack_Cohort_create_source_docs.xml delete mode 100644 nifi/user-templates/legacy/Common_schema_example_ingest.xml delete mode 100644 nifi/user-templates/legacy/DEID_sample_pipeline.xml delete mode 100644 nifi/user-templates/legacy/Generate_location_ES.xml delete mode 100644 nifi/user-templates/legacy/Grab_non_annotated_docs.xml delete mode 100644 nifi/user-templates/legacy/HealTAC_23.xml delete mode 100644 nifi/user-templates/legacy/OS_annotate_per_doc.xml delete mode 100644 nifi/user-templates/legacy/OpenSearch_Ingest_DB_OCR_service_to_ES.xml delete mode 100644 nifi/user-templates/legacy/OpenSearch_ingest_annotate_DB_MedCATService_to_ES.xml delete mode 100644 nifi/user-templates/legacy/OpenSearch_ingest_annotate_DB_to_ES_and_DB_ann_manager.xml delete mode 100644 nifi/user-templates/legacy/OpenSearch_ingest_annotate_ES_MedCATService_to_ES.xml delete mode 100644 nifi/user-templates/legacy/OpenSearch_ingest_docs_DB_to_ES.xml delete mode 100644 nifi/user-templates/legacy/Raw_file_read_from_disk_ocr_custom.xml delete mode 100644 nifi/user-templates/opensearch_docs_ingest_annotations_to_es.json delete mode 100644 nifi/user-templates/opensearch_ingest_docs_db_ocr_service_to_es.json delete mode 100644 nifi/user-templates/opensearch_ingest_docs_db_to_es.json diff --git a/.gitignore b/.gitignore index 306df23c5..33fe087ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,13 @@ venv **__pycache__ **/venv +*.pyc +.pyc + +# Ignore setuptools metadata +*.egg-info/ +*.egg-info +**/*.egg-info/ # keys and certificates *.pem diff --git a/deploy/database.env b/deploy/database.env index ad8b4b0c3..478e13c66 100644 --- a/deploy/database.env +++ b/deploy/database.env @@ -1,12 +1,12 @@ -# production db name -POSTGRES_DATABANK_DB=cogstack - POSTGRES_DB_MAX_CONNECTIONS=100 # Prefix of file names to load the DB schema for in /services/cogstack-db/(pgsql/mssql)/schemas/ folder POSTGRES_DB_SCHEMA_PREFIX="cogstack_db" -DATABASE_DOCKER_SHM_SIZE="1g" +# production db name +DATABASE_DB_NAME=cogstack + +DATABASE_DOCKER_SHM_SIZE=1g DATABASE_DOCKER_CPU_MIN=1 DATABASE_DOCKER_CPU_MAX=1 diff --git a/deploy/export_env_vars.sh b/deploy/export_env_vars.sh index 5064a0d4b..58b446543 100755 --- a/deploy/export_env_vars.sh +++ b/deploy/export_env_vars.sh @@ -41,7 +41,7 @@ env_files=( "$SERVICES_DIR/cogstack-nlp/medcat-service/env/medcat.env" ) -LINT_SCRIPT="$SCRIPT_DIR/../nifi/user-scripts/utils/lint_env.py" +LINT_SCRIPT="$SCRIPT_DIR/../nifi/user_scripts/utils/lint_env.py" if [ -x "$LINT_SCRIPT" ]; then echo "🔍 Validating env files..." diff --git a/deploy/gitea.env b/deploy/gitea.env index 599e9e748..e2ef85779 100644 --- a/deploy/gitea.env +++ b/deploy/gitea.env @@ -15,7 +15,7 @@ GITEA_BASE_URL="${GITEA_NAMESPACE_URL}/${GITEA_MAIN_REPO_NAME}.git" # this token is a sample, create yours # navigate to https://localhost:3000/user/settings/applications/ # this is a sample token, you should create your own -GITEA_TOKEN="711c8c6fad00f3fc082e4bffce2947f78ec12f4e" +GITEA_TOKEN="" GITEA_SUBMODULE_DIR="services" GITEA_LOCAL_KEY_PATH="$HOME/.ssh/id_rsa_gitea_cogstack" diff --git a/deploy/services.yml b/deploy/services.yml index c864c6c48..8e6bf1c85 100644 --- a/deploy/services.yml +++ b/deploy/services.yml @@ -238,9 +238,9 @@ services: container_name: cogstack-production-databank-db platform: linux/amd64 environment: - - POSTGRES_USER=${POSTGRES_USER:-admin} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-admin} - - POSTGRES_DATABANK_DB=${POSTGRES_DATABANK_DB:-cogstack} + - POSTGRES_USER=${DATABASE_USER:-admin} + - POSTGRES_PASSWORD=${DATABASE_PASSWORD:-admin} + - POSTGRES_DATABANK_DB=${DATABASE_DB_NAME:-cogstack} volumes: # mapping postgres data dump and initialization - ../services/cogstack-db/pgsql/schemas:/data/:ro @@ -495,12 +495,12 @@ services: # INFO: if there are local changes, map these content from local host to container # (normally, these 3 directories below are bundled with our NiFi image) # N.B. The container user may not have the permission to read these directories/files. - - ../nifi/user-templates:/opt/nifi/nifi-current/conf/templates:rw - - ../nifi/user-scripts:/opt/nifi/user-scripts:rw - - ../nifi/user-schemas:/opt/nifi/user-schemas:rw + - ../nifi/user_templates:/opt/nifi/nifi-current/conf/templates:rw + - ../nifi/user_scripts:/opt/nifi/user_scripts:rw + - ../nifi/user_schemas:/opt/nifi/user_schemas:rw # this is a direct mapping to where we store the NiFi python processors as of NiFi 2.x.x - - ../nifi/user-python-extensions:/opt/nifi/nifi-current/python_extensions:rw + - ../nifi/user_python_extensions:/opt/nifi/nifi-current/python_extensions:rw # INFO: uncomment below to map security certificates if need to secure NiFi endpoints - ../security:/security:ro diff --git a/docs/nifi/main.md b/docs/nifi/main.md index 42b1ae1fc..219465aaa 100644 --- a/docs/nifi/main.md +++ b/docs/nifi/main.md @@ -25,9 +25,9 @@ Avro Schema:[official documentation](https://avro.apache.org/docs/1.11.1/) ├── devel - custom folder that is mounted on the NiFi container where you may place your own scripts, again, read & write permissions required ├── drivers - drivers used for DB connections, currently PostgreSQL and MSSQL ├── nifi-app.log - log file mounted directly from the container for easy log checking -├── user-schemas - Avro schemas used within workflows, it can also contain other schemas used in specific custom processors -├── user-scripts - custom scripts used in workflows, you can put them here -└── user-templates - here we store the fully exported templates of the workflows within NiFi +├── user_schemas - Avro schemas used within workflows, it can also contain other schemas used in specific custom processors +├── user_scripts - custom scripts used in workflows, you can put them here +└── user_templates - here we store the fully exported templates of the workflows within NiFi ``` ## Custom Docker image diff --git a/nifi/user-schemas/avro/.keep b/nifi/__init__.py similarity index 100% rename from nifi/user-schemas/avro/.keep rename to nifi/__init__.py diff --git a/nifi/requirements.txt b/nifi/requirements.txt index 5cde6aa3f..9093822f4 100644 --- a/nifi/requirements.txt +++ b/nifi/requirements.txt @@ -21,7 +21,7 @@ pymssql==2.3.9 psycopg[c,binary]==3.2.9 requests==2.32.5 PyYAML==6.0.3 -orjson=3.11.4 +pydantic==2.12.5 # other utils xnat==0.7.2 diff --git a/nifi/user-python-extensions/convert_avro_binary_field_to_base64.py b/nifi/user-python-extensions/convert_avro_binary_field_to_base64.py deleted file mode 100644 index 0558dbbd4..000000000 --- a/nifi/user-python-extensions/convert_avro_binary_field_to_base64.py +++ /dev/null @@ -1,171 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import base64 -import copy -import io -import json -import traceback - -from avro.datafile import DataFileReader, DataFileWriter -from avro.io import DatumReader, DatumWriter -from avro.schema import RecordSchema, Schema, parse -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from nifiapi.relationship import Relationship -from overrides import override -from py4j.java_gateway import JavaObject, JVMView -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class ConvertAvroBinaryRecordFieldToBase64(BaseNiFiProcessor): - """NiFi Python processor to convert a binary field in Avro records to base64-encoded string. - - Reads each FlowFile as Avro, locates the configured binary_field_name, and rewrites the Avro schema, - so that field becomes a nullable string, preventing NiFi’s JSON - converters from turning raw bytes into integer arrays. - - Streams every record through a new Avro writer, base64-encoding the binary payload when operation_mode=base64 - (or leaving bytes untouched for raw), then reattaching the remaining fields - so downstream processors still see the original record structure. - - Emits the updated Avro binary along success with attributes capturing document ID field, - binary field, mode, and MIME type application/avro-binary; - - Exception routes to failure after logging a stack trace. - """ - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.operation_mode: str = "base64" - self.binary_field_name: str = "binarydoc" - self.document_id_field_name: str = "id" - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="binary_field_name", - description="Avro field containing binary data", - default_value="binarydoc", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="operation_mode", - description="Decoding mode (e.g. base64 or raw)", - default_value="base64", - required=True, - allowable_values=["base64", "raw"]), - PropertyDescriptor(name="document_id_field_name", - description="Field name containing document ID", - default_value="id", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - ] - - self._relationships = [ - Relationship( - name="success", - description="All FlowFiles processed successfully." - ), - Relationship( - name="failure", - description="FlowFiles that failed processing." - ) - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - self.relationships: list[Relationship] = self._relationships - - @override - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - """ - Transforms an Avro flow file by converting a specified binary field to a base64-encoded string. - - Args: - context (ProcessContext): The process context containing processor properties. - flowFile (JavaObject): The flow file to be transformed. - - Raises: - TypeError: If the Avro record is not a dictionary. - Exception: For any other errors during Avro processing. - - Returns: - FlowFileTransformResult: The result containing the transformed flow file, updated attributes, - and relationship. - """ - try: - self.process_context = context - self.set_properties(context.getProperties()) - - # read avro record - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - input_byte_buffer: io.BytesIO = io.BytesIO(input_raw_bytes) - reader: DataFileReader = DataFileReader(input_byte_buffer, DatumReader()) - - schema: Schema | None = reader.datum_reader.writers_schema - - # change the datatype of the binary field from bytes to string - # (avoids headaches later on when converting avro to json) - # because if we dont change the schema the native NiFi converter will convert bytes to an array of integers. - output_schema = None - if schema is not None and isinstance(schema, RecordSchema): - schema_dict = copy.deepcopy(schema.to_json()) - for field in schema_dict["fields"]: # type: ignore - self.logger.info(str(field)) - if field["name"] == self.binary_field_name: - field["type"] = ["null", "string"] - break - output_schema = parse(json.dumps(schema_dict)) - - # Write them to a binary avro stream - output_byte_buffer = io.BytesIO() - writer = DataFileWriter(output_byte_buffer, DatumWriter(), output_schema) - - for record in reader: - if type(record) is dict: - record_document_binary_data = record.get(str(self.binary_field_name), None) - - if record_document_binary_data is not None: - if self.operation_mode == "base64": - record_document_binary_data = base64.b64encode(record_document_binary_data).decode() - else: - self.logger.info("No binary data found in record, using empty content") - else: - raise TypeError("Expected Avro record to be a dictionary, but got: " + str(type(record))) - - _tmp_record = {} - _tmp_record[str(self.binary_field_name)] = record_document_binary_data - - for k, v in record.items(): - if k != str(self.binary_field_name): - _tmp_record[k] = v - - writer.append(_tmp_record) - - input_byte_buffer.close() - reader.close() - writer.flush() - output_byte_buffer.seek(0) - - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["document_id_field_name"] = str(self.document_id_field_name) - attributes["binary_field"] = str(self.binary_field_name) - attributes["operation_mode"] = str(self.operation_mode) - attributes["mime.type"] = "application/avro-binary" - - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=output_byte_buffer.getvalue()) - except Exception as exception: - self.logger.error("Exception during Avro processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-python-extensions/convert_json_record_schema.py b/nifi/user-python-extensions/convert_json_record_schema.py deleted file mode 100644 index 6cc672204..000000000 --- a/nifi/user-python-extensions/convert_json_record_schema.py +++ /dev/null @@ -1,155 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import json -import traceback -from collections import defaultdict -from typing import Any - -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from nifiapi.relationship import Relationship -from py4j.java_gateway import JavaObject, JVMView -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class ConvertJsonRecordSchema(BaseNiFiProcessor): - """Remaps each incoming JSON record (single dict or list of dicts) - using a lookup loaded from json_mapper_schema_path, - so the FlowFile content conforms to the common schema defined under /opt/nifi/user-schemas/json. - - For every mapping entry it can rename fields, populate constant null placeholders, - or stitch together composite fields by concatenating multiple source values with newline separators. - - Optionally preserves any source fields not covered by the mapping via the - preserve_non_mapped_fields boolean property, which defaults to true to avoid accidental data loss. - - Emits the transformed payload as JSON (mime.type=application/json) and tags the FlowFile with the schema path used, - routing successes to success and any exceptions to failure - """ - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.json_mapper_schema_path: str = "/opt/nifi/user-schemas/json/cogstack_common_schema_mapping.json" - self.preserve_non_mapped_fields: bool = True - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="json_mapper_schema_path", - description="The path to the json schema mapping file, " \ - "the schema directory is mounted as a volume in" \ - " the nifi container in the /opt/nifi/user-schemas/ folder", - default_value="/opt/nifi/user-schemas/json/cogstack_common_schema_mapping.json", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="preserve_non_mapped_fields", - description="Whether to preserve fields that are not mapped in the schema", - default_value="true", - required=True, - allowable_values=["true", "false"], - validators=[StandardValidators.BOOLEAN_VALIDATOR]) - ] - - self._relationships = [ - Relationship( - name="success", - description="All FlowFiles processed successfully." - ), - Relationship( - name="failure", - description="FlowFiles that failed processing." - ) - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - self.relationships: list[Relationship] = self._relationships - - def map_record(self, record: dict, json_mapper_schema: dict) -> dict: - """ - Maps the fields of a record to new field names based on the provided JSON schema mapping. - {new_field -> old_field, ....} - - Args: - record (dict): The input record whose fields need to be mapped. - json_mapper_schema (dict): The schema mapping dict specifying how to rename or nest fields. - - Returns: - dict: A new record with fields mapped according to the schema. - """ - - new_record: dict = {} - - # reverse the json_mapper_schema to map old_field -> new_field - json_mapper_schema_reverse = defaultdict(list) - for new_field, old_field in json_mapper_schema.items(): - # skip nulls & composite fields - if isinstance(old_field, str) and old_field: - json_mapper_schema_reverse[old_field].append(new_field) - - # Iterate through existing record fields - for curr_field_name, curr_field_value in record.items(): - if curr_field_name in json_mapper_schema_reverse: - # multiple new fields can receive same source value - for new_field_name in json_mapper_schema_reverse[curr_field_name]: - new_record[new_field_name] = curr_field_value - elif self.preserve_non_mapped_fields: - # preserve original fields not defined in mapping - new_record[curr_field_name] = curr_field_value - - # Add preset fields defined with null in schema - for new_field, old_field in json_mapper_schema.items(): - if old_field is None: - new_record.setdefault(new_field, None) - elif isinstance(old_field, list): - parts = [] - for sub_field in old_field: - val = record.get(sub_field) - if val is not None and val != "": - parts.append(str(val)) - new_record[new_field] = "\n".join(parts) if parts else None - - return new_record - - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - output_contents: list[dict[Any, Any]] = [] - - try: - self.process_context: ProcessContext = context - self.set_properties(context.getProperties()) - - # read avro record - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - records: dict | list[dict] = json.loads(input_raw_bytes.decode("utf-8")) - - if isinstance(records, dict): - records = [records] - - json_mapper_schema: dict = {} - with open(self.json_mapper_schema_path) as file: - json_mapper_schema = json.load(file) - - for record in records: - output_contents.append(self.map_record(record, json_mapper_schema)) - - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["json_mapper_schema_path"] = str(self.json_mapper_schema_path) - attributes["mime.type"] = "application/json" - - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps(output_contents).encode('utf-8')) - except Exception as exception: - self.logger.error("Exception during flowfile processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-python-extensions/parse_service_response.py b/nifi/user-python-extensions/parse_service_response.py deleted file mode 100644 index 51c7995dc..000000000 --- a/nifi/user-python-extensions/parse_service_response.py +++ /dev/null @@ -1,202 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import json -import traceback - -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from nifiapi.relationship import Relationship -from overrides import override -from py4j.java_gateway import JavaObject, JVMView -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class ParseCogStackServiceResult(BaseNiFiProcessor): - """ Normalises JSON responses from CogStack OCR or MedCAT services, reading each FlowFile, - coercing single objects to lists. - Exposes configurable properties for output text field name, service message type, - document ID/text fields, and MedCAT DEID behaviour so the same processor can be reused across services. - - """ - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.output_text_field_name: str = "text" - self.service_message_type: str = "ocr" - self.document_text_field_name:str = "text" - self.document_id_field_name: str = "_id" - self.medcat_output_mode: str = "not_set" - self.medcat_deid_keep_annotations: bool = True - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="output_text_field_name", - description="field to store OCR output text, this can also be used" - " in MedCAT output in DE_ID mode", - default_value="text", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="service_message_type", - description="the type of service message form this script processes," \ - " possible values: not_set | medcat | ocr", - default_value="not_set", - required=True, - allowable_values=["ocr", "medcat", "not_set"]), - PropertyDescriptor(name="document_id_field_name", - description="id field name of the document, this will be taken from the 'footer' usually", - default_value="_id", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="document_text_field_name", - description="text field name of the document", - validators=[StandardValidators.NON_EMPTY_VALIDATOR], - required=True, - default_value="text"), - PropertyDescriptor(name="medcat_output_mode", - description="service_message_type is set to 'medcat' \ - for this to work, only used for deid processing," - " if the output is for deid, then we can customise the" \ - " name of the text field, possible values: deid | not_set", - default_value="not_set", - required=True, - allowable_values=["deid", "not_set"], - ), - PropertyDescriptor(name="medcat_deid_keep_annotations", - description="if set to true, " \ - "then the annotations will be kept in the output with the text field", - required=True, - default_value="true", - allowable_values=["true", "false"], - validators=[StandardValidators.BOOLEAN_VALIDATOR], - ) - ] - - self._relationships = [ - Relationship( - name="success", - description="All FlowFiles processed successfully." - ), - Relationship( - name="failure", - description="FlowFiles that failed processing." - ) - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - self.relationships: list[Relationship] = self._relationships - - @override - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - """ - Transforms the input FlowFile by parsing the service response and extracting relevant fields. - - Args: - context (ProcessContext): The process context containing processor properties. - flowFile (JavaObject): The FlowFile object containing the input data. - - Raises: - Exception: If any error occurs during processing. - - Returns: - FlowFileTransformResult: The result containing the transformed contents and updated attributes. - """ - - output_contents: list = [] - - try: - self.process_context: ProcessContext = context - self.set_properties(context.getProperties()) - - # read avro record - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - - records: dict | list[dict] = json.loads(input_raw_bytes.decode("utf-8")) - - if isinstance(records, dict): - records = [records] - - if self.service_message_type == "ocr": - for record in records: - result = record.get("result", {}) - - _record = {} - _record["metadata"] = result.get("metadata", {}) - _record["text"] = result.get("text", "") - _record["success"] = result.get("success", False) - _record["timestamp"] = result.get("timestamp", None) - - if "footer" in result: - for k, v in result["footer"].items(): - _record[k] = v - - output_contents.append(_record) - - elif self.service_message_type == "medcat" and "result" in records[0]: - result = records[0].get("result", []) - medcat_info = records[0].get("medcat_info", {}) - - if isinstance(result, dict): - result = [result] - - for annotated_record in result: - annotations = annotated_record.get("annotations", []) - annotations = annotations[0] if len(annotations) > 0 else annotations - footer = annotated_record.get("footer", {}) - - if self.medcat_output_mode == "deid": - _output_annotated_record = {} - _output_annotated_record["service_model"] = medcat_info - _output_annotated_record["timestamp"] = annotated_record.get("timestamp", None) - _output_annotated_record[self.output_text_field_name] = annotated_record.get("text", "") - - if self.medcat_deid_keep_annotations is True: - _output_annotated_record["annotations"] = annotations - else: - _output_annotated_record["annotations"] = {} - - for k, v in footer.items(): - _output_annotated_record[k] = v - output_contents.append(_output_annotated_record) - - else: - for annotation_id, annotation_data in annotations.items(): - _output_annotated_record = {} - _output_annotated_record["service_model"] = medcat_info - _output_annotated_record["timestamp"] = annotated_record.get("timestamp", None) - - for k, v in annotation_data.items(): - _output_annotated_record[k] = v - - for k, v in footer.items(): - _output_annotated_record[k] = v - - if self.document_id_field_name in footer: - _output_annotated_record["annotation_id"] = \ - str(footer[self.document_id_field_name]) + "_" + str(annotation_id) - - output_contents.append(_output_annotated_record) - - # add properties to flowfile attributes - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["output_text_field_name"] = str(self.output_text_field_name) - attributes["mime.type"] = "application/json" - - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps(output_contents).encode('utf-8')) - except Exception as exception: - self.logger.error("Exception during flowfile processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-python-extensions/prepare_record_for_nlp.py b/nifi/user-python-extensions/prepare_record_for_nlp.py deleted file mode 100644 index 0f36f7069..000000000 --- a/nifi/user-python-extensions/prepare_record_for_nlp.py +++ /dev/null @@ -1,124 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import io -import json -import traceback -from typing import Any, Union - -from avro.datafile import DataFileReader -from avro.io import DatumReader -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from overrides import override -from py4j.java_gateway import JavaObject, JVMView -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class PrepareRecordForNlp(BaseNiFiProcessor): - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.document_text_field_name: str = "text" - self.document_id_field_name : str = "id" - self.process_flow_file_type : str = "json" - - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="document_id_field_name", - description="id field name of the document, this will be taken from the 'footer' usually", - default_value="_id", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="document_text_field_name", - description="text field name of the document", - validators=[StandardValidators.NON_EMPTY_VALIDATOR], - required=True, - default_value="text"), - PropertyDescriptor(name="process_flow_file_type", - description="Type of flowfile input: avro | json", - default_value="json", - required=True, - allowable_values=["avro", "json"]), - - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - - @override - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - """_summary_ - - Args: - context (ProcessContext): _description_ - flowFile (JavaObject): _description_ - - Raises: - TypeError: _description_ - exception: _description_ - - Returns: - FlowFileTransformResult: _description_ - """ - - output_contents: list = [] - - try: - self.process_context = context - self.set_properties(context.getProperties()) - - self.process_flow_file_type = str(self.process_flow_file_type).lower() - - # read avro record - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - input_byte_buffer: io.BytesIO = io.BytesIO(input_raw_bytes) - - reader: Union[DataFileReader, list[dict[str, Any]] | list[Any]] - - if self.process_flow_file_type == "avro": - reader = DataFileReader(input_byte_buffer, DatumReader()) - else: - json_obj = json.loads(input_byte_buffer.read().decode("utf-8")) - reader = [json_obj] if isinstance(json_obj, dict) else json_obj if isinstance(json_obj, list) else [] - - for record in reader: - if type(record) is dict: - record_document_text = record.get(str(self.document_text_field_name), "") - else: - raise TypeError("Expected record to be a dictionary, but got: " + str(type(record))) - - output_contents.append({ - "text": record_document_text, - "footer": {k: v for k, v in record.items() if k != str(self.document_text_field_name)} - }) - - input_byte_buffer.close() - - if isinstance(reader, DataFileReader): - reader.close() - - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["document_id_field_name"] = str(self.document_id_field_name) - attributes["mime.type"] = "application/json" - - output_contents = output_contents[0] if len(output_contents) == 1 else output_contents - - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps({"content": output_contents}).encode("utf-8")) - except Exception as exception: - self.logger.error("Exception during flowfile processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-python-extensions/prepare_record_for_ocr.py b/nifi/user-python-extensions/prepare_record_for_ocr.py deleted file mode 100644 index 0e29fa77d..000000000 --- a/nifi/user-python-extensions/prepare_record_for_ocr.py +++ /dev/null @@ -1,145 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import base64 -import io -import json -import sys -import traceback -from typing import Any, Union - -from avro.datafile import DataFileReader -from avro.io import DatumReader -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from nifiapi.relationship import Relationship -from overrides import override -from py4j.java_gateway import JavaObject, JVMView -from utils.helpers.avro_json_encoder import AvroJSONEncoder -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class PrepareRecordForOcr(BaseNiFiProcessor): - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.operation_mode: str = "base64" - self.binary_field_name: str = "binarydoc" - self.output_text_field_name: str = "text" - self.document_id_field_name : str = "id" - self.process_flow_file_type: str = "json" - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="binary_field_name", - description="Avro field containing binary data", - default_value="binarydoc", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="output_text_field_name", - description="Field to store Tika output text", - default_value="text"), - PropertyDescriptor(name="operation_mode", - description="Decoding mode (e.g. base64 or raw)", - default_value="base64", - required=True, - allowable_values=["base64", "raw"]), - PropertyDescriptor(name="document_id_field_name", - description="id field name of the document, this will be taken from the 'footer' usually", - default_value="_id", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR]), - PropertyDescriptor(name="process_flow_file_type", - description="Type of flowfile input: avro | json", - default_value="json", - required=True, - allowable_values=["avro", "json"]), - ] - - self._relationships = [ - Relationship( - name="success", - description="All FlowFiles processed successfully." - ), - Relationship( - name="failure", - description="FlowFiles that failed processing." - ) - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - self.relationships: list[Relationship] = self._relationships - - @override - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - - output_contents: list = [] - - try: - self.process_context = context - self.set_properties(context.getProperties()) - - self.process_flow_file_type = str(self.process_flow_file_type).lower() - - # read avro record - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - input_byte_buffer: io.BytesIO = io.BytesIO(input_raw_bytes) - - reader: Union[DataFileReader, list[dict[str, Any]] | list[Any]] - - if self.process_flow_file_type == "avro": - reader = DataFileReader(input_byte_buffer, DatumReader()) - else: - json_obj = json.loads(input_byte_buffer.read().decode("utf-8")) - reader = [json_obj] if isinstance(json_obj, dict) else json_obj if isinstance(json_obj, list) else [] - - for record in reader: - if type(record) is dict: - record_document_binary_data = record.get(str(self.binary_field_name), None) - if record_document_binary_data is not None: - if self.operation_mode == "base64": - record_document_binary_data = base64.b64encode(record_document_binary_data).decode() - else: - self.logger.info("No binary data found in record, using empty content") - else: - raise TypeError("Expected record to be a dictionary, but got: " + str(type(record))) - - output_contents.append({ - "binary_data": record_document_binary_data, - "footer": {k: v for k, v in record.items() if k != str(self.binary_field_name)} - }) - - input_byte_buffer.close() - - if isinstance(reader, DataFileReader): - reader.close() - - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["document_id_field_name"] = str(self.document_id_field_name) - attributes["binary_field"] = str(self.binary_field_name) - attributes["output_text_field_name"] = str(self.output_text_field_name) - attributes["mime.type"] = "application/json" - - if self.process_flow_file_type == "avro": - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps(output_contents, cls=AvroJSONEncoder)) - else: - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps(output_contents)) - except Exception as exception: - self.logger.error("Exception during flowfile processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-python-extensions/record_add_geolocation.py b/nifi/user-python-extensions/record_add_geolocation.py deleted file mode 100644 index b29218ec6..000000000 --- a/nifi/user-python-extensions/record_add_geolocation.py +++ /dev/null @@ -1,247 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import csv -import json -import os -import shutil -import traceback -from zipfile import ZipFile - -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from overrides import override -from py4j.java_gateway import JavaObject, JVMView -from utils.generic import download_file_from_url, safe_delete_paths -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class JsonRecordAddGeolocation(BaseNiFiProcessor): - """NiFi Python processor to add geolocation data to JSON records based on postcode lookup. - We use https://www.getthedata.com/open-postcode-geo for geolocation. - The schema of the file used is available at: https://www.getthedata.com/open-postcode-geo - | Field | Possible Values - |-------------------------------|--------------------------------------------------------------------- - | postcode | [outcode][space][incode] - | status | live
terminated - | usertype | small
large - | easting | int
NULL - | northing | int
NULL - | positional_quality_indicator | int - | country | England,Wales,Scotland,Northern Ireland,Channel Islands,Isle of Man - | latitude | decimal - | longitude | decimal - | postcode_no_space | [outcode][incode] - | postcode_fixed_width_seven | *See comments* - | postcode_fixed_width_eight | *See comments* - | postcode_area | [A-Z]{1,2} - | postcode_district | [outcode] - | postcode_sector | [outcode][space][number] - | outcode | [outcode] - | incode | [incode] - - """ - - class Java: - implements = ["org.apache.nifi.python.processor.FlowFileTransform"] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.lookup_datafile_url: str = "https://download.getthedata.com/downloads/open_postcode_geo.csv.zip" - self.lookup_datafile_path: str = "/opt/nifi/user-scripts/db/open_postcode_geo.csv" - self.postcode_field_name: str = "address_postcode" - self.geolocation_field_name: str = "address_geolocation" - - self.loaded_csv_file_rows: list[list] = [] - self.postcode_lookup_index: dict[str, int] = {} - - self._properties: list[PropertyDescriptor] = [ - PropertyDescriptor(name="lookup_datafile_url", - description="specify the URL for the geolocation lookup datafile zip", - default_value="https://download.getthedata.com/downloads/open" \ - "_postcode_geo.csv.zip", - required=True, - validators=[StandardValidators.URL_VALIDATOR]), - PropertyDescriptor(name="lookup_datafile_path", - description="specify the local path for the geolocation lookup datafile csv", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR], - default_value="/opt/nifi/user-scripts/db/open_postcode_geo.csv"), - PropertyDescriptor(name="postcode_field_name", - description="postcode field name in the records", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR], - default_value="address_postcode"), - PropertyDescriptor(name="geolocation_field_name", - description="new field to store the geolocation coords," - " if it is not present it will be created in each record", - required=True, - validators=[StandardValidators.NON_EMPTY_VALIDATOR], - default_value="address_postcode") - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - - @override - def onScheduled(self, context: ProcessContext) -> None: - """ Initializes processor resources when scheduled. - Args: - context (ProcessContext): The process context. - This argument is required by the NiFi framework. - """ - - self.logger.debug("onScheduled() called — initializing processor resources") - - if self._check_geolocation_lookup_datafile(): - with open(self.lookup_datafile_path) as csv_file: - csv_reader = csv.reader(csv_file) - self.loaded_csv_file_rows = [row for row in csv_reader] - - self.postcode_lookup_index = {val[9]: idx - for idx, val in enumerate(self.loaded_csv_file_rows)} - - def _check_geolocation_lookup_datafile(self) -> bool: - """ Downloads the geolookup csv file for UK postcodes. - - Raises: - e: file not found - - Returns: - bool: file exists or not - """ - - base_output_extract_dir_path: str = "/opt/nifi/user-scripts/db" - output_extract_dir_path: str = os.path.join(base_output_extract_dir_path, "open_postcode_geo") - output_download_path: str = os.path.join(base_output_extract_dir_path, "open_postcode_geo.zip") - datafile_csv_initial_path: str = os.path.join(output_extract_dir_path, "open_postcode_geo.csv") - file_found: bool = False - - if os.path.exists(self.lookup_datafile_path): - self.logger.info(f"geolocation lookup datafile already exists at {self.lookup_datafile_path}") - file_found = True - else: - try: - if os.path.exists(output_download_path) is False and os.path.isfile(self.lookup_datafile_path) is False: - download_file_from_url(self.lookup_datafile_url, output_download_path, ssl_verify=False) - self.logger.debug(f"downloaded geolocation lookup datafile to {self.lookup_datafile_path}") - - if output_download_path.endswith('.zip'): - with ZipFile(output_download_path, 'r') as zip_ref: - zip_ref.extractall(output_extract_dir_path) - self.logger.debug(f"extracted geolocation lookup datafile to {output_extract_dir_path}") - else: - self.logger.debug(f"file {self.lookup_datafile_path} already exists.... skipping download") - - if os.path.exists(datafile_csv_initial_path) and datafile_csv_initial_path != self.lookup_datafile_path: - self.logger.debug(f"geolocation lookup datafile found at {self.lookup_datafile_path} \ - after extraction") - shutil.copy2(datafile_csv_initial_path, self.lookup_datafile_path) - self.logger.debug(f"copied geolocation lookup datafile to {self.lookup_datafile_path}") - file_found = True - - except Exception as e: - self.logger.error(f"failed to download geolocation lookup datafile: {str(e)}") - traceback.print_exc() - raise e - - # cleanup downloaded files - safe_delete_paths([output_download_path, output_extract_dir_path]) - - return file_found - - @override - def transform(self, context: ProcessContext, flowFile: JavaObject) -> list[FlowFileTransformResult]: - """ Transforms the input FlowFile by adding geolocation data based on postcode lookup. - Args: - context (ProcessContext): The process context. - flowFile (JavaObject): The input FlowFile to be transformed. - Returns: - FlowFileTransformResult: The result of the transformation, including updated attributes and contents. - Raises: - Exception: If any error occurs during processing. - - NOTE: the input json should be small enough to fit into memory otherwise it might cause memory issues, - keep it < 20MB, under 20k records (depending on record size). - Use SplitRecord processor to split large files into smaller chunks before processing. - """ - - try: - self.process_context: ProcessContext = context - self.set_properties(context.getProperties()) - - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - - records: dict | list[dict] = json.loads(input_raw_bytes.decode("utf-8")) - - valid_records: list[dict] = [] - error_records: list[dict] = [] - - if isinstance(records, dict): - records = [records] - - if self.postcode_lookup_index: - for record in records: - if self.postcode_field_name in record: - _postcode = str(record[self.postcode_field_name]).replace(" ", "") - _data_col_row_idx = self.postcode_lookup_index.get(_postcode, -1) - - if _data_col_row_idx != -1: - _selected_row = self.loaded_csv_file_rows[_data_col_row_idx] - _lat, _long = str(_selected_row[7]).strip(), str(_selected_row[8]).strip() - try: - record[self.geolocation_field_name] = { - "lat": float(_lat), - "lon": float(_long) - } - except ValueError: - self.logger.debug(f"invalid lat/long values for postcode {_postcode}: {_lat}, {_long}") - error_records.append(record) - valid_records.append(record) - else: - raise FileNotFoundError("geolocation lookup datafile is not available and data was not loaded, " \ - "please check URLs") - - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["mime.type"] = "application/json" - - results: list[FlowFileTransformResult] = [] - - if valid_records: - results.append( - FlowFileTransformResult( - relationship="success", - attributes=attributes, - contents=json.dumps(valid_records).encode("utf-8"), - ) - ) - - if error_records: - error_attrs = attributes.copy() - error_attrs["record.count.errors"] = str(len(error_records)) - results.append( - FlowFileTransformResult( - relationship="failure", - attributes=error_attrs, - contents=json.dumps(error_records).encode("utf-8"), - ) - ) - - return results - except Exception: - self.logger.error("Exception during flowfile processing:\n" + traceback.format_exc()) - return [ - FlowFileTransformResult( - relationship="failure", - contents=flowFile.getContentsAsBytes(), - attributes={"exception": "unhandled processing error"}, - ) - ] diff --git a/nifi/user-python-extensions/record_decompress_cerner_blob.py b/nifi/user-python-extensions/record_decompress_cerner_blob.py deleted file mode 100644 index b94bd05bd..000000000 --- a/nifi/user-python-extensions/record_decompress_cerner_blob.py +++ /dev/null @@ -1,193 +0,0 @@ -import sys - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import base64 -import json -import sys -import traceback - -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from py4j.java_gateway import JavaObject, JVMView -from utils.cerner_blob import DecompressLzwCernerBlob -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class JsonRecordDecompressCernerBlob(BaseNiFiProcessor): - """ This script decompresses Cerner LZW compressed blobs from a JSON input stream. - It expects a JSON array of records, each containing a field with the binary data. - All RECORDS are expected to have the same fields, and presumably belonging to the same DOCUMENT. - All the fields of these records should have the same field values, except for the binary data field. - The binary data field is expected to be a base64 encoded string, which will be concatenated according to - the blob_sequence_order_field_name field, preserving the order of the blobs and composing - the whole document (supposedly). - The final base64 encoded string will be decoded back to binary data, then decompressed using LZW algorithm. - - """ - - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - super().__init__(jvm) - - self.operation_mode: str = "base64" - self.binary_field_name: str = "binarydoc" - self.output_text_field_name: str = "text" - self.document_id_field_name: str = "id" - self.input_charset = "utf-8" - self.output_charset = "utf-8" - self.output_mode = "base64" - self.binary_field_source_encoding = "base64" - self.blob_sequence_order_field_name = "blob_sequence_num" - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="binary_field_name", - description="Avro field containing binary data", - default_value="binarydoc", - required=True, - validators=StandardValidators.NON_EMPTY_VALIDATOR), - PropertyDescriptor(name="output_text_field_name", - description="Field to store output text", - default_value="text", - required=True), - PropertyDescriptor(name="operation_mode", - description="Decoding mode (e.g. base64 or raw)", - default_value="base64", - allowable_values=["base64", "raw"], - required=True), - PropertyDescriptor(name="document_id_field_name", - description="Field name containing document ID", - default_value="id",), - PropertyDescriptor(name="input_charset", - description="Input character set encoding", - default_value="utf-8", - required=True), - PropertyDescriptor(name="output_charset", - description="Output character set encoding", - default_value="utf-8", - required=True), - PropertyDescriptor(name="output_mode", - description="Output encoding mode (e.g. base64 or raw)", - default_value="base64", - required=True, - allowable_values=["base64", "raw"]), - PropertyDescriptor(name="binary_field_source_encoding", - description="Binary field source encoding (e.g. base64 or raw)", - default_value="base64", - required=True, - allowable_values=["base64", "raw"]), - PropertyDescriptor(name="blob_sequence_order_field_name", - description="Blob sequence order field name, \ - if the blob is split across multiple records", - required=False, - default_value="blob_sequence_num"), - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - """ - Transforms the input FlowFile by decompressing Cerner blob data from JSON records. - - Args: - context (ProcessContext): The process context containing processor properties. - flowFile (JavaObject): The incoming FlowFile containing JSON records. - - Returns: - FlowFileTransformResult: The result of the transformation, including updated attributes and contents. - - Raises: - Exception: If any error occurs during processing or decompression. - """ - - output_contents: list = [] - - try: - self.process_context = context - self.set_properties(context.getProperties()) - - # read avro record - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - - records = [] - - try: - records = json.loads(input_raw_bytes.decode()) - except json.JSONDecodeError as e: - self.logger.error(f"Error decoding JSON: {str(e)} \nAttempting to decode as {self.input_charset}") - try: - records = json.loads(input_raw_bytes.decode(self.input_charset)) - except json.JSONDecodeError as e: - self.logger.error(f"Error decoding JSON: {str(e)} \nAttempting to decode as windows-1252") - try: - records = json.loads(input_raw_bytes.decode("windows-1252")) - except json.JSONDecodeError as e: - self.logger.error(f"Error decoding JSON: {str(e)} \n with windows-1252") - raise - - if not isinstance(records, list): - records = [records] - - concatenated_blob_sequence_order = {} - output_merged_record = {} - - for record in records: - if self.blob_sequence_order_field_name in record: - concatenated_blob_sequence_order[int(record[self.blob_sequence_order_field_name])] = \ - record[self.binary_field_name] - - # take fields from the first record, doesn't matter which one, - # as they are expected to be the same except for the binary data field - for k, v in records[0].items(): - if k not in output_merged_record and k != self.binary_field_name: - output_merged_record[k] = v - - output_merged_record[self.binary_field_name] = b"" - full_compressed_blob = bytearray() - - for k in sorted(concatenated_blob_sequence_order.keys()): - v = concatenated_blob_sequence_order[k] - try: - temporary_blob = v - if self.binary_field_source_encoding == "base64": - temporary_blob = base64.b64decode(temporary_blob) - full_compressed_blob.extend(temporary_blob) - except Exception as e: - self.logger.error(f"Error decoding b64 blob part {k}: {str(e)}") - - try: - decompress_blob = DecompressLzwCernerBlob() - decompress_blob.decompress(full_compressed_blob) - output_merged_record[self.binary_field_name] = decompress_blob.output_stream - except Exception as exception: - self.logger.error(f"Error decompressing cerner blob: {str(exception)} \n") - - if self.output_mode == "base64": - output_merged_record[self.binary_field_name] = \ - base64.b64encode(output_merged_record[self.binary_field_name]).decode(self.output_charset) - - output_contents.append(output_merged_record) - - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["document_id_field_name"] = str(self.document_id_field_name) - attributes["binary_field"] = str(self.binary_field_name) - attributes["output_text_field_name"] = str(self.output_text_field_name) - attributes["mime.type"] = "application/json" - - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps(output_contents)) - except Exception as exception: - self.logger.error("Exception during flowfile processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-python-extensions/sample_processor.py b/nifi/user-python-extensions/sample_processor.py deleted file mode 100644 index ddee7e475..000000000 --- a/nifi/user-python-extensions/sample_processor.py +++ /dev/null @@ -1,166 +0,0 @@ -import sys -from typing import Any - -sys.path.insert(0, "/opt/nifi/user-scripts") - -import io -import json -import traceback -from logging import Logger - -from avro.datafile import DataFileReader, DataFileWriter -from avro.io import DatumReader, DatumWriter -from avro.schema import Schema -from nifiapi.flowfiletransform import FlowFileTransformResult -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from nifiapi.relationship import Relationship -from py4j.java_gateway import JavaObject, JVMView -from utils.helpers.base_nifi_processor import BaseNiFiProcessor - - -class SampleTestProcessor(BaseNiFiProcessor): - - class Java: - implements = ['org.apache.nifi.python.processor.FlowFileTransform'] - - class ProcessorDetails: - version = '0.0.1' - - def __init__(self, jvm: JVMView): - """ - Args: - jvm (JVMView): Required, Store if you need to use Java classes later. - """ - super().__init__(jvm) - - - # Example properties — meant to be overridden or extended in subclasses - self.sample_property_one: bool = True - self.sample_property_two: str = "" - self.sample_property_three: str = "default_value_one" - - # this is directly mirrored to the UI - self._properties = [ - PropertyDescriptor(name="sample_property_one", - description="sample property one description", - default_value="true", - required=True, - validators=StandardValidators.BOOLEAN_VALIDATOR), - PropertyDescriptor(name="sample_property_two", - description="sample property two description", - required=False, - default_value="some_value"), - PropertyDescriptor(name="sample_property_three", - required=True, - description="sample property three description", - default_value="default_value_one", - allowable_values=["default_value_one", "default_value_two", "default_value_three"], - validators=StandardValidators.NON_EMPTY_VALIDATOR) - ] - - self._relationships = [ - Relationship( - name="success", - description="All FlowFiles processed successfully." - ), - Relationship( - name="failure", - description="FlowFiles that failed processing." - ) - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - self.relationships: list[Relationship] = self._relationships - - def onScheduled(self, context: ProcessContext) -> None: - """ - Called automatically by NiFi once when the processor is scheduled to run - (i.e., enabled or started). This method is used for initializing and - allocating resources that should persist across multiple FlowFile - executions. - - Typical use cases include: - - Loading static data from disk (e.g., CSV lookup tables, configuration files) - - Establishing external connections (e.g., databases, APIs) - - Building in-memory caches or models used by onTrigger/transform() - - The resources created here remain in memory for the lifetime of the - processor and are shared by all concurrent FlowFile executions on this - node. They should be lightweight and thread-safe. To release or clean up - such resources, use the @OnStopped method, which NiFi calls when the - processor is disabled or stopped. - """ - pass - - def transform(self, context: ProcessContext, flowFile: JavaObject) -> FlowFileTransformResult: - - """ - NOTE: This is a sample method meant to be overridden and reimplemented by subclasses. - - Main processor logic. This example reads Avro records from the incoming flowfile, - and writes them back out to a new flowfile. It also adds the processor properties - to the flowfile attributes. IT IS A SAMPLE ONLY, - you are meant to use this as a starting point for other processors - - Args: - context (ProcessContext): The process context containing processor properties. - flowFile (JavaObject): The FlowFile object containing the input data. - - Raises: - Exception: If any error occurs during processing. - - Returns: - FlowFileTransformResult: The result containing the transformed contents and updated attributes. - """ - - output_contents: list[Any] = [] - - try: - self.process_context: ProcessContext = context - self.set_properties(context.getProperties()) - # add properties to flowfile attributes - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - self.logger.info("Successfully transformed Avro content for OCR") - - input_raw_bytes: bytes = flowFile.getContentsAsBytes() - - # read avro record - self.logger.debug("Reading flowfile content as bytes") - input_byte_buffer: io.BytesIO = io.BytesIO(input_raw_bytes) - reader: DataFileReader = DataFileReader(input_byte_buffer, DatumReader()) - - # below is an example of how to handle avro records, each record - schema: Schema | None = reader.datum_reader.writers_schema - - for record in reader: - #do stuff - pass - - # streams need to be closed - input_byte_buffer.close() - reader.close() - - # Write them to a binary avro stre - output_byte_buffer = io.BytesIO() - writer = DataFileWriter(output_byte_buffer, DatumWriter(), schema) - - writer.flush() - writer.close() - output_byte_buffer.seek(0) - - # add properties to flowfile attributes - attributes: dict = {k: str(v) for k, v in flowFile.getAttributes().items()} - attributes["sample_property_one"] = str(self.sample_property_one) - attributes["sample_property_two"] = str(self.sample_property_two) - attributes["sample_property_three"] = str(self.sample_property_three) - - return FlowFileTransformResult(relationship="success", - attributes=attributes, - contents=json.dumps(output_contents)) - except Exception as exception: - self.logger.error("Exception during Avro processing: " + traceback.format_exc()) - raise exception diff --git a/nifi/user-schemas/elasticsearch/base_index_settings.json b/nifi/user-schemas/elasticsearch/base_index_settings.json deleted file mode 100644 index 9f8a2a59a..000000000 --- a/nifi/user-schemas/elasticsearch/base_index_settings.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "settings": { - "index": { - "number_of_shards": 5, - "number_of_replicas": 1, - "refresh_interval": "60s" - } - }, - "mappings": { - "date_detection": true, - "dynamic_date_formats": [ - "yyyy-MM-dd HH:mm:ss", - "yyyy-MM-dd", - "basic_date", - "date_hour", - "date_hour_minute", - "date_hour_minute_second", - "time", - "hour_minute", - "yyyy/MM/dd", - "dd/MM/yyyy", - "dd/MM/yyyy HH:mm", - "date_time", - "t_time", - "date_hour_minute_second_millis", - "basic_time", - "basic_time_no_millis", - "basic_t_time", - "hour_minute_second", - "HH:mm.ss", - "HH:mm.ssZ" - ], - "dynamic_templates": [ - { - "dates": { - "match_mapping_type": "string", - "match_pattern": "regex", - "match": "(?i).*(date|_datetime|_dt|_dttm|_dat|_datime|_time|_ts|_timestamp|time|when|dt|dttm|timestamp|created|updated|modified|inserted|recorded|logged|entered|performed|signed|cosigned|completed|admit|discharge|visit|appointment|service|start|end|effective|expiry|validfrom|validto|close)$", - "mapping": { - "type": "date", - "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis||basic_date||date_hour_minute_second" - } - } - }, - { - "strings_as_text": { - "match_mapping_type": "string", - "mapping": { - "type": "text", - "analyzer": "standard", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - ], - "properties": { - "id": { "type": "keyword" } - } - } -} diff --git a/nifi/user-schemas/elasticsearch/indices/.keep b/nifi/user-schemas/elasticsearch/indices/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/nifi/user-schemas/elasticsearch/templates/.keep b/nifi/user-schemas/elasticsearch/templates/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/nifi/user-schemas/json/.keep b/nifi/user-schemas/json/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/nifi/user-schemas/legacy/annotation-medcat.avsc b/nifi/user-schemas/legacy/annotation-medcat.avsc deleted file mode 100644 index 1d2b6376f..000000000 --- a/nifi/user-schemas/legacy/annotation-medcat.avsc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "type": "record", - "name": "nifiRecord", - "namespace":"org.apache.nifi", - "fields": - [ - { "name": "doc_id", "type": "int" }, - { "name": "processing_timestamp", "type": { "type" : "string", "logicalType" : "timestamp-millis" } }, - - { "name": "ent_id", "type": "int" }, - { "name": "cui", "type": ["null", "string"], "default": null }, - { "name": "tui", "type": ["null", "string"], "default": null }, - { "name": "start_idx", "type": "int", "default": 0 }, - { "name": "end_idx", "type": "int", "default": 0 }, - { "name": "source_value", "type": "string", "default": "" }, - { "name": "type", "type": ["null", "string"], "default": null }, - { "name": "acc", "type": "float", "default": 0.0 }, - - { "name": "icd10", "type": ["null", "string"], "default": null }, - { "name": "umls", "type": ["null", "string"], "default": null }, - { "name": "snomed", "type": ["null", "string"], "default": null }, - { "name": "pretty_name", "type": ["null", "string"], "default": null } - ] -} \ No newline at end of file diff --git a/nifi/user-schemas/legacy/annotation_elasticsearch_index_mapping.json b/nifi/user-schemas/legacy/annotation_elasticsearch_index_mapping.json deleted file mode 100644 index 32a13c110..000000000 --- a/nifi/user-schemas/legacy/annotation_elasticsearch_index_mapping.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "mappings": { - "properties": { - "document_annotation_id": { - "type": "keyword" - }, - "meta": { - "properties": { - "id": { - "type": "keyword" - } - } - }, - "nlp": { - "properties": { - "acc": { - "type": "float" - }, - "context_similarity": { - "type": "float" - }, - "cui": { - "type": "keyword" - }, - "detected_name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "end": { - "type": "long" - }, - "id": { - "type": "long" - }, - "meta_anns": { - "properties": { - "Presence": { - "properties": { - "confidence": { - "type": "float" - }, - "name": { - "type": "keyword" - }, - "value": { - "type": "keyword" - } - } - }, - "Subject": { - "properties": { - "confidence": { - "type": "float" - }, - "name": { - "type": "keyword" - }, - "value": { - "type": "keyword" - } - } - }, - "Time": { - "properties": { - "confidence": { - "type": "float" - }, - "name": { - "type": "keyword" - }, - "value": { - "type": "keyword" - } - } - } - } - }, - "pretty_name": { - "type": "text" - }, - "source_value": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "start": { - "type": "long" - }, - "type_ids": { - "type": "keyword" - }, - "types": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - }, - "service_service_app_name": { - "type": "keyword" - }, - "service_service_language": { - "type": "keyword" - }, - "service_service_model": { - "type": "keyword" - }, - "service_service_version": { - "type": "keyword" - } - } - }, - "settings": { - "index": { - "codec": "best_compression", - "routing": { - "allocation": { - "include": { - "_tier_preference": "data_content" - } - } - }, - "number_of_shards": "20", - "number_of_replicas": "1" - } - } - } \ No newline at end of file diff --git a/nifi/user-schemas/legacy/cogstack_common_schema.avsc b/nifi/user-schemas/legacy/cogstack_common_schema.avsc deleted file mode 100644 index 92af25eb0..000000000 --- a/nifi/user-schemas/legacy/cogstack_common_schema.avsc +++ /dev/null @@ -1,93 +0,0 @@ -{ - "type": "record", - "name": "cogstackSchemaRecord", - "namespace": "cogstack.schema", - "fields" : - [ - {"name":"activity_VisitAdmissionDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitCareLevel", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitCloseDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitCreatedBy", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitCreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitCurrentLengthOfStay", "type": ["null", "long"], "default" : null}, - {"name":"activity_VisitCurrentLocation", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitDischargeDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null }, - {"name":"activity_VisitDischargeDisposition", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitDischargeLocation", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitInternalStatus", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitNextLocation", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitNumber", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitProviderName", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitService", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitSourceId", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitStatus", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitTemporaryLocation", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitType", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitUpdatedBy", "type": ["null", "string"], "default" : null}, - {"name":"activity_VisitUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_metadata", "type": ["null", "string"], "default" : null}, - {"name":"document_AncillaryReferenceId", "type": ["null", "string"], "default" : null}, - {"name":"document_Content", "type": ["null", "string"], "default" : null}, - {"name":"document_CreatedBy", "type": ["null", "string"], "default" : null}, - {"name":"document_CreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_FacilityId", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_abnormalityCode", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_accessToken", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_arrivalDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_category", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_enteredDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_isTextual", "type": ["null", "boolean"], "default" : null}, - {"name":"document_Fields_key", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_label", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_lastModifiedComments", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_lastModifiedReason", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_mediaCode", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_media_CreateBy", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_media_CreateWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_media_UpdatedBy", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_media_UpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_referenceLowerLimit", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_referenceUpperLimit", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_resultId", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_subCategory", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_text", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_unitOfMeasure", "type": ["null", "string"], "default" : null}, - {"name":"document_Fields_valueDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_valueNum", "type": ["null", "double"], "default" : null}, - {"name":"document_Fields_valueText", "type": ["null", "string"], "default" : null}, - {"name":"document_Id", "type": ["null", "string"], "default" : null}, - {"name":"document_OrderId", "type": ["null", "string"], "default" : null}, - {"name":"document_OrderName", "type": ["null", "string"], "default" : null}, - {"name":"document_Type", "type": ["null", "long"], "default" : null}, - {"name":"document_UpdatedBy", "type": ["null", "string"], "default" : null}, - {"name":"document_UpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_sourcedb", "type": ["null", "string"], "default" : null}, - {"name":"id", "type": ["null", "string"], "default" : null}, - {"name":"patient_Address", "type": ["null", "string"], "default" : null}, - {"name":"patient_AddressCity", "type": ["null", "string"], "default" : null}, - {"name":"patient_AddressGeoLocation", "type": ["null", "string"], "default" : null}, - {"name":"patient_AddressPostalCode", "type": ["null", "string"], "default" : null}, - {"name":"patient_Age", "type": ["null", "double"], "default" : null}, - {"name":"patient_CreatedBy", "type": ["null", "string"], "default" : null}, - {"name":"patient_CreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_DateOfBirth", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_DateOfDeath", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_Ethnicity", "type": ["null", "string"], "default" : null}, - {"name":"patient_FirstName", "type": ["null", "string"], "default" : null}, - {"name":"patient_Gender", "type": ["null", "string"], "default" : null}, - {"name":"patient_HospitalNumber", "type": ["null", "string"], "default" : null}, - {"name":"patient_Language", "type": ["null", "string"], "default" : null}, - {"name":"patient_LastName", "type": ["null", "string"], "default" : null}, - {"name":"patient_MaritalStatus", "type": ["null", "string"], "default" : null}, - {"name":"patient_MiddleName", "type": ["null", "string"], "default" : null}, - {"name":"patient_NhsNumber", "type": ["null", "string"], "default" : null}, - {"name":"patient_Occupation", "type": ["null", "string"], "default" : null}, - {"name":"patient_Religion", "type": ["null", "string"], "default" : null}, - {"name":"patient_SourceId", "type": ["null", "string"], "default" : null}, - {"name":"patient_Title", "type": ["null", "string"], "default" : null}, - {"name":"patient_UpdatedBy", "type": ["null", "string"], "default" : null}, - {"name":"patient_UpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_activity_document_identifiers", "type": ["null", "string"], "default" : null}, - {"name":"patient_name", "type": ["null", "string"], "default" : null} - ] -} \ No newline at end of file diff --git a/nifi/user-schemas/legacy/cogstack_common_schema_elasticsearch_index_mapping_template.json b/nifi/user-schemas/legacy/cogstack_common_schema_elasticsearch_index_mapping_template.json deleted file mode 100644 index 3844caded..000000000 --- a/nifi/user-schemas/legacy/cogstack_common_schema_elasticsearch_index_mapping_template.json +++ /dev/null @@ -1,433 +0,0 @@ -{ - "mappings": { - "date_detection" : true, - "dynamic_date_formats": [ - "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis||basic_date||date_hour||date_hour_minute||date_hour_minute_second||time||hour_minute||yyyy/MM/dd||dd/MM/yyyy||dd/MM/yyyy HH:mm||date_time||t_time||date_hour_minute_second_millis||basic_time||basic_time_no_millis||basic_t_time||hour_minute_second||HH:mm.ss||HH:mm.ssZ" - ], - "dynamic_templates": [ - { - "date" : { - "mapping" : { - "type" : "date", - "format" : "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis||basic_date||date_hour||date_hour_minute||date_hour_minute_second||time||hour_minute||yyyy/MM/dd||dd/MM/yyyy||dd/MM/yyyy HH:mm||date_time||t_time||date_hour_minute_second_millis||basic_time||basic_time_no_millis||basic_t_time||hour_minute_second||HH:mm.ss||HH:mm.ssZ" - } - } - }, - { - "nested": { - "match_mapping_type": "string", - "mapping": { - "type": "text" - } - } - } - ], - "properties": { - "activity_AdmissionDate": { - "type": "date" - }, - "activity_AttendanceArrivalDate": { - "type": "date" - }, - "activity_AttendanceCreatedWhen": { - "type": "date" - }, - "activity_AttendanceUpdatedWhen": { - "type": "date" - }, - "activity_DischargeCreatedWhen": { - "type": "date" - }, - "activity_DischargeDate": { - "type": "date" - }, - "activity_DischargeUpdatedWhen": { - "type": "date" - }, - "activity_EpisodeCreatedWhen": { - "type": "date" - }, - "activity_EpisodeDischargeDate": { - "type": "date" - }, - "activity_EpisodeStartDate": { - "type": "date" - }, - "activity_EpisodeUpdatedWhen": { - "type": "date" - }, - "activity_ReferralCreatedWhen": { - "type": "date" - }, - "activity_ReferralDate": { - "type": "date" - }, - "activity_ReferralDischargeDate": { - "type": "date" - }, - "activity_ReferralUpdatedWhen": { - "type": "date" - }, - "activity_SpellAdmissionDate": { - "type": "date" - }, - "activity_SpellCreatedWhen": { - "type": "date" - }, - "activity_SpellDischargeDate": { - "type": "date" - }, - "activity_SpellUpdatedWhen": { - "type": "date" - }, - "activity_VisitCanceledDate": { - "type": "date" - }, - "activity_VisitExpectedAdmissionDate": { - "type": "date" - }, - "activity_VisitPlannedDischargeDate": { - "type": "date" - }, - "document_ActivateDateReference": { - "type": "date" - }, - "document_ArrivalDate": { - "type": "date" - }, - "document_AuthoredDate": { - "type": "date" - }, - "document_CompleteDateReference": { - "type": "date" - }, - "document_ConfirmDate": { - "type": "date" - }, - "document_Date": { - "type": "date" - }, - "document_DateAdded": { - "type": "date" - }, - "document_DateCreated": { - "type": "date" - }, - "document_EncounterDate": { - "type": "date" - }, - "document_EnteredDate": { - "type": "date" - }, - "document_Fields_recordedDate": { - "type": "date" - }, - "document_History_createdWhen": { - "type": "date" - }, - "document_LastUpdated": { - "type": "date" - }, - "document_MediaCreatedWhen": { - "type": "date" - }, - "document_MediaUpdatedWhen": { - "type": "date" - }, - "document_ModifiedDate": { - "type": "date" - }, - "document_PerformedDate": { - "type": "date" - }, - "document_RequestedDate": { - "type": "date" - }, - "document_SignificantDate": { - "type": "date" - }, - "document_StopDate": { - "type": "date" - }, - "activity_VisitAdmissionDate": { - "type": "date" - }, - "activity_VisitCareLevel": { - "type": "keyword" - }, - "activity_VisitCloseDate": { - "type": "date" - }, - "activity_VisitCreatedBy": { - "type": "keyword" - }, - "activity_VisitCreatedWhen": { - "type": "date" - }, - "activity_VisitCurrentLengthOfStay": { - "type": "integer" - }, - "activity_VisitCurrentLocation": { - "type": "keyword" - }, - "activity_VisitDischargeDate": { - "type": "date" - }, - "activity_VisitDischargeDisposition": { - "type": "keyword" - }, - "activity_VisitDischargeLocation": { - "type": "keyword" - }, - "activity_VisitInternalStatus": { - "type": "keyword" - }, - "activity_VisitNextLocation": { - "type": "keyword" - }, - "activity_VisitNumber": { - "type": "keyword" - }, - "activity_VisitProviderName": { - "type": "text" - }, - "activity_VisitService": { - "type": "keyword" - }, - "activity_VisitSourceId": { - "type": "keyword" - }, - "activity_VisitStatus": { - "type": "keyword" - }, - "activity_VisitTemporaryLocation": { - "type": "keyword" - }, - "activity_VisitType": { - "type": "keyword" - }, - "activity_VisitUpdatedBy": { - "type": "keyword" - }, - "activity_VisitUpdatedWhen": { - "type": "date" - }, - "activity_VisitDurationArrivalToInitialAssessment" : {"type": "date"}, - "activity_VisitDurationArrivalToInitialDoctorSeen" : {"type": "date"}, - "activity_VisitDurationInitialDoctorSeenToDeparture" : {"type": "date"}, - "activity_VisitDurationArrivalToDeparture" : {"type": "date"}, - "activity_VisitPriority" : {"type": "text"}, - "activity_TestSampleCollectionTime" : {"type": "date"}, - "activity_TestSampleReceivedTime" : {"type": "date"}, - "activity_TestInitialReportedDate" : {"type": "date"}, - "activity_VisitPointOfCareType" : {"type": "text"}, - "activity_VisitPointOfCarePurpose" : {"type": "text"}, - "activity_VisitProviderType" : {"type": "text"}, - "activity_VisitCancelReason" : {"type": "text"}, - "activity_VisitCancelGroup" : {"type": "text"}, - "activity_VisitDuration" : {"type": "date"}, - "activity_metadata": { - "type": "text" - }, - "document_AncillaryReferenceId": { - "type": "keyword" - }, - "document_Content": { - "type": "text" - }, - "document_CreatedBy": { - "type": "keyword" - }, - "document_CreatedWhen": { - "type": "date" - }, - "document_FacilityId": { - "type": "keyword" - }, - "document_Fields_abnormalityCode": { - "type": "keyword" - }, - "document_Fields_accessToken": { - "type": "keyword" - }, - "document_Fields_arrivalDate": { - "type": "date" - }, - "document_Fields_category": { - "type": "keyword" - }, - "document_Fields_enteredDate": { - "type": "date" - }, - "document_Fields_isTextual": { - "type": "boolean" - }, - "document_Fields_key": { - "type": "keyword" - }, - "document_Fields_label": { - "type": "text" - }, - "document_Fields_lastModifiedComments": { - "type": "text" - }, - "document_Fields_lastModifiedReason": { - "type": "text" - }, - "document_Fields_mediaCode": { - "type": "keyword" - }, - "document_Fields_media_CreateBy": { - "type": "keyword" - }, - "document_Fields_media_CreateWhen": { - "type": "date" - }, - "document_Fields_media_UpdatedBy": { - "type": "keyword" - }, - "document_Fields_media_UpdatedWhen": { - "type": "date" - }, - "document_Fields_referenceLowerLimit": { - "type": "keyword" - }, - "document_Fields_referenceUpperLimit": { - "type": "keyword" - }, - "document_Fields_resultId": { - "type": "keyword" - }, - "document_Fields_subCategory": { - "type": "keyword" - }, - "document_Fields_text": { - "type": "text" - }, - "document_Fields_unitOfMeasure": { - "type": "keyword" - }, - "document_Fields_valueDate": { - "type": "date" - }, - "document_Fields_valueNum": { - "type": "scaled_float", - "scaling_factor": 100 - }, - "document_Fields_valueText": { - "type": "text" - }, - "document_Fields": { - "type": "nested", - "properties": { - "fields" : { - "type" : "text" - } - } - }, - "document_Id": { - "type": "keyword" - }, - "document_OrderId": { - "type": "keyword" - }, - "document_OrderName": { - "type": "text" - }, - "document_Type": { - "type": "keyword" - }, - "document_UpdatedBy": { - "type": "keyword" - }, - "document_UpdatedWhen": { - "type": "date" - }, - "document_sourcedb": { - "type": "keyword" - }, - "id": { - "type": "keyword" - }, - "patient_Address": { - "type": "text" - }, - "patient_AddressCity": { - "type": "text" - }, - "patient_AddressGeoLocation": { - "type": "geo_point" - }, - "patient_AddressPostalCode": { - "type": "text" - }, - "patient_Age": { - "type": "scaled_float", - "scaling_factor": 100 - }, - "patient_CreatedBy": { - "type": "keyword" - }, - "patient_CreatedWhen": { - "type": "date" - }, - "patient_DateOfBirth": { - "type": "date" - }, - "patient_DateOfDeath": { - "type": "date" - }, - "patient_Ethnicity": { - "type": "keyword" - }, - "patient_FirstName": { - "type": "text" - }, - "patient_Gender": { - "type": "keyword" - }, - "patient_HospitalNumber": { - "type": "keyword" - }, - "patient_Language": { - "type": "keyword" - }, - "patient_LastName": { - "type": "text" - }, - "patient_MaritalStatus": { - "type": "keyword" - }, - "patient_MiddleName": { - "type": "text" - }, - "patient_NhsNumber": { - "type": "keyword" - }, - "patient_Occupation": { - "type": "keyword" - }, - "patient_Religion": { - "type": "keyword" - }, - "patient_SourceId": { - "type": "keyword" - }, - "patient_Title": { - "type": "keyword" - }, - "patient_UpdatedBy": { - "type": "keyword" - }, - "patient_UpdatedWhen": { - "type": "date" - }, - "patient_activity_document_identifiers": { - "type": "text" - }, - "patient_name": { - "type": "text" - } - } - } -} diff --git a/nifi/user-schemas/legacy/cogstack_common_schema_full.avsc b/nifi/user-schemas/legacy/cogstack_common_schema_full.avsc deleted file mode 100644 index 79fd600a7..000000000 --- a/nifi/user-schemas/legacy/cogstack_common_schema_full.avsc +++ /dev/null @@ -1,330 +0,0 @@ -{ - "type": "record", - "name": "cogstackSchemaRecord", - "namespace": "cogstack.schema", - "fields" : - [ - {"name":"activity_AdmissionDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null }, - {"name":"activity_AttendanceArrivalDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_AttendanceCareGroup", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceChiefComplaint", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceCreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_AttendanceCurrentLengthOfStay", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceCurrentLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceNumber", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceReason", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceReferralSpecialty", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceRegistrationComplaint", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceSourceId", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceSourceOfReferral", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceStage", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceType", "type": ["null", "string"], "default": null}, - {"name":"activity_AttendanceUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_DischargeCreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_DischargeDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_DischargeDestination", "type": ["null", "string"], "default": null}, - {"name":"activity_DischargeLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_DischargeOutcome", "type": ["null", "string"], "default": null}, - {"name":"activity_DischargeUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_EpisodeCreatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeCreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_EpisodeCurrentLengthOfStay", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeDischargeDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_EpisodeDischargeMethod", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeId", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeNumber", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodePriority", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeSourceId", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeStartDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_EpisodeType", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeUpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_EpisodeUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_metadata", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralAdminCategory", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralAdminPriority", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralClosureReason", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralCreatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralCreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_ReferralDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_ReferralDischargeDate", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralFormat", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralGPCode", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralId", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralPracticeCode", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralPresentingReason", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralPriority", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralReason", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralService", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralSource", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralSourceType", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralStatus", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralUpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferralUpdatedWhen","type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_ReferrerCCGCode", "type": ["null", "string"], "default": null}, - {"name":"activity_ReferrerCCGName", "type": ["null", "string"], "default": null}, - {"name":"activity_SpecialtyName", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellAdmissionDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_SpellCreatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellCreatedWhen","type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_SpellCurrentLengthOfStay", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellDischargeDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_SpellDischargeDestination", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellDischargeDetail", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellDischargeMethod", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellId", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellNumber", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellStatus", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellUpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_SpellUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitAdmissionDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitAdmissionReason", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitAdmissionSource", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitAdmissionType", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCanceledDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitCareLevel", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCareProviderConsultant", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCareProviderId", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCareProviderType", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCloseDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitCreatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCreatedWhen", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCurrentLengthOfStay", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCurrentLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitDischargeDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitDischargeDisposition", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitDischargeLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitExpectedAdmissionDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitFacility", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitInternalStatus", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitLocationType", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitNextLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitNumber", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitPlannedDischargeDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitPointOfCare", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitPreadmissionNumber", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitProviderName", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitService", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitSourceId", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitSpecialtyCanonicalName", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitSpecialtyCode", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitSpecialtyId", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitSpecialtyName", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitStatus", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitTemporaryLocation", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitType", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitUpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"activity_VisitDurationArrivalToInitialAssessment", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}, "string"], "default": null }, - {"name":"activity_VisitDurationArrivalToInitialDoctorSeen", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}, "string"], "default": null }, - {"name":"activity_VisitDurationInitialDoctorSeenToDeparture", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}, "string"], "default": null }, - {"name":"activity_VisitDurationArrivalToDeparture", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}], "default": null }, - {"name":"activity_VisitPriority", "type": ["null", "string"], "default": null}, - {"name":"activity_TestSampleCollectionTime", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}, "string"], "default": null }, - {"name":"activity_TestSampleReceivedTime", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}, "string"], "default": null }, - {"name":"activity_TestInitialReportedDate", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}, "string"], "default": null }, - {"name":"activity_VisitPointOfCareType", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitPointOfCarePurpose", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitProviderType", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCancelReason", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitCancelGroup", "type": ["null", "string"], "default": null}, - {"name":"activity_VisitDuration", "type":["null", {"type":"long", "logicalType": "timestamp-millis"}], "default": null }, - {"name":"document_AccessToken", "type": ["null", "string"], "default": null}, - {"name":"document_ActivateDateReference", "type": ["null", "string"], "default": null}, - {"name":"document_ActivateDaysBefore", "type": ["null", "string"], "default": null}, - {"name":"document_ActivateHoursBefore", "type": ["null", "string"], "default": null}, - {"name":"document_ActivateStatus", "type": ["null", "string"], "default": null}, - {"name":"document_Allergen", "type": ["null", "string"], "default": null}, - {"name":"document_AllergyReaction", "type": ["null", "string"], "default": null}, - {"name":"document_AncillaryReferenceId", "type": ["null", "string"], "default": null}, - {"name":"document_ArrivalDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Author", "type": ["null", "string"], "default": null}, - {"name":"document_AuthoredDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_AuthorisorName", "type": ["null", "string"], "default": null}, - {"name":"document_CareProviderName", "type": ["null", "string"], "default": null}, - {"name":"document_CareProviderType", "type": ["null", "string"], "default": null}, - {"name":"document_Category", "type": ["null", "string"], "default": null}, - {"name":"document_CnId", "type": ["null", "string"], "default": null}, - {"name":"document_Comment", "type": ["null", "string"], "default": null}, - {"name":"document_CompleteDateReference", "type": ["null", "string"], "default": null}, - {"name":"document_CompleteDaysAfter", "type": ["null", "string"], "default": null}, - {"name":"document_CompleteHoursAfter", "type": ["null", "string"], "default": null}, - {"name":"document_CompleteStatus", "type": ["null", "string"], "default": null}, - {"name":"document_Conditions", "type": ["null", "string"], "default": null}, - {"name":"document_ConfidenceLevel", "type": ["null", "string"], "default": null}, - {"name":"document_ConfirmDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_ConfirmedBy", "type": ["null", "string"], "default": null}, - {"name":"document_ConfirmStaffJobTitle", "type": ["null", "string"], "default": null}, - {"name":"document_ConfirmStaffName", "type": ["null", "string"], "default": null}, - {"name":"document_Content", "type": ["null", "string"], "default": null}, - {"name":"document_ContentType", "type": ["null", "string"], "default": null}, - {"name":"document_CreatedBy", "type": ["null", "string"], "default": null}, - {"name":"document_CreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Date", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_DateAdded", "type": ["null", "string"], "default": null}, - {"name":"document_DateCreated", "type": ["null", "string"], "default": null}, - {"name":"document_Description", "type": ["null", "string"], "default": null}, - {"name":"document_Duration", "type": ["null", "string"], "default": null}, - {"name":"document_EdlId", "type": ["null", "string"], "default": null}, - {"name":"document_EncounterDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_EnteredDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_EnteredName", "type": ["null", "string"], "default": null}, - {"name":"document_EnteredOccupation", "type": ["null", "string"], "default": null}, - {"name":"document_EnteredRole", "type": ["null", "string"], "default": null}, - {"name":"document_EntryType", "type": ["null", "string"], "default": null}, - {"name":"document_Extension", "type": ["null", "string"], "default": null}, - {"name":"document_FacilityId", "type": ["null", "string"], "default": null}, - {"name":"document_Fields", "type": {"type": "map", "values" : "string"}, "default": {}}, - {"name":"document_Fields_abnormalityCode", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_accessToken", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_arrivalDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_category", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_comment", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_dataType", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_dosage", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_duration", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_enteredDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_frequency", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_hasNumericEquivalent", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_historySequenceNumber", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_includeInTotals", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_indication", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_isClientCharacteristic", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_isTextual", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_itemHeader", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_key", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_label", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_lastModifiedComments", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_lastModifiedReason", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_masterEntryName", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_maxDose", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_media_CreateBy", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_media_CreateWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_media_UpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_media_UpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_mediaCode", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_minDose", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_observationId", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_physicalNoteType", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_recordedDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_referenceLowerLimit", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_referenceUpperLimit", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_resultId", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_route", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_status", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_statusReason", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_subCategory", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_text", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_type", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_unitOfMeasure", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_valueDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Fields_valueNum", "type": ["null", "string"], "default": null}, - {"name":"document_Fields_valueText", "type": ["null", "string"], "default": null}, - {"name":"document_FileName", "type": ["null", "string"], "default": null}, - {"name":"document_FillerDocumentId", "type": ["null", "string"], "default": null}, - {"name":"document_FillerFacilityId", "type": ["null", "string"], "default": null}, - {"name":"document_Format", "type": ["null", "string"], "default": null}, - {"name":"document_Frequency", "type": ["null", "string"], "default": null}, - {"name":"document_HasBeenModified", "type": ["null", "string"], "default": null}, - {"name":"document_History_createdBy", "type": ["null", "string"], "default": null}, - {"name":"document_History_createdWhen", "type": ["null", "string"], "default": null}, - {"name":"document_History_groupCode", "type": ["null", "string"], "default": null}, - {"name":"document_History_key", "type": ["null", "string"], "default": null}, - {"name":"document_History_label", "type": ["null", "string"], "default": null}, - {"name":"document_History_valueText", "type": ["null", "string"], "default": null}, - {"name":"document_HoldReason", "type": ["null", "string"], "default": null}, - {"name":"document_Id", "type": ["null", "string"], "default": null}, - {"name":"document_InformationSource", "type": ["null", "string"], "default": null}, - {"name":"document_IsPartOfSet", "type": ["null", "string"], "default": null}, - {"name":"document_IsPRN", "type": ["null", "string"], "default": null}, - {"name":"document_KTreeItemId", "type": ["null", "string"], "default": null}, - {"name":"document_KTreeItemName", "type": ["null", "string"], "default": null}, - {"name":"document_KTreeRootId", "type": ["null", "string"], "default": null}, - {"name":"document_LastUpdated", "type": ["null", "string"], "default": null}, - {"name":"document_LetterMakerId", "type": ["null", "string"], "default": null}, - {"name":"document_MediaCode", "type": ["null", "string"], "default": null}, - {"name":"document_MediaCreatedBy", "type": ["null", "string"], "default": null}, - {"name":"document_MediaCreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_MediaUpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"document_MediaUpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_MinimumStatusForActivation", "type": ["null", "string"], "default": null}, - {"name":"document_MinimumStatusForCompletion", "type": ["null", "string"], "default": null}, - {"name":"document_ModifiedDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_ModifiedName", "type": ["null", "string"], "default": null}, - {"name":"document_ModifiedOccupation", "type": ["null", "string"], "default": null}, - {"name":"document_OrderEntryFormId", "type": ["null", "string"], "default": null}, - {"name":"document_OrderEntryFormTitle", "type": ["null", "string"], "default": null}, - {"name":"document_OrderId", "type": ["null", "string"], "default": null}, - {"name":"document_OrderName", "type": ["null", "string"], "default": null}, - {"name":"document_OrderPriority", "type": ["null", "string"], "default": null}, - {"name":"document_OrderSetHeading", "type": ["null", "string"], "default": null}, - {"name":"document_OrderSetName", "type": ["null", "string"], "default": null}, - {"name":"document_OrderSetType", "type": ["null", "string"], "default": null}, - {"name":"document_OrderStatus", "type": ["null", "string"], "default": null}, - {"name":"document_OrderStatusLevelNumber", "type": ["null", "string"], "default": null}, - {"name":"document_OrderUserData", "type": ["null", "string"], "default": null}, - {"name":"document_OrganizationalUnit", "type": ["null", "string"], "default": null}, - {"name":"document_PerformedDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_ProcedureId", "type": ["null", "string"], "default": null}, - {"name":"document_Reference", "type": ["null", "string"], "default": null}, - {"name":"document_RepeatOrder", "type": ["null", "string"], "default": null}, - {"name":"document_ReqCodedTime", "type": ["null", "string"], "default": null}, - {"name":"document_RequestedDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_ReviewMasterCategory", "type": ["null", "string"], "default": null}, - {"name":"document_ReviewSubCategory", "type": ["null", "string"], "default": null}, - {"name":"document_SequenceNumber", "type": ["null", "string"], "default": null}, - {"name":"document_SignificantDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_sourcedb", "type": ["null", "string"], "default": null}, - {"name":"document_SourceId", "type": ["null", "string"], "default": null}, - {"name":"document_SourceSelector", "type": ["null", "string"], "default": null}, - {"name":"document_Status", "type": ["null", "string"], "default": null}, - {"name":"document_StopDate", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_Subject", "type": ["null", "string"], "default": null}, - {"name":"document_SubSequenceNumber", "type": ["null", "string"], "default": null}, - {"name":"document_Summary", "type": ["null", "string"], "default": null}, - {"name":"document_TextType", "type": ["null", "string"], "default": null}, - {"name":"document_Title", "type": ["null", "string"], "default": null}, - {"name":"document_Type", "type": ["null", "string"], "default": null}, - {"name":"document_UpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"document_UpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"document_VersionGroup", "type": ["null", "string"], "default": null}, - {"name":"id", "type": ["null", "string"], "default": null}, - {"name":"patient_activity_document_identifiers", "type": ["null", "string"], "default": null}, - {"name":"patient_Address", "type": ["null", "string"], "default": null}, - {"name":"patient_AddressCity", "type": ["null", "string"], "default": null}, - {"name":"patient_AddressCountry", "type": ["null", "string"], "default": null}, - {"name":"patient_AddressGeoLocation", "type": ["null", "string"], "default": null}, - {"name":"patient_AddressPostalCode", "type": ["null", "string"], "default": null}, - {"name":"patient_Age", "type": ["null", "string"], "default": null}, - {"name":"patient_Allergies", "type": ["null", "string"], "default": null}, - {"name":"patient_CCGNumber", "type": ["null", "string"], "default": null}, - {"name":"patient_CityOfBirth", "type": ["null", "string"], "default": null}, - {"name":"patient_CountryOfBirth", "type": ["null", "string"], "default": null}, - {"name":"patient_CreatedBy", "type": ["null", "string"], "default": null}, - {"name":"patient_CreatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_DateOfBirth", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_DateOfDeath", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null}, - {"name":"patient_Ethnicity", "type": ["null", "string"], "default": null}, - {"name":"patient_FirstName", "type": ["null", "string"], "default": null}, - {"name":"patient_Gender", "type": ["null", "string"], "default": null}, - {"name":"patient_GlobalConsultant", "type": ["null", "string"], "default": null}, - {"name":"patient_HospitalNumber", "type": ["null", "string"], "default": null}, - {"name":"patient_Language", "type": ["null", "string"], "default": null}, - {"name":"patient_LastName", "type": ["null", "string"], "default": null}, - {"name":"patient_MaritalStatus", "type": ["null", "string"], "default": null}, - {"name":"patient_metadata", "type": ["null", "string"], "default": null}, - {"name":"patient_MiddleName", "type": ["null", "string"], "default": null}, - {"name":"patient_name", "type": ["null", "string"], "default": null}, - {"name":"patient_Nationality", "type": ["null", "string"], "default": null}, - {"name":"patient_NhsNumber", "type": ["null", "string"], "default": null}, - {"name":"patient_Occupation", "type": ["null", "string"], "default": null}, - {"name":"patient_PlaceOfBirth", "type": ["null", "string"], "default": null}, - {"name":"patient_RadiotherapyConsultant", "type": ["null", "string"], "default": null}, - {"name":"patient_Religion", "type": ["null", "string"], "default": null}, - {"name":"patient_SourceId", "type": ["null", "string"], "default": null}, - {"name":"patient_SpecialCase", "type": ["null", "string"], "default": null}, - {"name":"patient_Title", "type": ["null", "string"], "default": null}, - {"name":"patient_UpdatedBy", "type": ["null", "string"], "default": null}, - {"name":"patient_UpdatedWhen", "type":["null", { "type" : "long", "logicalType" : "timestamp-millis" }, "string"], "default" : null} - ] -} \ No newline at end of file diff --git a/nifi/user-schemas/legacy/cogstack_common_schema_mapping.json b/nifi/user-schemas/legacy/cogstack_common_schema_mapping.json deleted file mode 100644 index 70f7f1954..000000000 --- a/nifi/user-schemas/legacy/cogstack_common_schema_mapping.json +++ /dev/null @@ -1,326 +0,0 @@ -{ - "activity_AdmissionDate": "", - "activity_AttendanceArrivalDate": "", - "activity_AttendanceCareGroup": "", - "activity_AttendanceChiefComplaint": "", - "activity_AttendanceCreatedWhen": "", - "activity_AttendanceCurrentLengthOfStay": "", - "activity_AttendanceCurrentLocation": "", - "activity_AttendanceNumber": "", - "activity_AttendanceReason": "", - "activity_AttendanceReferralSpecialty": "", - "activity_AttendanceRegistrationComplaint": "", - "activity_AttendanceSourceId": "", - "activity_AttendanceSourceOfReferral": "", - "activity_AttendanceStage": "", - "activity_AttendanceType": "", - "activity_AttendanceUpdatedWhen": "", - "activity_DischargeCreatedWhen": "", - "activity_DischargeDate": "", - "activity_DischargeDestination": "", - "activity_DischargeLocation": "", - "activity_DischargeOutcome": "", - "activity_DischargeUpdatedWhen": "", - "activity_EpisodeCreatedBy": "", - "activity_EpisodeCreatedWhen": "", - "activity_EpisodeCurrentLengthOfStay": "", - "activity_EpisodeDischargeDate": "", - "activity_EpisodeDischargeMethod": "", - "activity_EpisodeId": "", - "activity_EpisodeNumber": "", - "activity_EpisodePriority": "", - "activity_EpisodeSourceId": "", - "activity_EpisodeStartDate": "", - "activity_EpisodeType": "", - "activity_EpisodeUpdatedBy": "", - "activity_EpisodeUpdatedWhen": "", - "activity_metadata": "", - "activity_VisitDurationArrivalToInitialAssessment" : "", - "activity_VisitDurationArrivalToInitialDoctorSeen" : "", - "activity_VisitDurationInitialDoctorSeenToDeparture" : "", - "activity_VisitDurationArrivalToDeparture" : "", - "activity_VisitPriority" : "", - "activity_TestSampleCollectionTime" : "", - "activity_TestSampleReceivedTime" : "", - "activity_TestInitialReportedDate" : "", - "activity_VisitPointOfCareType" : "", - "activity_VisitPointOfCarePurpose" : "", - "activity_VisitProviderType" : "", - "activity_VisitCancelReason" : "", - "activity_VisitCancelGroup" : "", - "activity_VisitDuration" : "", - "activity_ReferralAdminCategory": "", - "activity_ReferralAdminPriority": "", - "activity_ReferralClosureReason": "", - "activity_ReferralCreatedBy": "", - "activity_ReferralCreatedWhen": "", - "activity_ReferralDate": "", - "activity_ReferralDischargeDate": "", - "activity_ReferralFormat": "", - "activity_ReferralGPCode": "", - "activity_ReferralId": "", - "activity_ReferralLocation": "", - "activity_ReferralPracticeCode": "", - "activity_ReferralPresentingReason": "", - "activity_ReferralPriority": "", - "activity_ReferralReason": "", - "activity_ReferralService": "", - "activity_ReferralSource": "", - "activity_ReferralSourceType": "", - "activity_ReferralStatus": "", - "activity_ReferralUpdatedBy": "", - "activity_ReferralUpdatedWhen": "", - "activity_ReferrerCCGCode": "", - "activity_ReferrerCCGName": "", - "activity_SpecialtyName": "", - "activity_SpellAdmissionDate": "", - "activity_SpellCreatedBy": "", - "activity_SpellCreatedWhen": "", - "activity_SpellCurrentLengthOfStay": "", - "activity_SpellDischargeDate": "", - "activity_SpellDischargeDestination": "", - "activity_SpellDischargeDetail": "", - "activity_SpellDischargeMethod": "", - "activity_SpellId": "", - "activity_SpellNumber": "", - "activity_SpellStatus": "", - "activity_SpellUpdatedBy": "", - "activity_SpellUpdatedWhen": "", - "activity_VisitAdmissionDate": "", - "activity_VisitAdmissionReason": "", - "activity_VisitAdmissionSource": "", - "activity_VisitAdmissionType": "", - "activity_VisitCanceledDate": "", - "activity_VisitCareLevel": "", - "activity_VisitCareProviderConsultant": "", - "activity_VisitCareProviderId": "", - "activity_VisitCareProviderType": "", - "activity_VisitCloseDate": "", - "activity_VisitCreatedBy": "", - "activity_VisitCreatedWhen": "", - "activity_VisitCurrentLengthOfStay": "", - "activity_VisitCurrentLocation": "", - "activity_VisitDischargeDate": "", - "activity_VisitDischargeDisposition": "", - "activity_VisitDischargeLocation": "", - "activity_VisitExpectedAdmissionDate": "", - "activity_VisitFacility": "", - "activity_VisitInternalStatus": "", - "activity_VisitLocationType": "", - "activity_VisitNextLocation": "", - "activity_VisitNumber": "", - "activity_VisitPlannedDischargeDate": "", - "activity_VisitPointOfCare": "", - "activity_VisitPreadmissionNumber": "", - "activity_VisitProviderName": "", - "activity_VisitService": "", - "activity_VisitSourceId": "", - "activity_VisitSpecialtyCanonicalName": "", - "activity_VisitSpecialtyCode": "", - "activity_VisitSpecialtyId": "", - "activity_VisitSpecialtyName": "", - "activity_VisitStatus": "", - "activity_VisitTemporaryLocation": "", - "activity_VisitType": "", - "activity_VisitUpdatedBy": "", - "activity_VisitUpdatedWhen": "", - "document_AccessToken": "", - "document_ActivateDateReference": "", - "document_ActivateDaysBefore": "", - "document_ActivateHoursBefore": "", - "document_ActivateStatus": "", - "document_Allergen": "", - "document_AllergyReaction": "", - "document_AncillaryReferenceId": "", - "document_ArrivalDate": "", - "document_Author": "", - "document_AuthoredDate": "", - "document_AuthorisorName": "", - "document_CareProviderName": "", - "document_CareProviderType": "", - "document_Category": "", - "document_CnId": "", - "document_Comment": "", - "document_CompleteDateReference": "", - "document_CompleteDaysAfter": "", - "document_CompleteHoursAfter": "", - "document_CompleteStatus": "", - "document_Conditions": "", - "document_ConfidenceLevel": "", - "document_ConfirmDate": "", - "document_ConfirmedBy": "", - "document_ConfirmStaffJobTitle": "", - "document_ConfirmStaffName": "", - "document_Content": "", - "document_ContentType": "", - "document_CreatedBy": "", - "document_CreatedWhen": "", - "document_Date": "", - "document_DateAdded": "", - "document_DateCreated": "", - "document_Description": "", - "document_Duration": "", - "document_EdlId": "", - "document_EncounterDate": "", - "document_EnteredDate": "", - "document_EnteredName": "", - "document_EnteredOccupation": "", - "document_EnteredRole": "", - "document_EntryType": "", - "document_Extension": "", - "document_FacilityId": "", - "document_Fields": {}, - "document_Fields_abnormalityCode": "", - "document_Fields_accessToken": "", - "document_Fields_arrivalDate": "", - "document_Fields_category": "", - "document_Fields_comment": "", - "document_Fields_dataType": "", - "document_Fields_dosage": "", - "document_Fields_duration": "", - "document_Fields_enteredDate": "", - "document_Fields_frequency": "", - "document_Fields_hasNumericEquivalent": "", - "document_Fields_historySequenceNumber": "", - "document_Fields_includeInTotals": "", - "document_Fields_indication": "", - "document_Fields_isClientCharacteristic": "", - "document_Fields_isTextual": "", - "document_Fields_itemHeader": "", - "document_Fields_key": "", - "document_Fields_label": "", - "document_Fields_lastModifiedComments": "", - "document_Fields_lastModifiedReason": "", - "document_Fields_masterEntryName": "", - "document_Fields_maxDose": "", - "document_Fields_media_CreateBy": "", - "document_Fields_media_CreateWhen": "", - "document_Fields_media_UpdatedBy": "", - "document_Fields_media_UpdatedWhen": "", - "document_Fields_mediaCode": "", - "document_Fields_minDose": "", - "document_Fields_observationId": "", - "document_Fields_physicalNoteType": "", - "document_Fields_recordedDate": "", - "document_Fields_referenceLowerLimit": "", - "document_Fields_referenceUpperLimit": "", - "document_Fields_resultId": "", - "document_Fields_route": "", - "document_Fields_status": "", - "document_Fields_statusReason": "", - "document_Fields_subCategory": "", - "document_Fields_text": "document", - "document_Fields_type": "", - "document_Fields_unitOfMeasure": "", - "document_Fields_valueDate": "", - "document_Fields_valueNum": "", - "document_Fields_valueText": "", - "document_FileName": "", - "document_FillerDocumentId": "", - "document_FillerFacilityId": "", - "document_Format": "", - "document_Frequency": "", - "document_HasBeenModified": "", - "document_History_createdBy": "", - "document_History_createdWhen": "", - "document_History_groupCode": "", - "document_History_key": "", - "document_History_label": "", - "document_History_valueText": "", - "document_HoldReason": "", - "document_Id": "doc_id", - "document_InformationSource": "", - "document_IsPartOfSet": "", - "document_IsPRN": "", - "document_KTreeItemId": "", - "document_KTreeItemName": "", - "document_KTreeRootId": "", - "document_LastUpdated": "", - "document_LetterMakerId": "", - "document_MediaCode": "", - "document_MediaCreatedBy": "", - "document_MediaCreatedWhen": "", - "document_MediaUpdatedBy": "", - "document_MediaUpdatedWhen": "", - "document_MinimumStatusForActivation": "", - "document_MinimumStatusForCompletion": "", - "document_ModifiedDate": "", - "document_ModifiedName": "", - "document_ModifiedOccupation": "", - "document_OrderEntryFormId": "", - "document_OrderEntryFormTitle": "", - "document_OrderId": "", - "document_OrderName": "", - "document_OrderPriority": "", - "document_OrderSetHeading": "", - "document_OrderSetName": "", - "document_OrderSetType": "", - "document_OrderStatus": "", - "document_OrderStatusLevelNumber": "", - "document_OrderUserData": "", - "document_OrganizationalUnit": "", - "document_PerformedDate": "", - "document_ProcedureId": "", - "document_Reference": "", - "document_RepeatOrder": "", - "document_ReqCodedTime": "", - "document_RequestedDate": "", - "document_ReviewMasterCategory": "", - "document_ReviewSubCategory": "", - "document_SequenceNumber": "", - "document_SignificantDate": "", - "document_sourcedb": "filename", - "document_SourceId": "", - "document_SourceSelector": "", - "document_Status": "", - "document_StopDate": "", - "document_Subject": "", - "document_Subject.keyword": "", - "document_SubSequenceNumber": "", - "document_Summary": "", - "document_TextType": "", - "document_Title": "", - "document_Type": "typeid", - "document_UpdatedBy": "", - "document_UpdatedWhen": "dct", - "document_VersionGroup": "", - "id": "doc_id", - "patient_activity_document_identifiers": "", - "patient_Address": "", - "patient_AddressCity": "", - "patient_AddressCountry": "", - "patient_AddressGeoLocation": "", - "patient_AddressPostalCode": "", - "patient_Age": "", - "patient_Allergies": "", - "patient_CCGNumber": "", - "patient_CityOfBirth": "", - "patient_CountryOfBirth": "", - "patient_CreatedBy": "", - "patient_CreatedWhen": "", - "patient_DateOfBirth": "", - "patient_DateOfDeath": "", - "patient_Ethnicity": "", - "patient_FirstName": "", - "patient_Gender": "", - "patient_GlobalConsultant": "", - "patient_GlobalConsultant.keyword": "", - "patient_HospitalNumber": "", - "patient_Language": "", - "patient_LastName": "", - "patient_MaritalStatus": "", - "patient_metadata": "", - "patient_MiddleName": "", - "patient_name": "", - "patient_Nationality": "", - "patient_NhsNumber": "", - "patient_Occupation": "", - "patient_PlaceOfBirth": "", - "patient_RadiotherapyConsultant": "", - "patient_Religion": "", - "patient_SourceId": "", - "patient_SpecialCase": "", - "patient_Title": "", - "patient_UpdatedBy": "", - "patient_UpdatedWhen": "" -} \ No newline at end of file diff --git a/nifi/user-schemas/legacy/document.avsc b/nifi/user-schemas/legacy/document.avsc deleted file mode 100644 index 390326fb1..000000000 --- a/nifi/user-schemas/legacy/document.avsc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "type": "record", - "name": "nifiRecord", - "namespace":"org.apache.nifi", - "fields": - [ - { "name": "doc_id", "type": "string" }, - { "name": "doc_text", "type": "string", "default": "" }, - { "name": "processing_timestamp", "type": { "type" : "string", "logicalType" : "timestamp-millis" } }, - { "name": "metadata_x_ocr_applied", "type": "boolean" }, - { "name": "metadata_x_parsed_by", "type": "string" }, - { "name": "metadata_content_type", "type": ["null", "string"], "default": null }, - { "name": "metadata_page_count", "type": ["null", "int"], "default": null }, - { "name": "metadata_creation_date", "type": ["null", { "type" : "long", "logicalType" : "timestamp-millis" }], "default": null }, - { "name": "metadata_last_modified", "type": ["null", { "type" : "long", "logicalType" : "timestamp-millis" }], "default": null } - ] -} - diff --git a/nifi/user-schemas/legacy/document_all_fields.avsc b/nifi/user-schemas/legacy/document_all_fields.avsc deleted file mode 100644 index 4620270ea..000000000 --- a/nifi/user-schemas/legacy/document_all_fields.avsc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "record", - "name": "nifiRecord", - "namespace":"org.apache.nifi", - "fields": - [ - { "name": "docid", "type": "int"}, - { "name": "body_analysed", "type": "string", "default": "" }, - { "name": "sampleid", "type": "int"}, - { "name": "dct", "type": { "type" : "string", "logicalType" : "timestamp-millis" }}, - { "name": "processing_timestamp", "type": { "type" : "string", "logicalType" : "timestamp-millis" } }, - { "name": "metadata_x_ocr_applied", "type": "boolean" }, - { "name": "metadata_x_parsed_by", "type": "string" }, - { "name": "metadata_content_type", "type": ["null", "string"], "default": null }, - { "name": "metadata_page_count", "type": ["null", "int"], "default": null }, - { "name": "metadata_creation_date", "type": ["null", { "type" : "long", "logicalType" : "timestamp-millis" }], "default": null }, - { "name": "metadata_last_modified", "type": ["null", { "type" : "long", "logicalType" : "timestamp-millis" }], "default": null } - ] -} \ No newline at end of file diff --git a/nifi/user-scripts/bootstrap_external_lib_imports.py b/nifi/user-scripts/bootstrap_external_lib_imports.py deleted file mode 100644 index 830743b06..000000000 --- a/nifi/user-scripts/bootstrap_external_lib_imports.py +++ /dev/null @@ -1,19 +0,0 @@ -import os -import sys - - -def running_in_docker() -> bool: - if os.path.exists("/.dockerenv"): - return True - try: - with open("/proc/1/cgroup", "rt") as f: - return any("docker" in line or "containerd" in line for line in f) - except FileNotFoundError: - return False - - -# we need to add it to the sys imports -if running_in_docker(): - sys.path.insert(0, "/opt/nifi/user-scripts") -else: - sys.path.insert(0, "./user-scripts") diff --git a/nifi/user-scripts/clean_doc.py b/nifi/user-scripts/clean_doc.py deleted file mode 100644 index ff6baf011..000000000 --- a/nifi/user-scripts/clean_doc.py +++ /dev/null @@ -1,39 +0,0 @@ -import json -import re -import sys - -records = json.loads(sys.stdin.read()) - -TEXT_FIELD_NAME = "text" - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "text_field_name": - TEXT_FIELD_NAME = _arg[1] - -if isinstance(records, dict): - records = [records] - -# List of substitutions as (pattern, replacement) -PII_PATTERNS = [ - (r"PEf", "ABCD100"), - (r"\d\d\d?F|M", ""), # case-insensitive matching below - (r"RFH|CFH|BA|Barnet|BARNET", ""), - (r"IMT|CST|AHP|CNS", ""), - (r"GMC:\s*\d{7}", ""), - (r"MCIRL:\s*\d{6}", ""), - (r"NHS\s*Number:\s*\d{3}\s*\d{3}\s*\d{4}", ""), - (r"(https?:\/\/)?www\.royalfree\.nhs\.uk[^\s]*", ""), - (r"UCLH|UCH|NMUH|BGH|NUH", "") -] - -# removes any PII from the text field -for i in range(len(records)): - if TEXT_FIELD_NAME in records[i].keys(): - clean_text = records[i][TEXT_FIELD_NAME] - for pattern, repl in PII_PATTERNS: - clean_text = re.sub(pattern, repl, clean_text, flags=re.IGNORECASE) - records[i][TEXT_FIELD_NAME] = clean_text - -# Output cleaned JSON as UTF-8 -sys.stdout.buffer.write(json.dumps(records, ensure_ascii=False).encode("utf-8")) diff --git a/nifi/user-scripts/cogstack_cohort_generate_data.py b/nifi/user-scripts/cogstack_cohort_generate_data.py deleted file mode 100644 index 0fc8ad2bf..000000000 --- a/nifi/user-scripts/cogstack_cohort_generate_data.py +++ /dev/null @@ -1,436 +0,0 @@ -import json -import logging -import multiprocessing -import os -import sys -import traceback -from collections import Counter, defaultdict -from datetime import datetime, timezone -from multiprocessing import Pool, Queue - -from utils.ethnicity_map import ethnicity_map -from utils.generic import chunk, dict2json_file, dict2jsonl_file - -# default values from /deploy/nifi.env -NIFI_USER_SCRIPT_LOGS_DIR = os.getenv("NIFI_USER_SCRIPT_LOGS_DIR", "") - -LOG_FILE_NAME = "cohort_export.log" -log_file_path = os.path.join(NIFI_USER_SCRIPT_LOGS_DIR, str(LOG_FILE_NAME)) - - -ANNOTATION_DOCUMENT_ID_FIELD_NAME = "meta.docid" -DOCUMENT_ID_FIELD_NAME = "docid" - -PATIENT_ID_FIELD_NAME = "patient" -PATIENT_ETHNICITY_FIELD_NAME = "ethnicity" -PATIENT_GENDER_FIELD_NAME = "gender" -PATIENT_BIRTH_DATE_FIELD_NAME = "birthdate" -PATIENT_DEATH_DATE_FIELD_NAME = "deathdate" -PATIENT_DEATH_DATE_BACKUP_FIELD_NAME = "" - -OUTPUT_FOLDER_PATH = os.path.join(os.getenv("NIFI_DATA_PATH", "/opt/data/"), "cogstack-cohort") - -# this is a json exported by NiFi to some path in the NIFI_DATA_PATH -INPUT_PATIENT_RECORDS_PATH = "" -INPUT_ANNOTATIONS_RECORDS_PATH = "" - -DATE_TIME_FORMAT = "%Y-%m-%d" - -TIMEOUT = 360 - -CPU_THREADS = int(os.getenv("CPU_THREADS", int(multiprocessing.cpu_count() / 2))) - -INPUT_FOLDER_PATH = "" - -# json file(s) containing annotations exported by NiFi, the input format is expected to be one provided -# by MedCAT Service which was stored in an Elasticsearch index -INPUT_PATIENT_RECORD_FILE_NAME_PATTERN = "" -INPUT_ANNOTATIONS_RECORDS_FILE_NAME_PATTERN = "" - -EXPORT_ONLY_PATIENT_RECORDS = "false" - -# if this is enabled, the maximum patient age will be somewhere between 90 and 99 IF there is no DATE OF DEATH available -# calculated as: current_year - patient_year_of_birth, if > 100, patient_age = rand(90,99) -ENABLE_PATIENT_AGE_LIMIT = "false" - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "annotation_document_id_field_name": - ANNOTATION_DOCUMENT_ID_FIELD_NAME = _arg[1] - if _arg[0] == "date_time_format": - DATE_TIME_FORMAT = _arg[1] - if _arg[0] == "patient_id_field_name": - PATIENT_ID_FIELD_NAME = _arg[1] - if _arg[0] == "patient_ethnicity_field_name": - PATIENT_ETHNICITY_FIELD_NAME = _arg[1] - if _arg[0] == "patient_birth_date_field_name": - PATIENT_BIRTH_DATE_FIELD_NAME = _arg[1] - if _arg[0] == "patient_death_date_field_name": - PATIENT_DEATH_DATE_FIELD_NAME = _arg[1] - if _arg[0] == "patient_death_date_backup_field_name": - PATIENT_DEATH_DATE_BACKUP_FIELD_NAME = _arg[1] - if _arg[0] == "patient_gender_field_name": - PATIENT_GENDER_FIELD_NAME = _arg[1] - if _arg[0] == "document_id_field_name": - DOCUMENT_ID_FIELD_NAME = _arg[1] - if _arg[0] == "cpu_threads": - CPU_THREADS = int(_arg[1]) - if _arg[0] == "timeout": - TIMEOUT = int(_arg[1]) - if _arg[0] == "output_folder_path": - OUTPUT_FOLDER_PATH = _arg[1] - if _arg[0] == "input_folder_path": - INPUT_FOLDER_PATH = _arg[1] - if _arg[0] == "input_patient_record_file_name_pattern": - INPUT_PATIENT_RECORD_FILE_NAME_PATTERN = _arg[1] - if _arg[0] == "input_annotations_records_file_name_pattern": - INPUT_ANNOTATIONS_RECORDS_FILE_NAME_PATTERN = _arg[1] - if _arg[0] == "enable_patient_age_limit": - ENABLE_PATIENT_AGE_LIMIT = str(_arg[1]).lower() - - -def _process_patient_records(patient_records: list): - _ptt2sex, _ptt2eth, _ptt2dob, _ptt2age, _ptt2dod, _doc2ptt = {}, {}, {}, {}, {}, {} - - for patient_record in patient_records: - if PATIENT_ID_FIELD_NAME in patient_record.keys(): - try: - _ethnicity = str(patient_record[PATIENT_ETHNICITY_FIELD_NAME]).lower().replace("-", " ").replace("_", " ") if PATIENT_ETHNICITY_FIELD_NAME in patient_record.keys() else "other" - - _PATIENT_ID = str(patient_record[PATIENT_ID_FIELD_NAME]).replace("\"", "").replace("\'", "").strip() - - if _ethnicity in ethnicity_map.keys(): - _ptt2eth[_PATIENT_ID] = ethnicity_map[_ethnicity].title() - else: - _ptt2eth[_PATIENT_ID] = _ethnicity.title() - - # based on: https://www.datadictionary.nhs.uk/attributes/person_gender_code.html - _tmp_gender = str(patient_record[PATIENT_GENDER_FIELD_NAME]).lower() if PATIENT_GENDER_FIELD_NAME in patient_record.keys() else "Unknown" - if _tmp_gender in ["male", "1", "m"]: - _tmp_gender = "Male" - elif _tmp_gender in ["female", "2", "f"]: - _tmp_gender = "Female" - else: - _tmp_gender = "Unknown" - - _ptt2sex[_PATIENT_ID] = _tmp_gender - - dob = patient_record[PATIENT_BIRTH_DATE_FIELD_NAME] if PATIENT_BIRTH_DATE_FIELD_NAME in patient_record.keys() else 0 - dod = patient_record[PATIENT_DEATH_DATE_FIELD_NAME] if PATIENT_DEATH_DATE_FIELD_NAME in patient_record.keys() else \ - patient_record[PATIENT_DEATH_DATE_BACKUP_FIELD_NAME] if PATIENT_DEATH_DATE_BACKUP_FIELD_NAME in patient_record.keys() else 0 - - if dob in [None, "", "null"]: - dob = 0 - if dod in [None, "", "null"]: - dod = 0 - - patient_age = 0 - - if isinstance(dob, int): - dob = datetime.fromtimestamp(dob / 1000, tz=timezone.utc) - elif isinstance(dob, str): - dob = datetime.strptime(str(dob), DATE_TIME_FORMAT) - - if dod not in [None, "null", 0]: - if isinstance(dod, int): - dod = datetime.fromtimestamp(dod / 1000, tz=timezone.utc) - elif isinstance(dod, str): - dod = datetime.strptime(str(dod), DATE_TIME_FORMAT) - - patient_age = abs(dod.year - dob.year) - else: - patient_age = abs(datetime.now().year - dob.year) - if patient_age >= 100 and ENABLE_PATIENT_AGE_LIMIT == "true": - dod = datetime.now(tz=timezone.utc) - patient_age = -1 - - # convert to ints - dod = int(dod.strftime("%Y%m%d%H%M%S")) if dod not in [0, None, "null"] else 0 - dob = int(dob.strftime("%Y%m%d%H%M%S")) if dob not in [0, None, "null"] else 0 - - # change records - _ptt2dod[_PATIENT_ID] = dod - _ptt2dob[_PATIENT_ID] = dob - _ptt2age[_PATIENT_ID] = patient_age - - _derived_document_id_field_from_ann = ANNOTATION_DOCUMENT_ID_FIELD_NAME.removeprefix("meta.") - if DOCUMENT_ID_FIELD_NAME in patient_record.keys(): - docid = patient_record[DOCUMENT_ID_FIELD_NAME] - else: - docid = _derived_document_id_field_from_ann - - _doc2ptt[docid] = _PATIENT_ID - except ValueError: - continue - - return _ptt2sex, _ptt2eth, _ptt2dob, _ptt2age, _ptt2dod, _doc2ptt - - -def _process_annotation_records(annotation_records: list): - - _cui2ptt_pos = defaultdict(Counter) - _cui2ptt_tsp = defaultdict(lambda: defaultdict(int)) - - try: - - # for each part of the MedCAT output (e.g., part_0.pickle) - for annotation_record in annotation_records: - annotation_entity = annotation_record - if "_source" in annotation_record.keys(): - annotation_entity = annotation_record["_source"] - docid = annotation_entity[ANNOTATION_DOCUMENT_ID_FIELD_NAME] - - if str(docid) in global_doc2ptt.keys(): - patient_id = global_doc2ptt[str(docid)] - cui = annotation_entity["nlp.cui"] - - if annotation_entity["nlp.meta_anns"]["Subject"]["value"] == "Patient" and \ - annotation_entity["nlp.meta_anns"]["Presence"]["value"] == "True" and \ - annotation_entity["nlp.meta_anns"]["Time"]["value"] != "Future": - - _cui2ptt_pos[cui][patient_id] += 1 - - if "timestamp" in annotation_entity.keys(): - time = int(round(datetime.fromisoformat(annotation_entity["timestamp"]).timestamp())) - - if _cui2ptt_tsp[cui][patient_id] == 0 or time < _cui2ptt_tsp[cui][patient_id]: - _cui2ptt_tsp[cui][patient_id] = time - except Exception: - raise Exception("exception generated by process_annotation_records: " + str(traceback.format_exc())) - - return _cui2ptt_pos, _cui2ptt_tsp - - -def multiprocessing_patient_records(input_patient_record_data: dict): - - # ptt2sex.json a dictionary for gender of each patient {:, ...} - ptt2sex = {} - # ptt2eth.json a dictionary for ethnicity of each patient {:, ...} - ptt2eth = {} - # ptt2dob.json a dictionary for date of birth of each patient {:, ...} - ptt2dob = {} - # ptt2age.json a dictionary for age of each patient {:, ...} - ptt2age = {} - # ptt2dod.json a dictionary for dod if the patient has died {:, ...} - ptt2dod = {} - - # doc2ptt is a dictionary { : , ...} - doc2ptt = {} - - patient_process_pool_results = [] - - with Pool(processes=CPU_THREADS) as patient_process_pool: - rec_que = Queue() - - record_chunks = list(chunk(input_patient_record_data, int(len(input_patient_record_data) / CPU_THREADS))) - - counter = 0 - for record_chunk in record_chunks: - rec_que.put(record_chunk) - patient_process_pool_results.append(patient_process_pool.starmap_async(_process_patient_records, [(rec_que.get(),)], error_callback=logging.error)) - counter += 1 - - for result in patient_process_pool_results: - try: - result_data = result.get(timeout=TIMEOUT) - _ptt2sex, _ptt2eth, _ptt2dob, _ptt2age, _ptt2dod, _doc2ptt = result_data[0][0], result_data[0][1], result_data[0][2], result_data[0][3], result_data[0][4], result_data[0][5] - - ptt2sex.update(_ptt2sex) - ptt2eth.update(_ptt2eth) - ptt2dob.update(_ptt2dob) - ptt2age.update(_ptt2age) - ptt2dod.update(_ptt2dod) - doc2ptt.update(_doc2ptt) - - except Exception as exception: - time = datetime.now() - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(time) + ": " + str(exception)) - log_file.write("\n" + str(time) + ": " + traceback.format_exc()) - - return doc2ptt, ptt2dod, ptt2age, ptt2dob, ptt2eth, ptt2sex - - -def multiprocessing_annotation_records(input_annotations: dict): - - # cui2ptt_pos.jsonl each line is a dictionary of cui and the value is a dictionary of patients with a count {: {:, ...}}\n... - cui2ptt_pos = defaultdict(Counter) # store the count of a SNOMED term for a patient - - # cui2ptt_tsp.jsonl each line is a dictionary of cui and the value is a dictionary of patients with a timestamp {: {:, ...}}\n... - cui2ptt_tsp = defaultdict(lambda: defaultdict(int)) # store the first mention timestamp of a SNOMED term for a patient - - annotation_process_pool_results = [] - - with Pool(processes=CPU_THREADS) as annotations_process_pool: - rec_que = Queue() - - try: - if len(input_annotations) > 1: - record_chunks = list(chunk(input_annotations, int(len(input_annotations) / CPU_THREADS))) - else: - record_chunks = input_annotations - - counter = 0 - for record_chunk in record_chunks: - rec_que.put(record_chunk) - annotation_process_pool_results.append(annotations_process_pool.starmap_async(_process_annotation_records, [(rec_que.get(),)], error_callback=logging.error)) - counter += 1 - - for result in annotation_process_pool_results: - result_data = result.get(timeout=TIMEOUT) - - _cui2ptt_pos, _cui2ptt_tsp = result_data[0][0], result_data[0][1] - - for cui, patient_id_count_vals in _cui2ptt_pos.items(): - if cui not in cui2ptt_pos.keys(): - cui2ptt_pos[cui] = patient_id_count_vals - else: - for patient_id, count in patient_id_count_vals.items(): - if patient_id not in cui2ptt_pos[cui].keys(): - cui2ptt_pos[cui][patient_id] = count - else: - cui2ptt_pos[cui][patient_id] += count - - for cui, patient_id_timestamps in _cui2ptt_tsp.items(): - if cui not in cui2ptt_tsp.keys(): - cui2ptt_tsp[cui] = patient_id_timestamps - else: - for patient_id, timestamp in patient_id_timestamps.items(): - if patient_id not in cui2ptt_tsp[cui].keys(): - cui2ptt_tsp[cui][patient_id] = timestamp - else: - cui2ptt_tsp[cui][patient_id] = timestamp - - except Exception as exception: - time = datetime.now() - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(time) + ": " + str(exception)) - log_file.write("\n" + str(time) + ": " + traceback.format_exc()) - - return cui2ptt_pos, cui2ptt_tsp - - -############################################# - - -# for testing -#OUTPUT_FOLDER_PATH = "../../data/cogstack-cohort/" -#INPUT_FOLDER_PATH = "../../data/cogstack-cohort/" -#INPUT_ANNOTATIONS_RECORDS_FILE_NAME_PATTERN = "medical_reports_anns_" -#INPUT_PATIENT_RECORD_FILE_NAME_PATTERN = "medical_reports_text__" - -global_doc2ptt = {} - -if INPUT_PATIENT_RECORD_FILE_NAME_PATTERN: - ptt2dod, ptt2age, ptt2dob, ptt2eth, ptt2sex = {}, {}, {}, {}, {} - - # read each of the patient record files one by one - for root, sub_directories, files in os.walk(INPUT_FOLDER_PATH): - for file_name in files: - if INPUT_PATIENT_RECORD_FILE_NAME_PATTERN in file_name: - f_path = os.path.join(root,file_name) - - contents = [] - - with open(f_path, mode="r+") as f: - contents = json.loads(f.read()) - _doc2ptt, _ptt2dod, _ptt2age, _ptt2dob, _ptt2eth, _ptt2sex = multiprocessing_patient_records(contents) - - if _ptt2sex != {}: - ptt2sex.update(_ptt2sex) - if _ptt2eth != {}: - ptt2eth.update(_ptt2eth) - if _ptt2dob != {}: - ptt2dob.update(_ptt2dob) - if _ptt2dod != {}: - ptt2dod.update(_ptt2dod) - if _ptt2age != {}: - ptt2age.update(_ptt2age) - if _doc2ptt != {}: - global_doc2ptt.update(_doc2ptt) - - with open(log_file_path, "a+") as log_file: - time = datetime.now() - log_file.write("\n" + str(time) + ": processed file " + str(file_name)) - - dict2json_file(ptt2sex, os.path.join(OUTPUT_FOLDER_PATH, "ptt2sex.json")) - dict2json_file(ptt2eth, os.path.join(OUTPUT_FOLDER_PATH, "ptt2eth.json")) - dict2json_file(ptt2dob, os.path.join(OUTPUT_FOLDER_PATH, "ptt2dob.json")) - dict2json_file(ptt2dod, os.path.join(OUTPUT_FOLDER_PATH, "ptt2dod.json")) - dict2json_file(ptt2age, os.path.join(OUTPUT_FOLDER_PATH, "ptt2age.json")) - - -# dump patients for future ref -doc2ptt_path = os.path.join(OUTPUT_FOLDER_PATH, "doc2ptt.json") -if global_doc2ptt != {}: - with open(doc2ptt_path, "w") as doc2ptt_file: - doc2ptt_file.write(json.dumps(global_doc2ptt)) - -# if we have no patients, perhaps we have a list that is already present, ready to be used -# so that we only care about generating the annotations... -if len(global_doc2ptt.keys()) < 1: - if os.path.exists(doc2ptt_path): - with open(doc2ptt_path, "r+") as f: - global_doc2ptt = f.read() - global_doc2ptt = json.loads(global_doc2ptt) - -if INPUT_ANNOTATIONS_RECORDS_FILE_NAME_PATTERN: - # read each of the patient record files one by one - for root, sub_directories, files in os.walk(INPUT_FOLDER_PATH): - for file_name in files: - if INPUT_ANNOTATIONS_RECORDS_FILE_NAME_PATTERN in file_name: - f_path = os.path.join(root,file_name) - - contents = [] - - with open(f_path, mode="r+") as f: - contents = json.loads(f.read()) - - cui2ptt_pos, cui2ptt_tsp = multiprocessing_annotation_records(contents) - dict2jsonl_file(cui2ptt_pos, os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_pos.jsonl")) - dict2jsonl_file(cui2ptt_tsp, os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_tsp.jsonl")) - - with open(log_file_path, "a+") as log_file: - time = datetime.now() - log_file.write("\n" + str(time) + ": processed file " + str(file_name)) - - # merge all records - cui2ptt_pos = defaultdict(Counter) # store the count of a SNOMED term for a patient - cui2ptt_tsp = defaultdict(lambda: defaultdict(int)) # store the first mention timestamp of a SNOMED term for a patient - - with open(os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_tsp.jsonl"), "r+", encoding="utf-8") as cui2ptt_tspfile: - - for line in cui2ptt_tspfile: - _dict_line = json.loads(line) - - for cui, cui_timestamps in _dict_line.items(): - if cui not in cui2ptt_tsp.keys(): - cui2ptt_tsp[cui] = cui_timestamps - else: - for pttid, timestamp in cui_timestamps.items(): - if pttid not in cui2ptt_tsp[cui].keys(): - cui2ptt_tsp[cui][pttid] = int(timestamp) - else: - if int(cui2ptt_tsp[cui][pttid]) < int(timestamp): - cui2ptt_tsp[cui][pttid] = timestamp - - os.rename(os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_tsp.jsonl"), os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_tsp.jsonl.bk")) - dict2jsonl_file(cui2ptt_tsp, os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_tsp.jsonl")) - - with open(os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_pos.jsonl"), "r+", encoding="utf-8") as cui2ptt_posfile: - - for line in cui2ptt_posfile: - _dict_line = json.loads(line) - - for cui, pttid_cui_count in _dict_line.items(): - if cui not in cui2ptt_pos.keys(): - cui2ptt_pos[cui] = pttid_cui_count - else: - for pttid, cui_count in pttid_cui_count.items(): - if pttid not in cui2ptt_pos[cui].keys(): - cui2ptt_pos[cui][pttid] = int(cui_count) - else: - cui2ptt_pos[cui][pttid] += int(cui_count) - - os.rename(os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_pos.jsonl"), os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_pos.jsonl.bk")) - dict2jsonl_file(cui2ptt_pos, os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_pos.jsonl")) \ No newline at end of file diff --git a/nifi/user-scripts/cogstack_cohort_generate_random_data.py b/nifi/user-scripts/cogstack_cohort_generate_random_data.py deleted file mode 100644 index cb5fcbe48..000000000 --- a/nifi/user-scripts/cogstack_cohort_generate_random_data.py +++ /dev/null @@ -1,149 +0,0 @@ -import datetime -import json -import os -import sys -from collections import Counter, defaultdict -from datetime import datetime - -from utils.ethnicity_map import ethnicity_map - -ANNOTATION_DOCUMENT_ID_FIELD_NAME = "meta.docid" - -PATIENT_ID_FIELD_NAME = "patient" -PATIENT_ETHNICITY_FIELD_NAME = "ethnicity" -PATIENT_GENDER_FIELD_NAME = "gender" -PATIENT_BIRTH_DATE_FIELD_NAME = "birthdate" -PATIENT_DEATH_DATE_FIELD_NAME = "deathdate" - -OUTPUT_FOLDER_PATH = os.path.join(os.getenv("NIFI_DATA_PATH", "/opt/data/"), "cogstack-cohort") - -# this is a json exported by NiFi to some path in the NIFI_DATA_PATH -INPUT_PATIENT_RECORDS_PATH = "" -INPUT_ANNOTATIONS_RECORDS_PATH = "" - -DATE_TIME_FORMAT = "%Y-%m-%d" - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "annotation_document_id_field_name": - ANNOTATION_DOCUMENT_ID_FIELD_NAME = _arg[1] - if _arg[0] == "input_patient_records_path": - INPUT_PATIENT_RECORDS_PATH = _arg[1] - if _arg[0] == "input_annotations_records_path": - INPUT_ANNOTATIONS_RECORDS_PATH = _arg[1] - if _arg[0] == "date_time_format": - DATE_TIME_FORMAT = _arg[1] - if _arg[0] == "patient_field_name": - PATIENT_ID_FIELD_NAME = _arg[1] - if _arg[0] == "patient_ethnicity_field_name": - PATIENT_ETHNICITY_FIELD_NAME = _arg[1] - if _arg[0] == "patient_birth_date_field_name": - PATIENT_BIRTH_DATE_FIELD_NAME = _arg[1] - if _arg[0] == "patient_death_date_field_name": - PATIENT_DEATH_DATE_FIELD_NAME = _arg[1] - if _arg[0] == "patient_gender_field_name": - PATIENT_GENDER_FIELD_NAME = _arg[1] - -# function to convert a dictionary to json and write to file (d: dictionary, fn: string (filename)) -def dict2json_file(input_dict, fn): - # write the json file - with open(fn, 'w', encoding='utf-8') as outfile: - json.dump(input_dict, outfile, ensure_ascii=False, indent=None, separators=(',',':')) - -# json file containing annotations exported by NiFi, the input format is expected to be one provided -# by MedCAT Service which was stored in an Elasticsearch index -input_annotations = json.loads(open(INPUT_ANNOTATIONS_RECORDS_PATH, mode="r+").read()) - -# json file containing record data from a SQL database or from Elasticsearch -input_patient_record_data = json.loads(open(INPUT_PATIENT_RECORDS_PATH, mode="r+").read()) - -# cui2ptt_pos.jsonl each line is a dictionary of cui and the value is a dictionary of patients with a count {: {:, ...}}\n... -cui2ptt_pos = defaultdict(Counter) # store the count of a SNOMED term for a patient - -# cui2ptt_tsp.jsonl each line is a dictionary of cui and the value is a dictionary of patients with a timestamp {: {:, ...}}\n... -cui2ptt_tsp = defaultdict(lambda: defaultdict(int)) # store the first mention timestamp of a SNOMED term for a patient - -# doc2ptt is a dictionary { : , ...} -doc2ptt = {} - -# ptt2sex.json a dictionary for gender of each patient {:, ...} -ptt2sex = {} -# ptt2eth.json a dictionary for ethnicity of each patient {:, ...} -ptt2eth = {} -# ptt2dob.json a dictionary for date of birth of each patient {:, ...} -ptt2dob = {} -# ptt2age.json a dictionary for age of each patient {:, ...} -ptt2age = {} -# ptt2dod.json a dictionary for dod if the patient has died {:, ...} -ptt2dod = {} - -for patient_record in input_patient_record_data: - - _ethnicity = str(patient_record[PATIENT_ETHNICITY_FIELD_NAME]).lower().replace("-", " ").replace("_", " ") - - if _ethnicity in ethnicity_map.keys(): - ptt2eth[patient_record[PATIENT_ID_FIELD_NAME]] = ethnicity_map[_ethnicity].title() - else: - ptt2eth[patient_record[PATIENT_ID_FIELD_NAME]] = _ethnicity.title() - - # based on: https://www.datadictionary.nhs.uk/attributes/person_gender_code.html - _tmp_gender = str(patient_record[PATIENT_GENDER_FIELD_NAME]).lower() - if _tmp_gender in ["male", "1", "m"]: - _tmp_gender = "Male" - elif _tmp_gender in ["female", "2", "f"]: - _tmp_gender = "Female" - else: - _tmp_gender = "Unknown" - - ptt2sex[patient_record[PATIENT_ID_FIELD_NAME]] = _tmp_gender - - dob = datetime.strptime(patient_record[PATIENT_BIRTH_DATE_FIELD_NAME], DATE_TIME_FORMAT) - - dod = patient_record[PATIENT_DEATH_DATE_FIELD_NAME] - patient_age = 0 - - if dod not in [None, "null"]: - dod = datetime.strptime(patient_record[PATIENT_DEATH_DATE_FIELD_NAME], DATE_TIME_FORMAT) - patient_age = dod.year - dob.year - else: - patient_age = datetime.now().year - dob.year - - # convert to ints - dod = int(dod.strftime("%Y%m%d%H%M%S")) if dod not in [None, "null"] else 0 - dob = int(dob.strftime("%Y%m%d%H%M%S")) - - # change records - ptt2dod[patient_record[PATIENT_ID_FIELD_NAME]] = dod - ptt2dob[patient_record[PATIENT_ID_FIELD_NAME]] = dob - ptt2age[patient_record[PATIENT_ID_FIELD_NAME]] = patient_age - - docuemnt_id_field = ANNOTATION_DOCUMENT_ID_FIELD_NAME.removeprefix("meta.") - - doc2ptt[patient_record[docuemnt_id_field]] = patient_record[PATIENT_ID_FIELD_NAME] - -# for each part of the MedCAT output (e.g., part_0.pickle) -for annotation_record in input_annotations: - annotation_entity = annotation_record - if "_source" in annotation_record.keys(): - annotation_entity = annotation_record["_source"] - docid = annotation_entity[ANNOTATION_DOCUMENT_ID_FIELD_NAME] - - if docid in list(doc2ptt.keys()): - ptt = doc2ptt[docid] - if annotation_entity["nlp.meta_anns"]["Subject"]["value"] == "Patient" and annotation_entity["nlp.meta_anns"]["Presence"]["value"] == "True" and annotation_entity["nlp.meta_anns"]["Time"]["value"] != "Future": - cui = annotation_entity["nlp.cui"] - cui2ptt_pos[cui][ptt] += 1 - - if "timestamp" in annotation_entity.keys(): - time = int(round(datetime.fromisoformat(annotation_entity["timestamp"]).timestamp())) - if cui2ptt_tsp[cui][ptt] == 0 or time < cui2ptt_tsp[cui][ptt]: - cui2ptt_tsp[cui][ptt] = time - - -dict2json_file(ptt2sex, os.path.join(OUTPUT_FOLDER_PATH, "ptt2sex.json")) -dict2json_file(ptt2eth, os.path.join(OUTPUT_FOLDER_PATH, "ptt2eth.json")) -dict2json_file(ptt2dob, os.path.join(OUTPUT_FOLDER_PATH, "ptt2dob.json")) -dict2json_file(ptt2dod, os.path.join(OUTPUT_FOLDER_PATH, "ptt2dod.json")) -dict2json_file(ptt2age, os.path.join(OUTPUT_FOLDER_PATH, "ptt2age.json")) -dict2json_file(cui2ptt_pos, os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_pos.jsonl")) -dict2json_file(cui2ptt_tsp, os.path.join(OUTPUT_FOLDER_PATH, "cui2ptt_tsp.jsonl")) diff --git a/nifi/user-scripts/db/.gitignore b/nifi/user-scripts/db/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/nifi/user-scripts/dto/nifi_api_config.py b/nifi/user-scripts/dto/nifi_api_config.py deleted file mode 100644 index 303bdd1b1..000000000 --- a/nifi/user-scripts/dto/nifi_api_config.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - - -class NiFiAPIConfig: - NIFI_URL_SCHEME: str = "https" - NIFI_HOST: str = "localhost" - NIFI_PORT: int = 8443 - NIFI_REGISTRY_PORT: int = 18443 - - NIFI_USERNAME: str = os.environ.get("NIFI_SINGLE_USER_CREDENTIALS_USERNAME", "admin") - NIFI_PASSWORD: str = os.environ.get("NIFI_SINGLE_USER_CREDENTIALS_PASSWORD", "cogstackNiFi") - - ROOT_CERT_CA_PATH: str = os.path.abspath("../../../../security/certificates/root/root-ca.pem") - NIFI_CERT_PEM_PATH: str = os.path.abspath("../../../../security/certificates/nifi/nifi.pem") - NIFI_CERT_KEY_PATH: str = os.path.abspath("../../../../security/certificates/nifi/nifi.key") - - VERIFY_SSL: bool = True - - @property - def nifi_base_url(self) -> str: - """Full NiFi base URL, e.g. https://localhost:8443""" - return f"{self.NIFI_URL_SCHEME}://{self.NIFI_HOST}:{self.NIFI_PORT}" - - @property - def nifi_api_url(self) -> str: - """"NiFi REST API root, e.g. https://localhost:8443/nifi-api""" - return f"{self.nifi_base_url}/nifi-api" - - @property - def nifi_registry_base_url(self) -> str: - """"NiFi Registry REST API root, e.g. https://localhost:18443/nifi-registry""" - return f"{self.NIFI_URL_SCHEME}://{self.NIFI_HOST}:{self.NIFI_REGISTRY_PORT}/nifi-registry/" - - @property - def nifi_registry_api_url(self) -> str: - """"NiFi Registry REST API root, e.g. https://localhost:18443/nifi-registry/nifi-registry-api""" - return f"{self.NIFI_URL_SCHEME}://{self.NIFI_HOST}:{self.NIFI_REGISTRY_PORT}/nifi-registry-api" - - def auth_credentials(self) -> tuple[str, str]: - """Convenience for requests auth=(user, password).""" - return (self.NIFI_USERNAME, self.NIFI_PASSWORD) - - def get_nifi_ssl_certs(self) -> tuple[str, str]: - """Convenience for requests cert=(cert_path, key_path).""" - return (self.NIFI_CERT_PEM_PATH, self.NIFI_CERT_KEY_PATH) diff --git a/nifi/user-scripts/dto/pg_config.py b/nifi/user-scripts/dto/pg_config.py deleted file mode 100644 index 19f15d029..000000000 --- a/nifi/user-scripts/dto/pg_config.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel, Field - - -class PGConfig(BaseModel): - host: str = Field(default="localhost") - port: int = Field(default=5432) - db: str = Field(default="samples_db") - user: str = Field(default="test") - password: str = Field(default="test") - timeout: int = Field(default=50) diff --git a/nifi/user-scripts/dto/service_health.py b/nifi/user-scripts/dto/service_health.py deleted file mode 100644 index 5f6455dbb..000000000 --- a/nifi/user-scripts/dto/service_health.py +++ /dev/null @@ -1,51 +0,0 @@ -from datetime import datetime -from typing import Literal - -from pydantic import BaseModel, Field - - -class ServiceHealth(BaseModel): - """ - Base health check model shared by all services. - """ - - service: str = Field(..., description="Service name, e.g. NiFi, PostgreSQL, OpenSearch/ElasticSearch, etc.") - status: Literal["healthy", "unhealthy", "degraded"] = Field( - ..., description="Current service status" - ) - message: str | None = Field(None, description="Optional status message") - timestamp: datetime = Field(default_factory=datetime.utcnow) - avg_processing_ms: float | None = Field(None) - service_info: str | None = Field(None) - connected: bool | None = Field(None) - - class Config: - extra = "ignore" - -class MLServiceHealth(ServiceHealth): - model_name: str | None = Field(None, description="Name of the ML model") - model_version: str | None = Field(None, description="Version of the ML model") - model_card: str | None = Field(None, description="URL or path to the model card") - -class NiFiHealth(ServiceHealth): - active_threads: int | None = Field(None, description="Number of active threads") - queued_bytes: int | None = Field(None, description="Total queued bytes") - queued_count: int | None = Field(None, description="Number of queued flowfiles") - -class ElasticsearchHealth(ServiceHealth): - cluster_status: str | None = Field(None, description="Cluster health status") - node_count: int | None = Field(None) - active_shards: int | None = Field(None) - -class PostgresHealth(ServiceHealth): - version: str | None = Field(None) - latency_ms: float | None = Field(None, description="Ping latency in milliseconds") - db_name: str | None = Field(None, description="Database name") - -class MedCATTrainerHealth(ServiceHealth): - """Health check model for MedCAT Trainer web service.""" - app_version: str | None = Field(None, description="MedCAT Trainer app version") - -class CogstackCohortHealth(ServiceHealth): - """Health check model for CogStack Cohort service.""" - pass diff --git a/nifi/user-scripts/generate_location.py b/nifi/user-scripts/generate_location.py deleted file mode 100644 index 5d88f9af7..000000000 --- a/nifi/user-scripts/generate_location.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -import os -import sys -import traceback -from random import randrange - -import rancoord as rc - -global LOCATIONS -global NIFI_USER_SCRIPT_LOGS_DIR -global SUBJECT_ID_FIELD_NAME -global LOCATION_NAME_FIELD - -global output_stream - -LOG_FILE_NAME = "location_gen.log" -LOCATION_NAME_FIELD = "gen_location" - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "locations": - LOCATIONS = _arg[1] - elif _arg[0] == "user_script_logs_dir": - NIFI_USER_SCRIPT_LOGS_DIR = _arg[1] - elif _arg[0] == "subject_id_field": - SUBJECT_ID_FIELD_NAME = _arg[1] - elif _arg[0] == "location_name_field": - LOCATION_NAME_FIELD = _arg[1] - - -# generates a map polygon based on city names given -def poly_creator(city: str): - box = rc.nominatim_geocoder(city) - poly = rc.polygon_from_boundingbox(box) - return poly - - -def main(): - input_stream = sys.stdin.read() - - try: - log_file_path = os.path.join(NIFI_USER_SCRIPT_LOGS_DIR, str(LOG_FILE_NAME)) - patients = json.loads(input_stream) - - locations = [poly_creator(location) for location in LOCATIONS.split(",")] - - output_stream = [] - for patient in patients: - to_append = {} - - id = patient["_source"][SUBJECT_ID_FIELD_NAME] - idx = randrange(len(locations)) # pick a random location specified - lat, lon, _ = rc.coordinates_randomizer(polygon = locations[idx], num_locations = 1) # generate latitude and longitude - - to_append[SUBJECT_ID_FIELD_NAME] = id - to_append[LOCATION_NAME_FIELD] = "POINT (" + str(lon[0]) + " " + str(lat[0]) + ")" - output_stream.append(to_append) - except Exception: - if os.path.exists(log_file_path): - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(traceback.print_exc())) - else: - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(traceback.print_exc())) - finally: - return output_stream - - -sys.stdout.write(json.dumps(main())) diff --git a/nifi/user-scripts/get_files_from_storage.py b/nifi/user-scripts/get_files_from_storage.py deleted file mode 100644 index f1aefbbb2..000000000 --- a/nifi/user-scripts/get_files_from_storage.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/python3 - -import base64 -import json -import os -import re -import sys -import traceback -import uuid - -import numpy -import pandas - -# get the arguments from the "Command Arguments" property in NiFi, -# we are looking at anything after the 1st arg (which is the script name) -# example args: -# [ -# '/opt/nifi/user-scripts/get_files_from_storage.py', 'root_project_data_dir=/opt/data/', -# 'folder_pattern=.*\\d{4}\\/\\d{2}\\/\\d{2}', 'folder_to_ingest=2022', -# 'file_id_csv_column_name_match=file_name_id_no_ext' -# ] - -folder_to_ingest = "2022" -folder_pattern = ".*\d{4}\/\d{2}\/\d{2}" -file_id_csv_column_name_match = "file_name_id_no_ext" -root_project_data_dir = "/opt/data/" -csv_separator = "|" -output_batch_size = 1000 -# generates a separate pseudoID, in this case, UUID for the documents. -# useful when doc IDs are weird or a mess and you dont want to spend time cleaning. -generate_pseudo_doc_id = False - -# default: None, possible values: "files_only" - read files and only store their text & binary content (pre-ocr) and -# the file name as the document_Id -operation_mode = "" - -encoding="UTF-8" - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "folder_pattern": - folder_pattern = _arg[1] - elif _arg[0] == "folder_to_ingest": - folder_to_ingest = _arg[1] - elif _arg[0] == "file_id_csv_column_name_match": - file_id_csv_column_name_match = _arg[1] - elif _arg[0] == "root_project_data_dir": - root_project_data_dir = _arg[1] - elif _arg[0] == "csv_separator": - csv_separator = _arg[1] - elif _arg[0] == "output_batch_size": - output_batch_size = int(_arg[1]) - elif _arg[0] == "operation_mode": - operation_mode = str(_arg[1]) - elif _arg[0] == "generate_pseudo_doc_id": - generate_pseudo_doc_id = str(_arg[1]) - -# This is the DATA directory inside the postgres database Docker image, or it could be a folder on the local system -processed_folder_dump = "processed_" + folder_to_ingest -processed_folder_dump_path = os.path.join(str(os.environ.get("NIFI_USER_SCRIPT_LOGS_DIR", "/opt/nifi/user-scripts/logs/")), processed_folder_dump) -processed_folder_dump_path = processed_folder_dump_path.replace("\"", "").replace("\'", "") - -# log file name -ingested_folders_file = processed_folder_dump_path + ".log" - -pattern_c = re.compile(folder_pattern) - -folders_ingested = {} - -if os.path.exists(ingested_folders_file): - _folders_ingested_file = open(ingested_folders_file, "r+") - contents = _folders_ingested_file.read() - folders_ingested = json.loads(contents) if len(contents) > 0 else {} - _folders_ingested_file.close() - -output_data = [] - - -def get_files_and_metadata(): - '''_summary_ - This will read the pdf/docx/txt etc. documents and metadata.csv file from one folder and output them as a json, - NiFi will handle the conversion of this json to a proper flowfile using the ConvertRecord processor. - - It will only ingest one folder of that matches the yyyy/mm/dd pattern, then stop declared in the 'folder_pattern' variable. - The ingested folder is added to the list of ingested folders along with its files. - - # EXAMPLE folder & file structure - └── root_folder - └── 2022 - └── 08 - └── 01 - ├── aaaabbbbccccdddd.pdf <- file is the ID - └── metadata.csv <- contains file ID for matching - - ''' - - record_counter = 0 - - full_ingest_path = os.path.join(root_project_data_dir, folder_to_ingest) - - if not os.path.exists(full_ingest_path): - print("Could not open or find ingestion folder:" + str(full_ingest_path)) - - for root, sub_directories, files in os.walk(full_ingest_path): - # it will only ingest - if pattern_c.match(root): - if root not in folders_ingested: - folders_ingested[root] = [] - - txt_file_df = None - - doc_files = {} - csv_files = [] - - non_csvs = [file_name if "csv" not in file_name else csv_files.append(file_name) for file_name in files] - non_csvs = [file_name for file_name in non_csvs if file_name is not None] - - if len(non_csvs) != len(folders_ingested[root]): - - if operation_mode == "": - for csv_file_name in csv_files: - file_path = os.path.join(root, csv_file_name) - _txt_file_df = pandas.read_csv(file_path, sep=csv_separator, encoding=encoding) - _txt_file_df["binarydoc"] = pandas.Series(dtype=str) - _txt_file_df["text"] = pandas.Series(dtype=str) - - if txt_file_df is not None: - txt_file_df.append(_txt_file_df) - else: - txt_file_df = _txt_file_df - - for file_name in non_csvs: - extensionless_file_name = file_name[: - (len(file_name.split(".")[-1]) + 1)] - - if extensionless_file_name not in folders_ingested[root]: - file_path = os.path.join(root, file_name) - try: - if record_counter < output_batch_size: - with open(file_path, mode="rb") as original_file_contents: - original_file = original_file_contents.read() - doc_files[extensionless_file_name] = original_file - record_counter += 1 - else: - break - except Exception: - print("Failed to open file:" + file_path) - traceback.print_exc() - - try: - if txt_file_df is None and operation_mode == "files_only": - - # field names are taken from the cogstack common schema dict (cogstack_common_schema_mapping.json) - txt_file_df = pandas.DataFrame() - txt_file_df["document_Id"] = pandas.Series(dtype=str) - txt_file_df["binarydoc"] = pandas.Series(dtype=str) - txt_file_df["document_Fields_text"] = pandas.Series(dtype=str) - - if generate_pseudo_doc_id is not False: - txt_file_df["document_Pseudo_Id"] = pandas.Series(dtype=str) - - if txt_file_df is not None: - if operation_mode == "files_only": - for file_id in list(doc_files.keys()): - if file_id not in folders_ingested[root]: - _file_id_dict = { - "document_Id": str(file_id), - "binarydoc": base64.b64encode(doc_files[file_id]).decode(), - "document_Fields_text": "" - } - if generate_pseudo_doc_id is not False: - _file_id_dict["document_Pseudo_Id"] = str(uuid.uuid4().hex) - - txt_file_df = pandas.concat([txt_file_df, pandas.DataFrame.from_dict([_file_id_dict], orient="columns")]) - folders_ingested[root].append(file_id) - - else: - for i in range(0, len(txt_file_df)): - file_id = txt_file_df.iloc[i][file_id_csv_column_name_match] - - if file_id in list(doc_files.keys()) and file_id not in folders_ingested[root]: - txt_file_df.at[i, "binarydoc"] = base64.b64encode(doc_files[file_id]).decode() - txt_file_df.at[i, "text"] = "" - - if file_id not in folders_ingested[root]: - folders_ingested[root].append(file_id) - - txt_file_df = txt_file_df.loc[txt_file_df["binarydoc"].notna()] - txt_file_df = txt_file_df.replace(numpy.nan,'',regex=True) - - global output_data - - for i in range(0, len(txt_file_df)): - output_data.append(txt_file_df.iloc[i].to_dict()) - - except Exception: - print("failure") - traceback.print_exc() - - if record_counter >= output_batch_size - 1: - break - -get_files_and_metadata() - -with open(ingested_folders_file, "w+") as f: - f.write(json.dumps(folders_ingested)) - -sys.stdout.write(json.dumps(output_data)) diff --git a/nifi/user-scripts/legacy_scripts/annotation_creator.py b/nifi/user-scripts/legacy_scripts/annotation_creator.py deleted file mode 100644 index 6db6be63a..000000000 --- a/nifi/user-scripts/legacy_scripts/annotation_creator.py +++ /dev/null @@ -1,56 +0,0 @@ -import json -import os -import sys -import traceback - -from utils.sqlite_query import check_db_exists, connect_and_query, create_db_from_file - -ANNOTATION_DB_SQL_FILE_PATH = "/opt/cogstack-db/sqlite/schemas/annotations_nlp_create_schema.sql" - -# default values from /deploy/nifi.env -NIFI_USER_SCRIPT_DB_DIR = os.getenv("NIFI_USER_SCRIPT_DB_DIR") -NIFI_USER_SCRIPT_LOGS_DIR = os.getenv("NIFI_USER_SCRIPT_LOGS_DIR") - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "index_db_file_name": - INDEX_DB_FILE_NAME = _arg[1] - -LOG_FILE_NAME = "annotation_manager.log" - - -def main(): - input_stream = sys.stdin.read() - - try: - log_file_path = os.path.join(NIFI_USER_SCRIPT_LOGS_DIR, str(LOG_FILE_NAME)) - db_file_path = os.path.join(NIFI_USER_SCRIPT_DB_DIR, INDEX_DB_FILE_NAME) - - json_data_records = json.loads(input_stream) - - if len(check_db_exists("annotations", db_file_path)) == 0: - create_db_from_file(ANNOTATION_DB_SQL_FILE_PATH, db_file_path) - - output_stream = {} - # keep original structure of JSON: - output_stream["content"] = [] - - records = json_data_records["langs"]["buckets"] - for record in records: - query = "INSERT INTO annotations (elasticsearch_id) VALUES (" + '"' + str(record["key"]) + "_" + str(1) + '"' + ")" - result = connect_and_query(query, db_file_path, sql_script_mode=True) - - if len(result) == 0: - output_stream["content"].append(record) - - except Exception: - if os.path.exists(log_file_path): - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(traceback.print_exc())) - else: - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(traceback.print_exc())) - finally: - return output_stream - -sys.stdout.write(json.dumps(main())) diff --git a/nifi/user-scripts/legacy_scripts/annotation_manager.py b/nifi/user-scripts/legacy_scripts/annotation_manager.py deleted file mode 100644 index ffe124372..000000000 --- a/nifi/user-scripts/legacy_scripts/annotation_manager.py +++ /dev/null @@ -1,136 +0,0 @@ -import datetime -import json -import os -import sys -import traceback - -from utils.sqlite_query import check_db_exists, connect_and_query, create_connection, create_db_from_file - -global DOCUMENT_ID_FIELD_NAME -global DOCUMENT_TEXT_FIELD_NAME -global NIFI_USER_SCRIPT_DB_DIR -global DB_FILE_NAME -global LOG_FILE_NAME -global OPERATION_MODE - -ANNOTATION_DB_SQL_FILE_PATH = "/opt/cogstack-db/sqlite/schemas/annotations_nlp_create_schema.sql" - -# default values from /deploy/nifi.env -NIFI_USER_SCRIPT_DB_DIR = os.getenv("NIFI_USER_SCRIPT_DB_DIR") -NIFI_USER_SCRIPT_LOGS_DIR = os.getenv("NIFI_USER_SCRIPT_LOGS_DIR") - -# possible values: -# - check - check if a document ID has already been annotated -# - insert - inserts new annotation(s) into DB -OPERATION_MODE = "check" - -LOG_FILE_NAME = "annotation_manager.log" - -# get the arguments from the "Command Arguments" property in NiFi, we are looking at anything after the 1st arg (which is the script name) -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "index_db_file_name": - INDEX_DB_FILE_NAME = _arg[1] - elif _arg[0] == "document_id_field": - DOCUMENT_ID_FIELD_NAME = _arg[1] - elif _arg[0] == "document_text_field": - DOCUMENT_TEXT_FIELD_NAME = _arg[1] - elif _arg[0] == "user_script_db_dir": - NIFI_USER_SCRIPT_DB_DIR = _arg[1] - elif _arg[0] == "log_file_name": - LOG_FILE_NAME = _arg[1] - elif _arg[0] == "operation_mode": - OPERATION_MODE = _arg[1] - - -def main(): - input_stream = sys.stdin.read() - - output_stream = {} - - try: - log_file_path = os.path.join(NIFI_USER_SCRIPT_LOGS_DIR, str(LOG_FILE_NAME)) - db_file_path = os.path.join(NIFI_USER_SCRIPT_DB_DIR, INDEX_DB_FILE_NAME) - - json_data_records = json.loads(input_stream) - - if len(check_db_exists("annotations", db_file_path)) == 0: - create_db_from_file(ANNOTATION_DB_SQL_FILE_PATH, db_file_path) - - records = json_data_records - - _sqlite_connection_ro = None - _sqlite_connection_rw = None - - if isinstance(records, dict): - if "content" in json_data_records.keys(): - records = json_data_records["content"] - if len(records) <= 1 and isinstance(records, dict): - records = [records] - - if OPERATION_MODE == "check": - output_stream = {} - # keep original structure of JSON: - output_stream["content"] = [] - _sqlite_connection_ro = create_connection(db_file_path, read_only_mode=True) - - if OPERATION_MODE == "insert": - del output_stream - output_stream = [] - _sqlite_connection_rw = create_connection(db_file_path, read_only_mode=False) - - _cursor = _sqlite_connection_ro.cursor() if _sqlite_connection_ro is not None else _sqlite_connection_rw.cursor() - - for record in records: - if OPERATION_MODE == "check": - if "footer" in record.keys(): - _document_id = str(record["footer"][DOCUMENT_ID_FIELD_NAME]) - else: - _document_id = str(record[DOCUMENT_ID_FIELD_NAME]) - query = "SELECT id FROM annotations WHERE document_id = " + '"' + _document_id + '"' + " LIMIT 1" - result = connect_and_query(query, db_file_path, sqlite_connection=_sqlite_connection_ro, cursor=_cursor, keep_conn_open=True) - - if len(result) < 1: - output_stream["content"].append(record) - - if OPERATION_MODE == "insert": - - nlp_id = None - - # dt4h compatibility - if "nlp_output" in record.keys(): - if "record_metadata" in record["nlp_output"].keys(): - _document_id = str(record["nlp_output"]["record_metadata"]["id"]) - if "annotations" in record["nlp_output"].keys(): - index = 0 - for annotation in record["nlp_output"]["annotations"]: - nlp_id = str(index) - query = "INSERT OR REPLACE INTO annotations (id, document_id) VALUES (" + '"' + _document_id + "_" + nlp_id + '"' + "," + '"' + _document_id + '"' +")" - result = connect_and_query(query, db_file_path, sqlite_connection=_sqlite_connection_rw, sql_script_mode=True, cursor=_cursor, keep_conn_open=True) - index += 1 - - output_stream.append(record) - else: - _document_id = str(record["meta." + DOCUMENT_ID_FIELD_NAME]) - nlp_id = str(record["nlp.id"]) - - query = "INSERT OR REPLACE INTO annotations (id, document_id) VALUES (" + '"' + _document_id + "_" + nlp_id + '"' + "," + '"' + _document_id + '"' +")" - result = connect_and_query(query, db_file_path, sqlite_connection=_sqlite_connection_rw, sql_script_mode=True, cursor=_cursor, keep_conn_open=True) - output_stream.append(record) - - if _cursor is not None: - _cursor.close() - if _sqlite_connection_ro is not None: - _sqlite_connection_ro.close() - if _sqlite_connection_rw is not None: - _sqlite_connection_rw.close() - except Exception as exception: - time = datetime.datetime.now() - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(time) + ": " + str(exception)) - log_file.write("\n" + str(time) + ": " + traceback.format_exc()) - - return output_stream - - -sys.stdout.write(json.dumps(main())) diff --git a/nifi/user-scripts/legacy_scripts/annotation_manager_docs.py b/nifi/user-scripts/legacy_scripts/annotation_manager_docs.py deleted file mode 100644 index 844ca6acd..000000000 --- a/nifi/user-scripts/legacy_scripts/annotation_manager_docs.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -import os -import sys -import traceback - -from utils.sqlite_query import check_db_exists, connect_and_query, create_db_from_file - -global DOCUMENT_ID_FIELD_NAME -global DOCUMENT_TEXT_FIELD_NAME -global NIFI_USER_SCRIPT_DB_DIR -global DB_FILE_NAME -global LOG_FILE_NAME -global OPERATION_MODE - -global output_stream - -ANNOTATION_DB_SQL_FILE_PATH = "/opt/cogstack-db/sqlite/schemas/annotations_nlp_create_schema.sql" - -# default values from /deploy/nifi.env -NIFI_USER_SCRIPT_DB_DIR = os.getenv("NIFI_USER_SCRIPT_DB_DIR") -NIFI_USER_SCRIPT_LOGS_DIR = os.getenv("NIFI_USER_SCRIPT_LOGS_DIR") - -LOG_FILE_NAME = "annotation_manager.log" - -# get the arguments from the "Command Arguments" property in NiFi, we are looking at anything after the 1st arg (which is the script name) -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "index_db_file_name": - INDEX_DB_FILE_NAME = _arg[1] - elif _arg[0] == "document_id_field": - DOCUMENT_ID_FIELD_NAME = _arg[1] - elif _arg[0] == "user_script_db_dir": - NIFI_USER_SCRIPT_DB_DIR = _arg[1] - elif _arg[0] == "log_file_name": - LOG_FILE_NAME = _arg[1] - -def main(): - input_stream = sys.stdin.read() - - try: - log_file_path = os.path.join(NIFI_USER_SCRIPT_LOGS_DIR, str(LOG_FILE_NAME)) - db_file_path = os.path.join(NIFI_USER_SCRIPT_DB_DIR, INDEX_DB_FILE_NAME) - - json_data_records = json.loads(input_stream) - records = json_data_records["result"] - - if len(check_db_exists("annotations", db_file_path)) == 0: - create_db_from_file(ANNOTATION_DB_SQL_FILE_PATH, db_file_path) - - output_stream = {} - - # keep original structure of JSON: - output_stream["result"] = [] - output_stream["medcat_info"] = json_data_records["medcat_info"] - for record in records: - query = "INSERT INTO annotations (elasticsearch_id) VALUES (" + '"' + str(record["footer"][DOCUMENT_ID_FIELD_NAME]) + "_" + str(1) + '"' + ")" - result = connect_and_query(query, db_file_path, sql_script_mode=True) - output_stream["result"].append(record) - - except Exception: - if os.path.exists(log_file_path): - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(traceback.print_exc())) - else: - with open(log_file_path, "a+") as log_file: - log_file.write("\n" + str(traceback.print_exc())) - finally: - return output_stream - -sys.stdout.write(json.dumps(main())) diff --git a/nifi/user-scripts/legacy_scripts/anonymise_doc.py b/nifi/user-scripts/legacy_scripts/anonymise_doc.py deleted file mode 100644 index 1d066b19f..000000000 --- a/nifi/user-scripts/legacy_scripts/anonymise_doc.py +++ /dev/null @@ -1,59 +0,0 @@ -# tested with medcat==1.5.3 - -import ast -import json -import os -import sys - -from medcat.cat import CAT -from medcat.utils.ner import deid_text - -input_text = sys.stdin.read() - -MODEL_PACK_PATH = os.environ.get("MODEL_PACK_PATH", "/opt/models/de_id_base.zip") - -TEXT_FIELD_NAME = "document" -NPROC = 100 - -# if there are issues with DE-ID model not working on certain long documents please play around with the character limit -# dependent on the tokenizer used -CHAR_LIMIT = 512 - -REDACT = True - -for arg in sys.argv: - _arg = arg.split("=", 1) - if _arg[0] == "model_pack_path": - MODEL_PACK_PATH = _arg[1] - if _arg[0] == "text_field_name": - TEXT_FIELD_NAME = _arg[1] - if _arg[0] == "nproc": - NPROC = _arg[1] - if _arg[0] == "char_limit": - CHAR_LIMIT = int(_arg[1]) - if _arg[0] == "redact": - REDACT = ast.literal_eval(_arg[1]) - -records = json.loads(str(input_text)) -final_records = [] - -cat = CAT.load_model_pack(MODEL_PACK_PATH) - -for record in records: - if TEXT_FIELD_NAME in record.keys(): - text_field = record[TEXT_FIELD_NAME] - _anon_text = "" - if len(text_field) > CHAR_LIMIT: - sections = int(len(text_field) / CHAR_LIMIT) - - for i in range(0, sections): - _tmp_text = text_field[i * CHAR_LIMIT:(i + 1) * CHAR_LIMIT] - _anon_text += deid_text(cat, _tmp_text, redact=REDACT) - else: - _anon_text = deid_text(cat, text_field, redact=REDACT) - record[TEXT_FIELD_NAME] = _anon_text - final_records.append(record) - else: - final_records.append(record) - -sys.stdout.write(json.dumps(final_records)) \ No newline at end of file diff --git a/nifi/user-scripts/legacy_scripts/flowfile_to_attribute_with_content.py b/nifi/user-scripts/legacy_scripts/flowfile_to_attribute_with_content.py deleted file mode 100644 index 48cd3c078..000000000 --- a/nifi/user-scripts/legacy_scripts/flowfile_to_attribute_with_content.py +++ /dev/null @@ -1,99 +0,0 @@ -import copy -import io -import json -import traceback - -# jython packages -# other packages, normally available to python 2.7 -from avro.datafile import DataFileReader -from avro.io import DatumReader -from org.apache.commons.io import IOUtils -from org.apache.nifi.processor.io import OutputStreamCallback, StreamCallback -from org.python.core.util import StringUtil - -""" - This script converts a flow file with avro/json content with all the record's fields that has to flow file attributes. -""" - -global flowFile - -global OPERATION_MODE -global FIELD_NAMES_TO_KEEP_AS_CONTENT - -flowFile = session.get() - -output_flowFiles = [] - -class WriteContentCallback(OutputStreamCallback): - def __init__(self, content): - self.content_text = content - - def process(self, outputStream): - try: - outputStream.write(StringUtil.toBytes(self.content_text)) - except: - traceback.print_exc(file=sys.stdout) - raise - -class PyStreamCallback(StreamCallback): - def __init__(self): - pass - - def process(self, inputStream, outputStream): - bytes_arr = IOUtils.toByteArray(inputStream) - bytes_io = io.BytesIO(bytes_arr) - - - if OPERATION_MODE == "json": - records = json.loads(bytes_io.read()) - elif OPERATION_MODE == "avro": - records = DataFileReader(bytes_io, DatumReader()) - - for record in records: - metadata = copy.deepcopy(records.meta) - schema_from_file = json.loads(metadata["avro.schema"]) - new_flow_file = session.create(flowFile) - - new_flow_file = session.putAttribute(new_flow_file, "attribute_list", str(list(record.keys()))) - new_flow_file = session.putAttribute(new_flow_file, "avro_schema", json.dumps(schema_from_file)) - - for k, v in record.iteritems(): - if k != FIELD_NAMES_TO_KEEP_AS_CONTENT: - new_flow_file = session.putAttribute(new_flow_file, k, str(v)) - if FIELD_NAMES_TO_KEEP_AS_CONTENT != "" and k == FIELD_NAMES_TO_KEEP_AS_CONTENT: - new_flow_file = session.write(new_flow_file, WriteContentCallback(str(v).encode("UTF-8"))) - output_flowFiles.append(new_flow_file) - - if type(records) == DataFileReader: - records.close() - - -if flowFile != None: - # possible values: - # - avro, convert avro flowfile fields to flowfile attributes, keep only what is needed as content - # - json, convert json flowfile fields to attributes - OPERATION_MODE_PROPERTY_NAME = "operation_mode" - - FIELD_NAMES_TO_KEEP_AS_CONTENT_PROPERTY_NAME = "keep_fields_as_content" - - FIELD_NAMES_TO_KEEP_AS_CONTENT = [] - - OPERATION_MODE = str(context.getProperty(OPERATION_MODE_PROPERTY_NAME)).lower() - - if OPERATION_MODE == "" or OPERATION_MODE is None: - OPERATION_MODE = "avro" - - FIELD_NAMES_TO_KEEP_AS_CONTENT = str(context.getProperty(FIELD_NAMES_TO_KEEP_AS_CONTENT_PROPERTY_NAME)).lower() - - try: - #flowFile_content = IOUtils.toString(session.read(flowFile), StandardCharsets.UTF_8) - flowFile = session.write(flowFile, PyStreamCallback()) - - session.transfer(output_flowFiles, REL_SUCCESS) - session.remove(flowFile) - except Exception: - log.error(traceback.format_exc()) - session.transfer(flowFile, REL_FAILURE) - -else: - session.transfer(flowFile, REL_FAILURE) diff --git a/nifi/user-scripts/legacy_scripts/ingest_into_es.py b/nifi/user-scripts/legacy_scripts/ingest_into_es.py deleted file mode 100644 index cf1f09d06..000000000 --- a/nifi/user-scripts/legacy_scripts/ingest_into_es.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 - -""" -Ingest PostgreSQL database samples into Elasticsearch -""" - -import psycopg2 -from elasticsearch import Elasticsearch - -es = Elasticsearch(["http://0.0.0.0:9200"], http_auth=("admin", "admin")) -con = psycopg2.connect(database="db_samples", user="test", password="test", - host="0.0.0.0", port="5555") -tables = ["patients", "medical_reports_text", "encounters", "observations"] - - -def _get_column_names_and_data(table_name): - cur = con.cursor() - cur.execute("SELECT * FROM {0}".format(table_name)) - - rows = cur.fetchall() - - column_names = [cur.description[i][0] for i in range(len(cur.description))] - - return rows, column_names - - -def _insert_table_into_es(table_name): - rows, columns_names = _get_column_names_and_data(table_name) - - for idx, row in enumerate(rows): - body = dictionary = dict(zip(columns_names, row)) - index_name = "samples_%s" % table_name - - es.index(index=index_name, id=idx, body=body) - - -def main(): - for table in tables: - print("Indexing table", table) - _insert_table_into_es(table) - print("Completed", table) - - -if __name__ == "__main__": - main() diff --git a/nifi/user-scripts/legacy_scripts/parse-anns-from-nlp-response-bulk.py b/nifi/user-scripts/legacy_scripts/parse-anns-from-nlp-response-bulk.py deleted file mode 100644 index f53bdfe81..000000000 --- a/nifi/user-scripts/legacy_scripts/parse-anns-from-nlp-response-bulk.py +++ /dev/null @@ -1,166 +0,0 @@ -import io -import json -import sys -import traceback - -# jython packages -from org.apache.commons.io import IOUtils -from org.apache.nifi.processor.io import OutputStreamCallback, StreamCallback -from org.python.core.util import StringUtil - -global flowFile - -global DOCUMENT_ID_FIELD_NAME - -global ANNOTATION_ID_FIELD_NAME -global ANNOTATION_TYPES_TO_IGNORE - -flowFile = session.get() - -flowFiles = [] - -FIELD_NLP_PREFIX = 'nlp.' -FIELD_META_PREFIX = 'meta.' - - -class WriteContentCallback(OutputStreamCallback): - def __init__(self, content): - self.content_text = content - - def process(self, outputStream): - try: - outputStream.write(StringUtil.toBytes(self.content_text)) - except Exception: - traceback.print_exc(file=sys.stdout) - raise - - -class PyStreamCallback(StreamCallback): - def __init__(self): - pass - - def process(self, inputStream, outputStream): - bytes_arr = IOUtils.toByteArray(inputStream) - bytes_io = io.BytesIO(bytes_arr) - - json_data_records = json.loads(bytes_io.read()) - - bytes_io.close() - - result = json_data_records - - if ANNOTATION_OUTPUT_TYPE == "medcat": - result = json_data_records["result"] - medcat_info = json_data_records["medcat_info"] - elif type(json_data_records) is list: - medcat_info = json_data_records[0]["nlp_service_info"] - - for annotated_text_record in result: - doc_id = "" - if ANNOTATION_OUTPUT_TYPE != "medcat": - annotated_text_record = annotated_text_record["nlp_output"] - doc_id = str(annotated_text_record["record_metadata"]["id"]) - - # skip if this document has no annotations - if "annotations" not in annotated_text_record: - continue - if len(annotated_text_record["annotations"]) == 0: - continue - - annotations = annotated_text_record["annotations"] - - footer = {} - if "footer" in list(annotated_text_record.keys()): - footer = annotated_text_record["footer"] - - new_footer = {} - - if ANNOTATION_OUTPUT_TYPE == "medcat": - assert DOCUMENT_ID_FIELD_NAME in footer.keys() - doc_id = footer[DOCUMENT_ID_FIELD_NAME] - else: - # this seciton is for non-medcat annotations (e.g dt4h-annotator) - # skip if no doc id is found - if not doc_id: - continue - - # transform to medcat format - if type(annotations) is list: - _annotaitions = {} - for i in range(len(annotations)): - _annotaitions[str(i)] = annotations[i] - annotations = _annotaitions - - for k,v in footer.iteritems(): - if k in ORIGINAL_FIELDS_TO_INCLUDE: - new_footer[str(FIELD_META_PREFIX + k)] = v - - # sometimes there's an empty annotation list - if type(annotations) is dict and len(annotations.keys()) == 0: - log.info("Empty annotation list - " + str(footer[DOCUMENT_ID_FIELD_NAME])) - continue - - anns_ids = [] - for ann_id, annotation in annotations.iteritems(): - ignore_annotation = False - anns_ids.append(ann_id) - - for type_to_ignore in ANNOTATION_TYPES_TO_IGNORE: - if type_to_ignore in annotation["types"]: - ignore_annotation = True - break - - if ignore_annotation is False: - new_ann_record = {} - for k,v in annotation.iteritems(): - new_ann_record[FIELD_NLP_PREFIX + str(k)] = v - - new_ann_record["service_model"] = medcat_info["service_model"] - new_ann_record["service_version"] = medcat_info["service_version"] - - if "timestamp" not in new_ann_record.keys(): - new_ann_record["timestamp"] = annotated_text_record["record_metadata"]["nlp_processing_date"] - else: - new_ann_record["timestamp"] = annotated_text_record["timestamp"] - - log.info(str(doc_id)) - # create the new _id for the annotation record in ElasticSearch - - new_ann_record[FIELD_META_PREFIX + DOCUMENT_ID_FIELD_NAME] = doc_id - new_ann_record.update(new_footer) - - if ANNOTATION_OUTPUT_TYPE != "medcat": - document_annotation_id = str(doc_id) + "_" + str(ann_id) - else: - document_annotation_id = str(doc_id) + "_" + str(annotation[ANNOTATION_ID_FIELD_NAME]) - - new_flow_file = session.create(flowFile) - new_flow_file = session.putAttribute(new_flow_file, "document_annotation_id", document_annotation_id) - new_flow_file = session.putAttribute(new_flow_file, "mime.type", "application/json") - new_flow_file = session.write(new_flow_file, WriteContentCallback(json.dumps(new_ann_record).encode("UTF-8"))) - flowFiles.append(new_flow_file) - - -if flowFile is not None: - DOCUMENT_ID_FIELD_NAME = str(context.getProperty("document_id_field")) - ANNOTATION_ID_FIELD_NAME = str(context.getProperty("annotation_id_field")) - - _tmp_ann_type_ignore = str(context.getProperty("ignore_annotation_types")) - _tmp_original_record_fields_to_include = str(context.getProperty("original_record_fields_to_include")) - - ANNOTATION_OUTPUT_TYPE = str(context.getProperty("annotation_output_type")).strip().lower() - ANNOTATION_OUTPUT_TYPE = "other" if ANNOTATION_OUTPUT_TYPE not in ["medcat", ""] else "medcat" - - ORIGINAL_FIELDS_TO_INCLUDE = _tmp_original_record_fields_to_include.split(",") if _tmp_original_record_fields_to_include.lower() != "none" else [] - ANNOTATION_TYPES_TO_IGNORE = _tmp_ann_type_ignore.split(",") if _tmp_ann_type_ignore.lower() != "none" else [] - - try: - flowFile = session.write(flowFile, PyStreamCallback()) - session.transfer(flowFiles, REL_SUCCESS) - session.remove(flowFile) - except Exception: - log.error(traceback.format_exc()) - session.transfer(flowFile, REL_FAILURE) - -else: - session.transfer(flowFile, REL_FAILURE) diff --git a/nifi/user-scripts/legacy_scripts/parse-es-db-result-for-nlp-request-bulk.py b/nifi/user-scripts/legacy_scripts/parse-es-db-result-for-nlp-request-bulk.py deleted file mode 100644 index 0e42174e1..000000000 --- a/nifi/user-scripts/legacy_scripts/parse-es-db-result-for-nlp-request-bulk.py +++ /dev/null @@ -1,110 +0,0 @@ -import io -import json -import os -import traceback - -# jython packages -from org.apache.commons.io import IOUtils -from org.apache.nifi.processor.io import StreamCallback - -global flowFile - -global DOCUMENT_ID_FIELD_NAME -global DOCUMENT_TEXT_FIELD_NAME - -global invalid_record_ids - -flowFile = session.get() - -class PyStreamCallback(StreamCallback): - def __init__(self): - pass - - def process(self, inputStream, outputStream): - bytes_arr = IOUtils.toByteArray(inputStream) - bytes_io = io.BytesIO(bytes_arr) - json_data_records = json.loads(str(bytes_io.read())) - - if type(json_data_records) == dict: - json_data_records = [json_data_records] - - out_records = [] - for record in json_data_records: - out_record = {"footer": {}} - - # if we are pulling from DB we won't have the json fields/_source keys. - FIELD_TO_CHECK = None - - if "fields" in record.keys(): - FIELD_TO_CHECK = "fields" - elif "_source" in record.keys(): - FIELD_TO_CHECK = "_source" - - if FIELD_TO_CHECK is not None: - _record = record[FIELD_TO_CHECK] - else: - _record = record - - for k, v in _record.iteritems(): - if k!= DOCUMENT_TEXT_FIELD_NAME: - out_record["footer"][k] = v - - try: - if DOCUMENT_ID_FIELD_NAME == "_id" and FIELD_TO_CHECK is not None: - out_record["id"] = record["_id"] - out_record["footer"]["_id"] = record["_id"] - else: - if DOCUMENT_ID_FIELD_NAME in _record.keys(): - out_record["id"] = _record[DOCUMENT_ID_FIELD_NAME] - out_record["footer"][DOCUMENT_ID_FIELD_NAME] = _record[DOCUMENT_ID_FIELD_NAME] - - if DOCUMENT_TEXT_FIELD_NAME in _record.keys(): - if len(_record[DOCUMENT_TEXT_FIELD_NAME]) > 1: - out_record["text"] = _record[DOCUMENT_TEXT_FIELD_NAME] - out_records.append(out_record) - else: - invalid_record_ids.append(record[DOCUMENT_ID_FIELD_NAME]) - log.debug("Document id :" + str(record[DOCUMENT_ID_FIELD_NAME]) + ", text field has no content, document will not be added to the queue.") - else: - invalid_record_ids.append(record) - log.debug("Document id :" + str(record[DOCUMENT_ID_FIELD_NAME]) + " , has no field named " + DOCUMENT_TEXT_FIELD_NAME + ", document will not be added to the queue.") - except KeyError: - invalid_record_ids.append(record) - log.debug(str(record["_id"]) + " , has no field named " + DOCUMENT_TEXT_FIELD_NAME + ", document will not be added to the queue.") - - outputStream.write(json.dumps({"content": out_records}).encode("UTF-8")) - -if flowFile != None: - - DOCUMENT_ID_PROPERTY_NAME="document_id_field" - DOCUMENT_TEXT_PROPERTY_NAME="document_text_field" - - DOCUMENT_ID_FIELD_NAME = str(context.getProperty(DOCUMENT_ID_PROPERTY_NAME)) - DOCUMENT_TEXT_FIELD_NAME = str(context.getProperty(DOCUMENT_TEXT_PROPERTY_NAME)) - - # needs to be set to True/False - LOG_INVALID_RECORDS = bool(str(context.getProperty("log_invalid_records_to_file"))) - LOG_FILE_NAME = str(context.getProperty("log_file_name")) - - invalid_record_ids = [] - - try: - flowFile = session.write(flowFile, PyStreamCallback()) - flowFile = session.putAttribute(flowFile, "invalid_record_ids", str(invalid_record_ids)) - session.transfer(flowFile, REL_SUCCESS) - except Exception: - log.error(traceback.format_exc()) - flowFile = session.putAttribute(flowFile, "invalid_record_ids", str(invalid_record_ids)) - session.transfer(flowFile, REL_FAILURE) - finally: - if LOG_INVALID_RECORDS: - log_file_path = os.path.join(str(os.environ.get("NIFI_USER_SCRIPT_LOGS_DIR", "/opt/nifi/user-scripts/logs/")), str(LOG_FILE_NAME)) - _out_list = ','.join(str(x) for x in invalid_record_ids) - if os.path.exists(log_file_path) and len(invalid_record_ids) > 0: - with open(log_file_path, "a+") as log_file: - log_file.write("," + _out_list) - else: - with open(log_file_path, "w+") as log_file: - log_file.write(_out_list) -else: - session.transfer(flowFile, REL_FAILURE) diff --git a/nifi/user-scripts/legacy_scripts/parse-json-to-avro.py b/nifi/user-scripts/legacy_scripts/parse-json-to-avro.py deleted file mode 100644 index 9e434b650..000000000 --- a/nifi/user-scripts/legacy_scripts/parse-json-to-avro.py +++ /dev/null @@ -1,62 +0,0 @@ -import json -import os -import sys -import uuid -from datetime import datetime as time - -import avro -from avro.datafile import DataFileWriter -from avro.io import DatumWriter - -log_file_path = "/opt/nifi/user-scripts/logs/parse_json/parse-json-to-avro_file_" - -time = str(time.now().timestamp()) - -input_stream = sys.stdin.read() -records_stream = None - -try: - records_stream = json.loads(input_stream) -except Exception: - _log_file_path = log_file_path + time + ".log" - with open(_log_file_path, "a+") as log_file: - log_file.write(input_stream) - - -schema = { - "type": "record", - "name": "inferAvro", - "namespace":"org.apache.nifi", - "fields": [] -} - -fields = list(records_stream[0].keys()) - -for field_name in fields: - schema["fields"].append({"name": field_name, "type": ["null", {"type": "long", "logicalType": "timestamp-millis"}, "string"]}) - -avro_schema = avro.schema.parse(json.dumps(schema)) - -file_id = str(uuid.uuid4().hex) - -tmp_file_path = os.path.join("/opt/nifi/user-scripts/tmp/" + file_id + ".avro") - -with open(tmp_file_path, mode="wb+") as tmp_file: - writer = DataFileWriter(tmp_file, DatumWriter(), avro_schema) - - for _record in records_stream: - writer.append(_record) - - writer.close() - -tmp_file = open(tmp_file_path, "rb") - -tmp_file_data = tmp_file.read() - -tmp_file.close() - -# delete file temporarly created above -if os.path.isfile(tmp_file_path): - os.remove(tmp_file_path) - -sys.stdout.buffer.write(tmp_file_data) diff --git a/nifi/user-scripts/legacy_scripts/parse-tika-result-json-to-avro.py b/nifi/user-scripts/legacy_scripts/parse-tika-result-json-to-avro.py deleted file mode 100644 index 9f54fb650..000000000 --- a/nifi/user-scripts/legacy_scripts/parse-tika-result-json-to-avro.py +++ /dev/null @@ -1,69 +0,0 @@ -import ast -import io -import json -import os -import sys -import traceback - -# jython packages -from org.apache.commons.io import IOUtils -from org.apache.nifi.processor.io import StreamCallback - -""" - We add this because we have extra pip packages installed in our separate version of Jython - this is usually declared in he "Module Directory" property of the ExecuteScript NiFi processor, - if it is declared there there's no need to include it here. -""" -JYTHON_HOME = os.environ.get("JYTHON_HOME", "") -sys.path.append(JYTHON_HOME + "/Lib/site-packages") - -global flowFile - -flowFile = session.get() - -class PyStreamCallback(StreamCallback): - def __init__(self): - pass - - def process(self, inputStream, outputStream): - bytes_arr = IOUtils.toByteArray(inputStream) - bytes_io = io.BytesIO(bytes_arr) - - json_data_record = json.loads(str(bytes_io.read()).encode("UTF-8")) - - # add text and metadata column to record - avro_data_dict["text"] = json_data_record["result"]["text"] - avro_data_dict["ocr_metadata"] = json_data_record["result"]["metadata"] - avro_data_dict["ocr_timestamp"] = json_data_record["result"]["timestamp"] - - outputStream.write(json.dumps(avro_data_dict).encode("UTF-8")) - -if flowFile != None: - DOC_ID_ATTRIBUTE_NAME = "document_id_field" - BINARY_FIELD_NAME = "binary_field" - OUTPUT_TEXT_FIELD_NAME = "output_text_field_name" - - doc_id_property = str(flowFile.getAttribute(DOC_ID_ATTRIBUTE_NAME)) - binary_data_property = str(flowFile.getAttribute(BINARY_FIELD_NAME)) - global output_text_property - output_text_property = str(flowFile.getAttribute(OUTPUT_TEXT_FIELD_NAME)) - - try: - # these are the column names from the DB/data_source - avro_record_data_source_columns = ast.literal_eval(str(flowFile.getAttribute("avro_keys"))) - - global avro_data_dict - avro_data_dict = {} - - for column_name in avro_record_data_source_columns: - avro_data_dict[column_name] = flowFile.getAttribute(column_name) - - flowFile = session.write(flowFile, PyStreamCallback()) - session.transfer(flowFile, REL_SUCCESS) - except Exception as exception: - log.error(traceback.format_exc()) - log.error(str(exception)) - session.transfer(flowFile, REL_FAILURE) - -else: - session.transfer(flowFile, REL_FAILURE) diff --git a/nifi/user-scripts/legacy_scripts/prepare-db-record-for-tika-request-single.py b/nifi/user-scripts/legacy_scripts/prepare-db-record-for-tika-request-single.py deleted file mode 100644 index c8daea003..000000000 --- a/nifi/user-scripts/legacy_scripts/prepare-db-record-for-tika-request-single.py +++ /dev/null @@ -1,96 +0,0 @@ -import base64 -import io -import os -import sys -import traceback - -# jython packages -from org.apache.commons.io import IOUtils -from org.apache.nifi.processor.io import StreamCallback - -""" - We add this because we have extra pip packages installed in our separate version of Jython - this is usually declared in he "Module Directory" property of the ExecuteScript NiFi processor, - if it is declared there there's no need to include it here. -""" -JYTHON_HOME = os.environ.get("JYTHON_HOME", "") -sys.path.append(JYTHON_HOME + "/Lib/site-packages") - -# other packages, normally available to python 2.7 -from avro.datafile import DataFileReader -from avro.io import DatumReader - -""" - Below are the object descriptions, these are used throughout NiFi jython/groovy scripts - - flowfile: https://www.javadoc.io/doc/org.apache.nifi/nifi-api/latest/org/apache/nifi/flowfile/FlowFile.html - - context: https://www.javadoc.io/doc/org.apache.nifi/nifi-api/latest/org/apache/nifi/processor/ProcessContext.html - - session: https://www.javadoc.io/doc/org.apache.nifi/nifi-api/latest/org/apache/nifi/processor/ProcessSession.html - - log: https://www.javadoc.io/doc/org.apache.nifi/nifi-api/latest/org/apache/nifi/logging/ComponentLog.html - - propertyValue: https://www.javadoc.io/doc/org.apache.nifi/nifi-api/latest/org/apache/nifi/components/PropertyValue.html -""" - -global flowFile -global OPERATION_MODE - -flowFile = session.get() - -class PyStreamCallback(StreamCallback): - def __init__(self): - pass - - def process(self, inputStream, outputStream): - bytes_arr = IOUtils.toByteArray(inputStream) - bytes_io = io.BytesIO(bytes_arr) - reader = DataFileReader(bytes_io, DatumReader()) - - global avro_record - avro_record = reader.next() # Get first Avro record. Also can be iterated in loop - - binary_data = None - for record_attr_name in avro_record.keys(): - if binary_data_property == record_attr_name: - # remove the binary content, no need to have a duplicate - binary_data = avro_record[binary_data_property] - - if OPERATION_MODE == "base64": - binary_data = base64.b64decode(binary_data).decode() - - del avro_record[binary_data_property] - break - - # write the binary directly to the flow file - outputStream.write(binary_data) - -if flowFile != None: - DOC_ID_ATTRIBUTE_NAME = "document_id_field" - BINARY_FIELD_NAME = "binary_field" - OUTPUT_TEXT_FIELD_NAME = "output_text_field_name" - - doc_id_property = str(context.getProperty(DOC_ID_ATTRIBUTE_NAME)) - binary_data_property = str(context.getProperty(BINARY_FIELD_NAME)) - output_text_property = str(context.getProperty(OUTPUT_TEXT_FIELD_NAME)) - - # check if this has been set - OPERATION_MODE = str(context.getProperty("operation_mode")) - - try: - flowFile = session.write(flowFile, PyStreamCallback()) - - out_data = {k : str(v) for k,v in avro_record.iteritems()} - out_data["avro_keys"] = str(out_data.keys()) - out_data["output_text_property"] = output_text_property - out_data[DOC_ID_ATTRIBUTE_NAME] = doc_id_property - out_data[BINARY_FIELD_NAME] = binary_data_property - out_data[OUTPUT_TEXT_FIELD_NAME] = output_text_property - - flowFile = session.putAllAttributes(flowFile, out_data) - - session.transfer(flowFile, REL_SUCCESS) - except Exception as exception: - log.error(traceback.format_exc()) - log.error(str(exception)) - session.transfer(flowFile, REL_FAILURE) - -else: - session.transfer(flowFile, REL_FAILURE) - diff --git a/nifi/user-scripts/legacy_scripts/prepare-file-for-tika-request-single-keep-db-fields.py b/nifi/user-scripts/legacy_scripts/prepare-file-for-tika-request-single-keep-db-fields.py deleted file mode 100644 index 6b2787e50..000000000 --- a/nifi/user-scripts/legacy_scripts/prepare-file-for-tika-request-single-keep-db-fields.py +++ /dev/null @@ -1,77 +0,0 @@ -import base64 -import io -import os -import sys -import traceback - -# jython packages -from org.apache.commons.io import IOUtils -from org.apache.nifi.processor.io import StreamCallback - -""" - We add this because we have extra pip packages installed in our separate version of Jython - this is usually declared in he "Module Directory" property of the ExecuteScript NiFi processor, - if it is declared there there's no need to include it here. -""" -JYTHON_HOME = os.environ.get("JYTHON_HOME", "") -sys.path.append(JYTHON_HOME + "/Lib/site-packages") - -global flowFile -global operation_mode - -flowFile = session.get() - -class PyStreamCallback(StreamCallback): - def __init__(self): - pass - - def process(self, inputStream, outputStream): - bytes_arr = IOUtils.toByteArray(inputStream) - bytes_io = io.BytesIO(bytes_arr) - reader = DataFileReader(bytes_io, DatumReader()) - - global avro_record - avro_record = reader.next() # Get first Avro record. Also can be iterated in loop - - binary_data = None - for record_attr_name in avro_record.keys(): - if binary_data_property == record_attr_name: - # remove the binary content, no need to have a duplicate - binary_data = avro_record[binary_data_property] - - if operation_mode == "base64": - binary_data = base64.b64decode(binary_data) - - del avro_record[binary_data_property] - break - - # write the binary directly to the flow file - outputStream.write(binary_data) - -if flowFile != None: - DOC_ID_ATTRIBUTE_NAME = "document_id_field" - BINARY_FIELD_NAME = "binary_field" - OUTPUT_TEXT_FIELD_NAME = "output_text_field_name" - - doc_id_property = str(context.getProperty(DOC_ID_ATTRIBUTE_NAME)) - binary_data_property = str(context.getProperty(BINARY_FIELD_NAME)) - output_text_property = str(context.getProperty(OUTPUT_TEXT_FIELD_NAME)) - - # check if this has been set - operation_mode = str(context.getProperty("operation_mode")) - - try: - flowFile = session.write(flowFile, PyStreamCallback()) - - avro_record_converted = {str(k) : str(v) for k,v in avro_record.iteritems()} - flowFile = session.putAllAttributes(flowFile, avro_record_converted) - - session.transfer(flowFile, REL_SUCCESS) - except Exception as exception: - log.error(traceback.format_exc()) - log.error(str(exception)) - session.transfer(flowFile, REL_FAILURE) - -else: - session.transfer(flowFile, REL_FAILURE) - diff --git a/nifi/user-scripts/logs/.gitignore b/nifi/user-scripts/logs/.gitignore deleted file mode 100644 index f59ec20aa..000000000 --- a/nifi/user-scripts/logs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* \ No newline at end of file diff --git a/nifi/user-scripts/logs/parse_json/.gitkeep b/nifi/user-scripts/logs/parse_json/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/nifi/user-scripts/tests/generate_big_ann_file.py b/nifi/user-scripts/tests/generate_big_ann_file.py deleted file mode 100644 index b3dcf8072..000000000 --- a/nifi/user-scripts/tests/generate_big_ann_file.py +++ /dev/null @@ -1,23 +0,0 @@ -import json - -f_path = "../../../data/cogstack-cohort/medical_reports_anns_medcat_medmen__*.json" - - -def chunk(input_list: list, num_slices: int): - for i in range(0, len(input_list), num_slices): - yield input_list[i:i + num_slices] - - -contents = None - -add_records = 400000 - -first_annotation = contents[0] - -for i in range(add_records): - contents.append(first_annotation) - -export_path = "../../../data/medical_reports_anns_medcat_medmen__test_big.json" - -with open(export_path, mode="w+") as f: - f.write(json.dumps(contents)) diff --git a/nifi/user-scripts/tests/generate_files.py b/nifi/user-scripts/tests/generate_files.py deleted file mode 100644 index 48c4a52dc..000000000 --- a/nifi/user-scripts/tests/generate_files.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import random -import uuid - -from reportlab.pdfgen import canvas - -path = "../../../data/ingestion/2022/" - -str_file_name = "ex1.pdf" -tmp_pdf_path = "./test_files/" + str_file_name - -tmp_pdf = open(tmp_pdf_path, mode="rb").read() - -csv_header = "file_name_id_no_ext|file_ext" - -for i in range(1,13): - month_path = "0" + str(i) if i < 10 else str(i) - - for j in range(1,32): - day_path = "0" + str(j) if j < 10 else str(j) - - day_dir_path = os.path.join(path, month_path,str(day_path)) - os.makedirs(day_dir_path, exist_ok=True) - - metadata_csv = csv_header - for file_counter in range(0, random.randint(2, 5)): - uid = str(uuid.uuid4().hex) - new_file_name_no_ext = str_file_name + "_" + uid - metadata_csv += "\n" + new_file_name_no_ext + "|pdf" - - with open(os.path.join(day_dir_path, new_file_name_no_ext + ".pdf"), "wb") as file: - file.write(tmp_pdf) - - with(open(os.path.join(day_dir_path, "metadata.csv"), "w+")) as csv_file: - csv_file.write(metadata_csv) - - -def create_long_pdf_over_char_limit(file_path, text): - c = canvas.Canvas(file_path) - # Set font and size - c.setFont("Helvetica", 12) - - # Set margin - margin = 50 - width, height = c._pagesize - - # Split the text into lines - lines = [text[i:i + 100] for i in range(0, len(text), 100)] # Adjust line length as needed - - # Write lines to PDF - y = height - margin - for line in lines: - c.drawString(margin, y, line) - y -= 12 + 2 # Adjust spacing between lines as needed - - c.save() - -# this is a string over the int limit (for testing the built in jackson XML parser) -over_char_text = "a" * 2147483647 - -#create_long_pdf_over_char_limit(path + "long_pdf.pdf", over_char_text) \ No newline at end of file diff --git a/nifi/user-scripts/tests/get_ingested_files.py b/nifi/user-scripts/tests/get_ingested_files.py deleted file mode 100644 index d697ce451..000000000 --- a/nifi/user-scripts/tests/get_ingested_files.py +++ /dev/null @@ -1,10 +0,0 @@ -import json - -file_path = "../logs/processed_*.log" - -json_dict = json.load(open(file_path)) - -x = 0 -for i in json_dict.keys(): - x += len(json_dict[i]) - print(i, len(json_dict[i])) diff --git a/nifi/user-scripts/tests/test_files/ex1.pdf b/nifi/user-scripts/tests/test_files/ex1.pdf deleted file mode 100755 index 90fb6ef44b09f0309eb08836fa6b6465da5a9ecd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 194005 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 diff --git a/nifi/user-scripts/tmp/.gitignore b/nifi/user-scripts/tmp/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/nifi/user-scripts/utils/cerner_blob.py b/nifi/user-scripts/utils/cerner_blob.py deleted file mode 100644 index 4b59444bb..000000000 --- a/nifi/user-scripts/utils/cerner_blob.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import List - - -class LzwItem: - def __init__(self, _prefix: int = 0, _suffix: int = 0) -> None: - self.prefix = _prefix - self.suffix = _suffix - - -class DecompressLzwCernerBlob: - def __init__(self) -> None: - self.MAX_CODES: int = 8192 - self.tmp_decompression_buffer: List[int] = [0] * self.MAX_CODES - self.lzw_lookup_table: List[LzwItem] = [LzwItem()] * self.MAX_CODES - self.tmp_buffer_index: int = 0 - self.current_byte_buffer_index: int = 0 - - # starts after 256, since 256 is the ASCII alphabet - self.code_count: int = 257 - self.output_stream = bytearray() - - def save_to_lookup_table(self, compressed_code: int): - self.tmp_buffer_index = -1 - while compressed_code >= 258: - self.tmp_buffer_index += 1 - self.tmp_decompression_buffer[self.tmp_buffer_index] = \ - self.lzw_lookup_table[compressed_code].suffix - compressed_code = self.lzw_lookup_table[compressed_code].prefix - - self.tmp_buffer_index += 1 - self.tmp_decompression_buffer[self.tmp_buffer_index] = compressed_code - - for i in reversed(list(range(self.tmp_buffer_index + 1))): - self.output_stream.append(self.tmp_decompression_buffer[i]) - - def decompress(self, input_stream: bytearray = bytearray()): - - byte_buffer_index: int = 0 - - # used for bit shifts - shift: int = 1 - current_shift: int = 1 - - previous_code: int = 0 - middle_code: int = 0 - lookup_index: int = 0 - - skip_flag: bool = False - - first_code = input_stream[byte_buffer_index] - - while True: - if current_shift >= 9: - - current_shift -= 8 - - if first_code != 0: - byte_buffer_index += 1 - middle_code = input_stream[byte_buffer_index] - - first_code = (first_code << current_shift + - 8) | (middle_code << current_shift) - - byte_buffer_index += 1 - middle_code = input_stream[byte_buffer_index] - - tmp_code = middle_code >> (8 - current_shift) - lookup_index = first_code | tmp_code - - skip_flag = True - else: - byte_buffer_index += 1 - first_code = input_stream[byte_buffer_index] - byte_buffer_index += 1 - middle_code = input_stream[byte_buffer_index] - else: - byte_buffer_index += 1 - middle_code = input_stream[byte_buffer_index] - - if not skip_flag: - lookup_index = (first_code << current_shift) | ( - middle_code >> 8 - current_shift) - - if lookup_index == 256: - shift = 1 - current_shift += 1 - first_code = input_stream[byte_buffer_index] - - self.tmp_decompression_buffer = [0] * self.MAX_CODES - self.tmp_buffer_index = 0 - - self.lzw_lookup_table = [LzwItem()] * self.MAX_CODES - self.code_count = 257 - continue - - elif lookup_index == 257: # EOF marker - return self.output_stream - - skip_flag = False - - # skipit part - if previous_code == 0: - self.tmp_decompression_buffer[0] = lookup_index - if lookup_index < self.code_count: - self.save_to_lookup_table(lookup_index) - if self.code_count < self.MAX_CODES: - self.lzw_lookup_table[self.code_count] = LzwItem( - previous_code, - self.tmp_decompression_buffer[self.tmp_buffer_index]) - self.code_count += 1 - else: - self.lzw_lookup_table[self.code_count] = LzwItem( - previous_code, - self.tmp_decompression_buffer[self.tmp_buffer_index]) - self.code_count += 1 - self.save_to_lookup_table(lookup_index) - # end of skipit - - first_code = (middle_code & (0xff >> current_shift)) - current_shift += shift - - if self.code_count in [511, 1023, 2047, 4095]: - shift += 1 - current_shift += 1 - previous_code = lookup_index diff --git a/nifi/user-scripts/utils/ethnicity_map.py b/nifi/user-scripts/utils/ethnicity_map.py deleted file mode 100644 index 9fed56234..000000000 --- a/nifi/user-scripts/utils/ethnicity_map.py +++ /dev/null @@ -1,153 +0,0 @@ -# Mapped on top-level of 2001 NHS Data Dictionary; https://datadictionary.nhs.uk/data_elements/ethnic_category.html -ethnicity_map = {'algerian': 'black', - 'any other group': 'other', - 'asian and chinese': 'asian', - 'bangladeshi': 'asian', - 'black african': 'black', - 'black british': 'black', - 'black': 'black', - 'british': 'white', - 'caribbean': 'black', - 'asian': 'asian', - 'chinese': 'asian', - 'cypriot (part nt st)': 'white', - 'ecuadorian': 'other', - 'english': 'white', - 'ethiopian': 'black', - 'filipino': 'asian', - 'ghanaian': 'black', - 'american': "unknown", - 'greek cypriot': 'white', - 'indian/british india': 'asian', - 'iranian': 'other', - 'italian': 'white', - 'dominican': 'other', - 'mixed black': 'black', - 'mixed caribbean': 'black', - 'nigerian': 'black', - 'not given': 'unknown', - 'not specified': 'unknown', - 'not stated': 'unknown', - 'other asian backgrou': 'asian', - 'other asian unspecif': 'asian', - 'other black backgrou': 'black', - 'other black unspecif': 'black', - 'other ethnic group': 'other', - 'other latin american': 'other', - 'other white back gro': 'white', - 'other white unspecif': 'white', - 'other white/mixed eu': 'white', - 'pakistani/british pa': 'asian', - 'portuguese': 'white', - 'somali': 'black', - 'spanish': 'white', - 'mexican': 'other', - 'puerto rican': 'other', - 'sri lankan': 'asian', - 'sudanese': 'black', - 'turkish': 'other', - 'ugandan': 'black', - 'vietnamese': 'asian', - 'white irish': 'white', - 'former ussr rep': 'white', - 'polish': 'white', - 'iraqi': 'other', - 'albanian': 'other', - 'columbian': 'other', - 'scottish': 'white', - 'not stated': 'unknown', - 'other mixed backgrou': 'mixed', - 'welsh': 'white', - 'british asian': 'asian', - 'caribbean asian': 'asian', - 'eritrean': 'black', - 'turkish cypriot': 'other', - 'sinhalese': 'asian', - 'white and asian': 'asian', - 'other mixed': 'mixed', - 'mixed asian': 'asian', - 'greek': 'white', - 'white': 'white', - 'arab': 'other', - 'multiple codes': 'multiple codes', - 'irish': 'white', - 'japanese': 'asian', - 'middle east': 'other', - 'croatian': 'white', - 'hispanic': 'other', - 'black and asian': 'mixed', - 'black and white': 'mixed' - } - -# Mapped on bottom-level of 2001 NHS Data Dictionary; https://datadictionary.nhs.uk/data_elements/ethnic_category.html -ethnicity_map_detail = {'algerian': 'black or black british - african', - 'any other group': 'other ethnic groups - any other ethnic group', - 'asian and chinese': 'other ethnic groups - chinese', - 'bangladeshi': 'asian or asian british - pakistani', - 'black african': 'black or black british - african', - 'black british': 'black or black british - any other black background', - 'british': 'white - british', - 'caribbean': 'black or black british - caribbean', - 'chinese': 'other ethnic groups - chinese', - 'cypriot (part nt st)': 'white - any other white background', - 'ecuadorian': 'other ethnic groups - any other ethnic group', - 'english': 'white - british', - 'ethiopian': 'black or black british - african', - 'filipino': 'asian or asian british - any other asian background', - 'ghanaian': 'black or black british - african', - 'greek cypriot': 'white - any other white background', - 'indian/british india': 'asian or asian british - indian', - 'iranian': 'other ethnic groups - any other ethnic group', - 'italian': 'white - any other white background', - 'mixed black': 'black or black british - any other black background', - 'mixed caribbean': 'black or black british - caribbean', - 'nigerian': 'black or black british - african', - 'not given': 'not stated', - 'not specified': 'not stated', - 'not stated': 'not stated', - 'other asian backgrou': 'asian or asian british - any other asian background', - 'other asian unspecif': 'asian or asian british - any other asian background', - 'other black backgrou': 'black or black british - any other black background', - 'other black unspecif': 'black or black british - any other black background', - 'other ethnic group': 'other ethnic groups - any other ethnic group', - 'other latin american': 'other ethnic groups - any other ethnic group', - 'other white back gro': 'white - any other white background', - 'other white unspecif': 'white - any other white background', - 'other white/mixed eu': 'white - any other white background', - 'pakistani/british pa': 'asian or asian british - pakistani', - 'portuguese': 'white - any other white background', - 'somali': 'black or black british - african', - 'spanish': 'white - any other white background', - 'sri lankan': 'asian or asian british - any other asian background', - 'sudanese': 'black or black british - african', - 'turkish': 'other ethnic groups - any other ethnic group', - 'ugandan': 'black or black british - african', - 'vietnamese': 'other ethnic groups - any other ethnic group', - 'white irish': 'white - irish', - 'former ussr rep': 'white - any other white background', - 'polish': 'white - any other white background', - 'iraqi': 'other ethnic groups - any other ethnic group', - 'albanian': 'white - any other white background', - 'columbian': 'other ethnic groups - any other ethnic group', - 'scottish': 'white - british', - 'not stated': 'not stated', - 'other mixed backgrou': 'mixed - any other mixed background', - 'welsh': 'white - british', - 'british asian': 'asian or asian british - any other asian background', - 'caribbean asian': 'mixed - any other mixed background', - 'eritrean': 'black or black british - african', - 'turkish cypriot': 'other ethnic groups - any other ethnic group', - 'sinhalese': 'asian or asian british - any other asian background', - 'white and asian': 'mixed - white and asian', - 'other mixed': 'mixed - any other mixed background', - 'mixed asian': 'mixed - any other mixed background', - 'greek': 'white - any other white background', - 'arab': 'other ethnic groups - any other ethnic group', - 'multiple codes': 'multiple codes', - 'irish': 'white - irish', - 'japanese': 'other ethnic groups - any other ethnic group', - 'middle east': 'other ethnic groups - any other ethnic group', - 'croatian': 'white - any other white background', - 'black and asian': 'mixed - white and asian', - 'black and white': 'mixed - any other mixed background' - } diff --git a/nifi/user-scripts/utils/generic.py b/nifi/user-scripts/utils/generic.py deleted file mode 100644 index 5d5be2ead..000000000 --- a/nifi/user-scripts/utils/generic.py +++ /dev/null @@ -1,159 +0,0 @@ -import json -import logging -import os -import shutil -import ssl -import subprocess -import sys -import traceback -import urllib.request -from collections import defaultdict -from collections.abc import Callable, Iterable - -logger = logging.getLogger(__name__) - - -def safe_delete_paths(paths: Iterable[str | os.PathLike], chmod_mode: int = 755) -> None: - """ - Forcefully deletes a list of files or directories. - - Makes files and dirs writable before deletion. - - Removes both normal files and symlinks. - - Recursively deletes directories. - - Args: - paths: iterable of file or directory paths. - chmod_mode: permission mode to apply before deleting. - """ - - for path in paths: - if not os.path.exists(path): - continue - try: - # Handle symlinks separately - if os.path.islink(path) or os.path.isfile(path): - os.chmod(path, chmod_mode) - os.remove(path) - - if os.path.isdir(path): - # Make sure everything inside is writable - subprocess.run(["chmod", "-R", str(chmod_mode), path], check=False) - shutil.rmtree(path, ignore_errors=False) - logger.info(f"Deleted: {path}") - except Exception as e: - logger.error(f"Could not delete {path}: {e}") - - -def chunk(input_list: list, num_slices: int): - for i in range(0, len(input_list), num_slices): - yield input_list[i:i + num_slices] - - -# function to convert a dictionary to json and write to file (d: dictionary, fn: string (filename)) -def dict2json_file(input_dict: dict, file_path: str) -> None: - # write the json file - with open(file_path, "a+", encoding="utf-8") as outfile: - json.dump(input_dict, outfile, ensure_ascii=False, indent=None, separators=(",", ":")) - - -def dict2json_truncate_add_to_file(input_dict: dict, file_path: str) -> None: - if os.path.exists(file_path): - with open(file_path, "a+") as outfile: - outfile.seek(outfile.tell() - 1, os.SEEK_SET) - outfile.truncate() - outfile.write(",") - json_string = json.dumps(input_dict, separators=(",", ":")) - # skip starting "{" - json_string = json_string[1:] - - outfile.write(json_string) - else: - dict2json_file(input_dict, file_path) - - -def dict2jsonl_file(input_dict: dict | defaultdict, file_path: str) -> None: - with open(file_path, 'a', encoding='utf-8') as outfile: - for k,v in input_dict.items(): - o = {k: v} - json_obj = json.loads(json.dumps(o)) - json.dump(json_obj, outfile, ensure_ascii=False, indent=None, separators=(',',':')) - print('', file=outfile) - - -def get_logger(name: str) -> logging.Logger: - """Return a configured logger shared across all NiFi clients.""" - level_name = os.getenv("NIFI_LOG_LEVEL", "INFO").upper() - level = getattr(logging, level_name, logging.INFO) - - logger = logging.getLogger(name) - if not logger.handlers: - handler = logging.StreamHandler(sys.stdout) - fmt = logging.Formatter( - "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", - "%Y-%m-%d %H:%M:%S", - ) - handler.setFormatter(fmt) - logger.addHandler(handler) - logger.setLevel(level) - logger.propagate = False - return logger - -def download_file_from_url(url: str, output_path: str, ssl_verify: bool = False, chunk_size: int = 8192) -> None: - """Download a file from a URL to a local destination. - - Args: - url (str): The URL of the file to download. - output_path (str): The local file path to save the downloaded file. - ssl_verify (bool): Whether to verify SSL certificates. Defaults to False. - chunk_size (int): Size of data chunks to read at a time. Defaults to 8192 bytes. - - Raises: - Exception: If the download fails. - """ - - try: - context = ssl.create_default_context() if ssl_verify else ssl._create_unverified_context() - - with urllib.request.urlopen(url, context=context) as response, open(output_path, 'wb') as out_file: - while chunk := response.read(chunk_size): - out_file.write(chunk) - - except Exception as e: - raise Exception(f"Failed to download file from {url} to {output_path}: {e}") from e - - -# ----------------------------------------------------------------------------------------------------------------- -# Function for handling property parsing, used in NiFi processors mainly, but can beused elsewhere -# ----------------------------------------------------------------------------------------------------------------- -def parse_value(value: str) -> str|int|float|bool: - """Convert NiFi string property values into native Python types.""" - if isinstance(value, bool): - return value - - val_str = str(value).strip() - if val_str.lower() in ("true", "false"): - return val_str.lower() == "true" - if val_str.replace(".", "", 1).isdigit(): - return float(val_str) if "." in val_str else int(val_str) - return value - - -# ----------------------------------------------------------------------------------------------------------------- -# Safe execution wrapper with consistent error logging -# ----------------------------------------------------------------------------------------------------------------- -def safe_execute(logger: logging.Logger, func, *args, **kwargs) -> Callable: - """ - Executes a function safely, logging errors with full traceback. - - Args: - logger (logging.Logger): Logger to write errors to. - func (Callable): Function to execute. - *args, **kwargs: Arguments passed to the function. - - Returns: - The result of func(*args, **kwargs), or None on error. - """ - try: - return func(*args, **kwargs) - except Exception as e: - logger.error(f"❌ Error during execution of {func.__name__}: {e}\n{traceback.format_exc()}") - raise diff --git a/nifi/user-scripts/utils/helpers/avro_json_encoder.py b/nifi/user-scripts/utils/helpers/avro_json_encoder.py deleted file mode 100644 index 9e7a256b8..000000000 --- a/nifi/user-scripts/utils/helpers/avro_json_encoder.py +++ /dev/null @@ -1,9 +0,0 @@ -import json -from datetime import datetime - - -class AvroJSONEncoder(json.JSONEncoder): - def default(self, o): - if isinstance(o, datetime): - return o.isoformat() - return super().default(o) diff --git a/nifi/user-scripts/utils/helpers/base_nifi_processor.py b/nifi/user-scripts/utils/helpers/base_nifi_processor.py deleted file mode 100644 index 634a05509..000000000 --- a/nifi/user-scripts/utils/helpers/base_nifi_processor.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging -from logging import Logger -from typing import Generic, TypeVar - -from nifiapi.flowfiletransform import FlowFileTransform -from nifiapi.properties import ( - ProcessContext, - PropertyDescriptor, - StandardValidators, -) -from nifiapi.relationship import Relationship -from py4j.java_gateway import JavaObject, JVMView -from utils.generic import parse_value - - -def _make_wrapper_method(name): - """Return a function that delegates to the base's implementation on self.""" - def wrapper(self, *args, **kwargs): - # call Base class implementation - base_impl = getattr(super(self.__class__, self), name, None) - if base_impl is None: - raise AttributeError(f"Base class missing {name}") - return base_impl(*args, **kwargs) - wrapper.__name__ = name - return wrapper - -def nifi_processor(*, processor_details: dict | None = None): - """ - NOTE (4-11-2025): at the moment this decorator is a bit useless as the curre - NiFi version does not support automatic discovery of processor details from Python processors - it only scans for the Java nested class "ProcessorDetails" and stops there, limited - discovery capabilities for now. Hopefully in future versions this can be used. - - Class decorator that injects: - - class Java with implements set - - class ProcessorDetails (optional) - - thin wrappers for getPropertyDescriptors, getRelationships, transform - Use like: - @nifi_processor(processor_details={"version":"0.1.0"}) - class MyProc(BaseNiFiProcessor): ... - """ - def decorator(cls): - # Inject Java if missing (exact nested-class syntax NiFi looks for) - if not hasattr(cls, "Java"): - class Java: - implements = ["org.apache.nifi.python.processor.FlowFileTransform"] - cls.Java = Java - - # Inject ProcessorDetails if provided and missing - if processor_details and not hasattr(cls, "ProcessorDetails"): - class ProcessorDetails: - pass - for k, v in processor_details.items(): - setattr(ProcessorDetails, k, v) - cls.ProcessorDetails = ProcessorDetails - - # Ensure NiFi-visible methods exist on the class itself: - # If subclass hasn't defined them, create a thin delegating wrapper - for method_name in ("getPropertyDescriptors", "getRelationships", "transform"): - if not hasattr(cls, method_name): - setattr(cls, method_name, _make_wrapper_method(method_name)) - - return cls - return decorator - - -T = TypeVar("T") - -class BaseNiFiProcessor(FlowFileTransform, Generic[T]): - """Base class providing common NiFi Python processor utilities. - NOTE: This is an example implementation meant to be reimplemented by processors inheriting it . - For the moment all inherting processors must define - their own Java and ProcessorDetails nested classes unfortunately, until dynamic. - """ - - identifier = None - logger: Logger = Logger(__qualname__) - - def __init__(self, jvm: JVMView): - """ - This is the base class for all NiFi Python processors. It provides common functionality - such as property handling, relationship definitions, and logging. - - This is an example implementation meant to be reimplemented by processors inheriting it . - - Args: - jvm (JVMView): Required, Store if you need to use Java classes later. - """ - self.jvm = jvm - self.logger: Logger = logging.getLogger(self.__class__.__name__) - self.process_context: ProcessContext - - self._properties: list = [ - PropertyDescriptor(name="sample_property_one", - description="sample property one description", - default_value="true", - required=True, - validators=StandardValidators.BOOLEAN_VALIDATOR), - ] - - self._relationships = [ - Relationship( - name="success", - description="All FlowFiles processed successfully." - ), - Relationship( - name="failure", - description="FlowFiles that failed processing." - ) - ] - - self.descriptors: list[PropertyDescriptor] = self._properties - self.relationships: list[Relationship] = self._relationships - - self.logger.info(f"Initialized {self.__class__.__name__} processor.") - - def getRelationships(self) -> list[Relationship]: - return self.relationships - - def getPropertyDescriptors(self) -> list[PropertyDescriptor]: - return self.descriptors - - def set_logger(self, logger: Logger): - self.logger = logger - - def set_properties(self, properties: dict) -> None: - """Populate class attributes from NiFi property map.""" - for k, v in properties.items(): - name = k.name if hasattr(k, "name") else str(k) - val = parse_value(v) - if hasattr(self, name): - setattr(self, name, val) - self.logger.debug(f"property set '{name}' -> {val!r} (type={type(val).__name__})") - - def onScheduled(self, context: ProcessContext) -> None: - """ - Called automatically by NiFi once when the processor is scheduled to run - (i.e., enabled or started). This method is used for initializing and - allocating resources that should persist across multiple FlowFile - executions. - - Typical use cases include: - - Loading static data from disk (e.g., CSV lookup tables, configuration files) - - Establishing external connections (e.g., databases, APIs) - - Building in-memory caches or models used by onTrigger/transform() - - The resources created here remain in memory for the lifetime of the - processor and are shared by all concurrent FlowFile executions on this - node. They should be lightweight and thread-safe. To release or clean up - such resources, use the @OnStopped method, which NiFi calls when the - processor is disabled or stopped. - """ - pass - - def transform(self, context: ProcessContext, flowFile: JavaObject): - raise NotImplementedError diff --git a/nifi/user-scripts/utils/helpers/nifi_api_client.py b/nifi/user-scripts/utils/helpers/nifi_api_client.py deleted file mode 100644 index 1c353d2c1..000000000 --- a/nifi/user-scripts/utils/helpers/nifi_api_client.py +++ /dev/null @@ -1,82 +0,0 @@ -from logging import Logger - -from dto.nifi_api_config import NiFiAPIConfig -from nipyapi import canvas, security -from nipyapi.nifi import ApiClient, ProcessGroupsApi -from nipyapi.nifi.configuration import Configuration as NiFiConfiguration -from nipyapi.nifi.models.process_group_entity import ProcessGroupEntity -from nipyapi.nifi.models.processor_entity import ProcessorEntity -from nipyapi.registry import ApiClient as RegistryApiClient -from nipyapi.registry import BucketsApi -from nipyapi.registry.configuration import Configuration as RegistryConfiguration -from utils.generic import get_logger - - -class NiFiRegistryClient: - def __init__(self, config: NiFiAPIConfig) -> None: - self.config = config or NiFiAPIConfig() - self.nipyapi_config = RegistryConfiguration() - self.nipyapi_config.host = self.config.nifi_registry_api_url - self.nipyapi_config.verify_ssl = self.config.VERIFY_SSL - self.nipyapi_config.cert_file = self.config.NIFI_CERT_PEM_PATH # type: ignore - self.nipyapi_config.key_file = self.config.NIFI_CERT_KEY_PATH # type: ignore - self.nipyapi_config.ssl_ca_cert = self.config.ROOT_CERT_CA_PATH # type: ignore - - self.logger: Logger = get_logger(self.__class__.__name__) - - self.api_client = RegistryApiClient(self.nipyapi_config.host) - self.buckets_api = BucketsApi(self.api_client) - - def list_buckets(self): - buckets = self.buckets_api.get_buckets() - for b in buckets: - self.logger.info("Bucket: %s (%s)", b.name, b.identifier) - return buckets - - -class NiFiClient: - def __init__(self, config: NiFiAPIConfig) -> None: - self.config = config or NiFiAPIConfig() - self.nipyapi_config = NiFiConfiguration() - self.nipyapi_config.host = self.config.nifi_api_url - self.nipyapi_config.verify_ssl = self.config.VERIFY_SSL - self.nipyapi_config.cert_file = self.config.NIFI_CERT_PEM_PATH # type: ignore - self.nipyapi_config.key_file = self.config.NIFI_CERT_KEY_PATH # type: ignore - self.nipyapi_config.ssl_ca_cert = self.config.ROOT_CERT_CA_PATH # type: ignore - - self.logger: Logger = get_logger(self.__class__.__name__) - - self.api_client = ApiClient(self.nipyapi_config) - self.process_group_api = ProcessGroupsApi(self.api_client) - - self._login() - - def _login(self) -> None: - security.service_login( - service='nifi', - username=self.config.NIFI_USERNAME, - password=self.config.NIFI_PASSWORD - ) - self.logger.info("✅ Logged in to NiFi") - - def get_root_process_group_id(self) -> str: - return canvas.get_root_pg_id() - - def get_process_group_by_name(self, process_group_name: str) -> None | list[object] | object: - return canvas.get_process_group(process_group_name, identifier_type="nam") - - def get_process_group_by_id(self, process_group_id: str) -> ProcessGroupEntity: - return canvas.get_process_group(process_group_id, identifier_type="id") - - def start_process_group(self, process_group_id: str) -> bool: - return canvas.schedule_process_group(process_group_id, True) - - def stop_process_group(self, process_group_id: str) -> bool: - return canvas.schedule_process_group(process_group_id, False) - - def get_child_process_groups_from_parent_id(self, parent_process_group_id: str) -> list[ProcessGroupEntity]: - parent_pg = canvas.get_process_group(parent_process_group_id, identifier_type="id") - return canvas.list_all_process_groups(parent_pg.id) - - def get_all_processors_in_process_group(self, process_group_id: str) -> list[ProcessorEntity]: - return canvas.list_all_processors(process_group_id) diff --git a/nifi/user-scripts/utils/helpers/service.py b/nifi/user-scripts/utils/helpers/service.py deleted file mode 100644 index 9d4b28080..000000000 --- a/nifi/user-scripts/utils/helpers/service.py +++ /dev/null @@ -1,33 +0,0 @@ -import sys -import time - -import psycopg2 -from psycopg2 import sql - -sys.path.append("../../dto/") - -from dto.pg_config import PGConfig - - -def check_postgres(cfg: PGConfig) -> tuple[bool, float | None, str | None]: - """Return (is_healthy, latency_ms, error_detail)""" - start = time.perf_counter() - try: - conn = psycopg2.connect( - host=cfg.host, - port=cfg.port, - dbname=cfg.db, - user=cfg.user, - password=cfg.password, - connect_timeout=cfg.timeout - ) - with conn.cursor() as cur: - cur.execute(sql.SQL("SELECT 1;")) - result = cur.fetchone() - conn.close() - if result != (1,): - return False, None, f"Unexpected result: {result}" - latency = (time.perf_counter() - start) * 1000 - return True, latency, None - except Exception as e: - return False, None, str(e) diff --git a/nifi/user-scripts/utils/lint_env.py b/nifi/user-scripts/utils/lint_env.py deleted file mode 100644 index 8918e9152..000000000 --- a/nifi/user-scripts/utils/lint_env.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" - Lightweight env file validator used by deploy/export_env_vars.sh. -""" - -from __future__ import annotations - -import sys -from collections.abc import Iterable -from pathlib import Path - -PORT_SUFFIXES = ("_PORT", "_OUTPUT_PORT", "_INPUT_PORT") -BOOL_SUFFIXES = ("_ENABLED", "_SSL_ENABLED", "_BAKE") -BOOL_VALUES = {"true", "false", "1", "0", "yes", "no", "on", "off"} - - -def strip_quotes(value: str) -> str: - if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): - return value[1:-1] - return value - - -def parse_env_file(path: Path) -> tuple[list[str], list[str], list[tuple[str, str, int]]]: - errors: list[str] = [] - warnings: list[str] = [] - entries: list[tuple[str, str, int]] = [] - - for lineno, raw_line in enumerate(path.read_text().splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - - if line.startswith("export "): - line = line[len("export ") :].strip() - - if "=" not in line: - errors.append(f"{path}:{lineno}: missing '=' (got: {raw_line})") - continue - - key, value = line.split("=", 1) - key = key.strip() - value = value.strip() - - if not key: - errors.append(f"{path}:{lineno}: empty key (got: {raw_line})") - continue - - entries.append((key, value, lineno)) - - seen = {} - for key, _, lineno in entries: - if key in seen: - warnings.append(f"{path}:{lineno}: duplicate key '{key}' (also on line {seen[key]})") - else: - seen[key] = lineno - - return errors, warnings, entries - - -def validate_entries(path: Path, entries: Iterable[tuple[str, str, int]]) -> list[str]: - errors: list[str] = [] - - for key, value, lineno in entries: - normalized = strip_quotes(value) - - if any(key.endswith(suffix) for suffix in PORT_SUFFIXES): - if not normalized.isdigit(): - errors.append(f"{path}:{lineno}: '{key}' should be an integer port (got '{value}')") - - if any(key.endswith(suffix) for suffix in BOOL_SUFFIXES): - if normalized.lower() not in BOOL_VALUES: - errors.append( - f"{path}:{lineno}: '{key}' should be one of {sorted(BOOL_VALUES)} (got '{value}')" - ) - - return errors - - -def main(args: list[str]) -> int: - if not args: - script = Path(__file__).name - print(f"Usage: {script} [ ...]") - return 1 - - warnings: list[str] = [] - errors: list[str] = [] - checked_files = 0 - - for path_str in args: - path = Path(path_str).resolve() - if not path.exists(): - warnings.append(f"Skipping missing env file: {path}") - continue - - checked_files += 1 - parse_errors, parse_warnings, entries = parse_env_file(path) - errors.extend(parse_errors) - warnings.extend(parse_warnings) - errors.extend(validate_entries(path, entries)) - - for warning in warnings: - print(f"⚠️ {warning}") - - if errors: - print("❌ Env validation failed:") - for err in errors: - print(f" - {err}") - return 1 - - print(f"✅ Env validation passed ({checked_files} files checked)") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/nifi/user-scripts/utils/pgsql_query.py b/nifi/user-scripts/utils/pgsql_query.py deleted file mode 100644 index 12e32d96e..000000000 --- a/nifi/user-scripts/utils/pgsql_query.py +++ /dev/null @@ -1,8 +0,0 @@ -import psycopg2 - -conn = psycopg2.connect( - host="localhost", - database="suppliers", - user="YourUsername", - password="YourPassword" -) diff --git a/nifi/user-scripts/utils/sqlite_query.py b/nifi/user-scripts/utils/sqlite_query.py deleted file mode 100644 index 075812f5a..000000000 --- a/nifi/user-scripts/utils/sqlite_query.py +++ /dev/null @@ -1,97 +0,0 @@ -import sqlite3 -from ast import List - - -def connect_and_query(query: str, db_file_path: str, sqlite_connection: sqlite3.Connection = None, cursor: sqlite3.Cursor = None, sql_script_mode: bool = False, keep_conn_open=False) -> List: - """ Executes whatever query. - - Args: - query (str): your SQL query. - db_file_path (str): file path to sqlite db - sql_script_mode (bool, optional): if it is transactional or just a fetch query . Defaults to False. - - Raises: - sqlite3.Error: sqlite error. - - Returns: - List: List of results - """ - - result = [] - - try: - if sqlite_connection is None: - sqlite_connection = create_connection(db_file_path) - - if cursor is None: - cursor = sqlite_connection.cursor() - - if not sql_script_mode: - cursor.execute(query) - result = cursor.fetchall() - else: - cursor.executescript(query) - sqlite_connection.commit() - except sqlite3.Error as error: - raise sqlite3.Error(error) - finally: - if cursor and not keep_conn_open: - cursor.close() - if sqlite_connection and not keep_conn_open: - sqlite_connection.close() - - - return result - - -def create_connection(db_file_path: str, read_only_mode=False) -> sqlite3.Connection: - - connection_str = "file:" + str(db_file_path) - - if read_only_mode: - connection_str += "?mode=ro" - - _tmp_conn = sqlite3.connect(connection_str, uri=True) - - if read_only_mode: - _tmp_conn.execute("pragma synchronous = OFF") - else: - _tmp_conn.execute("pragma journal_mode = wal") - _tmp_conn.execute("pragma synchronous = normal") - _tmp_conn.execute("pragma journal_size_limit = 6144000") - - return _tmp_conn - - -def query_with_connection(query: str, sqlite_connection: sqlite3.Connection) -> List: - result = [] - try: - cursor = sqlite_connection.cursor() - cursor.execute(query) - result = cursor.fetchall() - cursor.close() - except sqlite3.Error as error: - raise sqlite3.Error(error) - return result - - -def check_db_exists(table_name: str, db_file_path: str): - query = "PRAGMA table_info(" + table_name + ");" - return connect_and_query(query=query, db_file_path=db_file_path) - - -def create_db_from_file(sqlite_file_path: str, db_file_path: str) -> sqlite3.Cursor: - """ Creates db from .sqlite schema/query file - - Args: - sqlite_file_path (str): sqlite db folder - db_file_path (str): sqlite db file name - - Returns: - sqlite3.Cursor: result of query - """ - query = "" - with open(sqlite_file_path, mode="r") as sql_file: - query = sql_file.read() - return connect_and_query(query=query, db_file_path=db_file_path, - sql_script_mode=True) diff --git a/nifi/user-templates/dt4h/annotate_dt4h_ann_manager.xml b/nifi/user-templates/dt4h/annotate_dt4h_ann_manager.xml deleted file mode 100644 index 0c0a51dae..000000000 --- a/nifi/user-templates/dt4h/annotate_dt4h_ann_manager.xml +++ /dev/null @@ -1,2322 +0,0 @@ - -