diff --git a/.github/workflows/logger.yml b/.github/workflows/ci.yml similarity index 59% rename from .github/workflows/logger.yml rename to .github/workflows/ci.yml index ba2fb07..027c253 100644 --- a/.github/workflows/logger.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -name: Test logger module +name: Full CI workflow on: push: @@ -14,15 +14,31 @@ permissions: pull-requests: write jobs: - build: + collect_dirs: + runs-on: ubuntu-latest + outputs: + dirs: ${{ steps.dirs.outputs.dirs }} + steps: + - uses: actions/checkout@v2 + - id: dirs + run: echo "dirs=$(find modules/*/ -type d -wholename */app/tests -printf '{"path":"%h", "base":"%H"}\n' | sed -e 's/\/app",/",/g' | sed -e 's/\/"}/"}/g' | jq --slurp --compact-output '.')" >> ${GITHUB_OUTPUT} + + build: + needs: collect_dirs runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dir: ${{ fromJson(needs.collect_dirs.outputs.dirs) }} defaults: run: - working-directory: ./logger - + working-directory: ${{ matrix.dir.path }} steps: - uses: actions/checkout@v4 + - uses: cardinalby/export-env-action@v1 + with: + envFile: ${{ matrix.dir.base }}/config.env.default - name: Set up Python 3.10 uses: actions/setup-python@v3 with: @@ -40,10 +56,10 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | - coverage run --branch -m pytest app/tests/ - coverage xml --omit="app/tests/**" + coverage run --branch -m --omit="/tmp/**" pytest app/tests/ + coverage xml --omit="app/tests/**, /tmp/**" - name: Python Coverage uses: orgoro/coverage@v3.2 with: - coverageFile: ./logger/coverage.xml + coverageFile: ${{ matrix.dir.path }}/coverage.xml token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/quasar.yml b/.github/workflows/quasar.yml deleted file mode 100644 index 2679c60..0000000 --- a/.github/workflows/quasar.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Quasar front-end module - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - pull-requests: write - -jobs: - build: - - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./quasar/backend - - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest coverage - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - coverage run --branch -m pytest app/tests/ - coverage xml --omit="app/tests/**" - - name: Python Coverage - uses: orgoro/coverage@v3.2 - with: - coverageFile: ./quasar/backend/coverage.xml - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reasoner.yml b/.github/workflows/reasoner.yml deleted file mode 100644 index 63eda87..0000000 --- a/.github/workflows/reasoner.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Test reasoning module - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - pull-requests: write - -jobs: - build: - - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./reasoning - - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest coverage - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - coverage run --branch -m pytest app/tests/ - coverage xml --omit="app/tests/**" - - name: Python Coverage - uses: orgoro/coverage@v3.2 - with: - coverageFile: ./reasoning/coverage.xml - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/response_generator.yml b/.github/workflows/response_generator.yml deleted file mode 100644 index b1f89e1..0000000 --- a/.github/workflows/response_generator.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Test response generator module - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - pull-requests: write - -jobs: - build: - - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./response-generator - - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest coverage - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - coverage run --branch -m pytest app/tests/ - coverage xml --omit="app/tests/**" - - name: Python Coverage - uses: orgoro/coverage@v3.2 - with: - coverageFile: ./response-generator/coverage.xml - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/text_to_triples.yml b/.github/workflows/text_to_triples.yml deleted file mode 100644 index e591157..0000000 --- a/.github/workflows/text_to_triples.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Test text to triples module - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - pull-requests: write - -jobs: - build: - - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./text-to-triples - - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest coverage - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - coverage run --branch -m pytest app/tests/ - coverage xml --omit="app/tests/**" - - name: Python Coverage - uses: orgoro/coverage@v3.2 - with: - coverageFile: ./text-to-triples/coverage.xml - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e6f17a2..aad20ac 100644 --- a/.gitignore +++ b/.gitignore @@ -237,3 +237,6 @@ front-end/src/flagged *.sublime-project *.sublime-workspace *.Identifier +setup.env +core-modules.yaml +modules/*/config.env \ No newline at end of file diff --git a/README.md b/README.md index d98ed66..b4e7a79 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,238 @@ -# CHIP DEMO +# CHIP Modular System -## How to run -0. Clone this repository somehow (preferably with `git` or `Fork` if you want to make additions, but to just run it you can also just download a zip) -1. Make sure you have Docker Desktop installed and properly configured somehow -2. Run the following command in your favorite terminal application window in the root folder of the project: `docker-compose build`. +Welcome! This README will explain the general structure of the CHIP Modular System, and how to use it appropriately for your goals. -The first time you do this it may take a while, as it needs to download all the images and build the containers. -3. As soon as that is done, you may run `docker-compose up`, to bring the whole system up. +## Requirements +- Make sure bash is installed on the system (consider [using WSL2 on Windows](https://learn.microsoft.com/en-us/windows/wsl/install)). +- The system uses [Docker Compose](https://docs.docker.com/compose/install/), make sure it is installed and functioning. Docker Desktop is recommended for good overview, using the WSL2 backend is also highly recommended. + On Mac, remove the line containing ``"credsStore"`` from ``~/.docker/config.json``. +- A system that has a CPU with virtualization support. +- [OPTIONAL]: GPU with CUDA support, for running LLMs locally. -If this succeeded, you can go to `localhost:8000` in your favorite browser, the system should welcome you. The Gradio interface can be accessed at `localhost:8000/gradio`. You may initialize the knowledge repository with `localhost:8000/init` and load the initial knowledge graph with `localhost:8000/load_kg`. -In the Docker Desktop dashboard, you can also see all of the containers and their status, and you may click them to inspect their logs, or even login to perform commands on them. See this guide for more info: https://docs.docker.com/desktop/use-desktop/ +## Quick Start +For a quick start with default settings, just navigate to the root folder and use `./chip.sh start`. You may access the front end at `http://localhost:9000`. +## Architecture Overview +The system works with the notion of "core" modules, and "non-core" modules. There are five different types of core modules: +- Front End +- Triple Extractor +- Reasoner +- Response Generator +- Logger -## Resetting and making changes -In order to reset the system entirely and take it down, you should use `docker-compose down -v`. It is important to include the `-v` flag, otherwise the underlying GraphDB data will not be reset and persist instead. You can then bring the system up again by using `docker-compose up`. +Any module that doesn't classify as one of these module types, is considered not to be a core module. All core modules must expose a `/process` route, which they use to transfer JSON data to one another via POST requests. A description of the expected models for the bodies of the requests will come after this subsection. -Normally, if you make changes to the code in the `src` folder, they should automatically transfer to your container as it is running. If this is not case, you can run the following commands in sequence to propagate your code updates: +The general communication structure is as follows: +``` + Logger + /|\ + ________________________|_________________________ + | | | | +Front End ==> Triple Extractor ==> Reasoner ==> Response Generator ==| + /|\ | + |-------------------------SSE-----------------------------------| +``` + +All core modules communicate with the logger for logging, but other than that the communication is a pre-determined chain. At the end, a Server-Sent-Event (SSE) is used to communicate back to the Front End that the system has finished processing and has a chat response ready. + +Core modules may communicate with other non-core/side modules to query e.g. knowledge, or to cache things via Redis, but this is the core loop that can always assumed to be present. +## Models + +These are the models that the core modules expect the JSON bodies to conform to, sent via a POST request to the `.../process` route of the next module in the chain. + +### Triple Extractor +[POST] `/process`: +```JSON +{ + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) +} ``` -docker-compose down -v -docker-compose build -docker-compose up + +### Reasoner +[POST] `/process`: +```JSON +{ + "sentence_data": { + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) + }, + "triples": [ + { + "subject":, + "object": , + "predicate": + }, + ... + ] +} ``` -> NOTE: Changes to the front-end currently do not propagate, so you need to execute the above three commands in sequence in order to make them appear. You can also just use `CTRL+C` (on windows) on the terminal that hosts docker-compose to take the system down, though this will NOT take the GraphDB volume down. +### Response Generator +[POST] `/process`: +```JSON + { + "sentence_data": { + "patient_name": , // The name of the user currently chatting. + "sentence": , // The sentence that the user submitted. + "timestamp": // The time at which the user submitted the sentence (ISO format). + }, + "type": , // Whether the reasoner decided to give an answer (A) or to request more information (Q). + "data": // A dict containing the output of the reasoner. + } +``` -## General terminology and project setup -- Each of the folders in this project represent a "module"; a separate component in the system that can talk to the other components. -- We use docker-compose to launch all these modules together; each of them will be represented by one container. +### Front End +[POST] `/process`: +```JSON + { + "message": // The generated message. + } +``` -- Each of these containers are isolated little systems based on images specified in their `Dockerfile` that run their corresponding module under an API. +### Logger +The Logger module is special, in that its format is already pre-determined by Python's logging framework. -- You can see an API as some sort of access point, a gateway to either send data (POST), or to trigger some code or get data (GET). The URL/link that is used for doing this is called a "route". For instance, the front-end module has the `/gradio` route, which you can browse to in order to see the UI. This idea of "browsing" to a "route" is known as "sending a request". So in this system, the containers are sending `GET` and `POST` requests to one another in order to communicate. +## General Usage and Configuration +The system has a set of pre-configured core modules that it will start up with, specified in `core-modules.yaml`. This file initially does not exist, but it will be created (from default values) along with other configuration files by running the `chip.sh` script without any subcommands, and it looks like this: -- In practice, the main difference between `GET`and `POST` is that you can send additional data in a `POST` request, which is stored in the "body" of the request. This allows us to pass `JSON` and files around. +```YAML +logger_module: logger-default +frontend_module: front-end-quasar +response_generator_module: response-generator-gemini +reasoner_module: reasoning-demo +triple_extractor_module: text-to-triples-rule-based +``` -- The `docker-compose.yml` in the root folder shows the names of each of the containers, and the ports that they expose. Usually, a module can be spoken to via: `http://:5000/`. +The module names correspond to the name of the directory they reside in within `modules` directory. -- The "image" that they use is the recipe for the system. That `Dockerfile` file that you can find in each of the module's folders basically represents that recipe. +A second configuration file that will be generated is `setup.env`. This environment file contains the url/port mappings for the modules, which are derived from their respective compose files. This is how the modules know how to reach each other. The mapping uses the following convention: `MODULE_NAME_CONVERTED_TO_CAPS_AND_UNDERSCORES=:`. -- The `FROM` clause in those files tells you what the "base" recipe is that the image is based on, the following format `:`. Nearly all `Dockerfile` files in the project are currently using `python:3-slim`, but you can change this according to your needs, e.g. `python:3.9-slim`. You can find all possible tags here: https://hub.docker.com/_/python/tags?page=&page_size=&ordering=&name=3.9 +Finally, every module also has its own `config.env` which will be copied from the default values in their `config.env.default` file. This `config.env` file is not tracked by git, so it is a good place to store things like API keys that modules may need. They will be available within the container as an environment variable. +Full command overview for the `chip.sh` script: -## Module route overview -This is a list of the modules and the routes they expose. -### `front-end` (visible to the host! Use `http://localhost:5000/`) -- [GET] `/`: The base route, should welcome you to the system -- [GET] `/init`: Initializes the GraphDB knowledge database -- [GET] `/ping`: Allows you to ping other modules in the system, making them welcome you -- [POST] `/response`: Expects a response from the `response-generator` module here. - - Expected format (this might change depending on what we need): - ```json - { - "reply": - } - ``` +- `chip.sh` (*without args*): generates the `core-modules.yaml` file if it doesn't exist from `core-modules.yaml.default`, and then generates `setup.env` containing the module mappings. `setup.env` will always be overwritten if it already exists. This also takes place for any of the subcommands. It also generates `config.env` for any module that didn't have it yet, from their `config.env.default`. Configs are not git-tracked, only their defaults are. -### `knowledge` -This module has its own API which is documented at: https://graphdb.ontotext.com/documentation/10.2/using-the-graphdb-rest-api.html +- `chip.sh start [module name ...]`: builds and starts the system pre-configured with the current core modules specified in `core-modules.yaml` and their dependencies, or starts specific modules and their dependencies given by name and separated by spaces. -It's a bit tricky to use however... Still in the process of figuring it out. +- `chip.sh build [module name ...]`: builds the core modules specified in `core-modules.yaml` and their dependencies, or builds specific modules and their dependencies given by name and separated by spaces. +- `chip.sh stop`: takes down any active modules. -### `logger` -The logger is currently not utilized yet. +- `chip.sh clean`: removes all volume data, giving you a clean slate. -- [GET] `/`: The base route, welcomes you to the module -- [GET] `/log/`: Sends a line of text to the logger +- `chip.sh list`: prints a list of all available modules. +- `chip.sh auto-complete`: adds auto-completion for the modules to the script. If you prefix this command with `source`, it will immediately load the auto-complete definitions in the current terminal, otherwise you have to restart the terminal for it to take effect. -### `text-to-triples` -The `front-end` posts sentence data to this module, and this module then posts that to the `reasoning` module. -- [GET] `/`: The base route, welcomes you to the module -- [POST] `/new-sentence`: `front-end` posts new sentences input by the user to this route. - - Expected format: - ```json - { - "patient_name": , - "sentence": , - "timestamp": - } - ``` +For instance, if you are in the process of creating a new module, and just want to build it, you would use `chip.sh build my-cool-new-module`. If you want to both build and start it, you would use `chip.sh start my-cool-new-module`. Say your module has `redis` and `knowledge-demo` as dependencies, then docker-compose will automatically also build and start the `redis` and `knowledge-demo` modules for you. -### `reasoning` -The reasoning module receives a list of triples from the `text-to-triples` modules, performs some computations and communications with the `knowledge` module based on this (should store the new knowledge among other things), and then posts a reasoning result to the `response-generator` module. +## Generic Module Structure +All modules adhere to the following structure: +``` +my-cool-new-module +|- Dockerfile (optional) +|- compose.yml +|- README.md +|- ... +``` -The `reason_advice.py` and `reason_question.py` files should contain the code necessary to infer advice resp. a question based on the knowledge in GraphDB. +The [Dockerfile](https://docs.docker.com/reference/dockerfile/) can be omitted, if `compose.yml` specifies a particular image without further modifications. This may be the case for certain non-core modules, such as `redis`. + +How it practically works, is that all `compose.yml` files of all modules will be collected by the `chip.sh` script, and then merged into a big docker compose configuration using docker compose [merge](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/). + +Here's an example of a minimal `compose.yml` file of the `my-cool-new-module` module, residing in a directory of the same name within the `modules` directory: +```YAML +services: # This is always present at the root. + my-cool-new-module: # SERVICE NAME MUST CORRESPOND TO DIRECTORY/MODULE NAME + env_file: setup.env # The module mappings, don't touch this. + expose: + - 5000 # The backend port, generally no need to touch this if using Flask. + build: ./modules/my-cool-new-module/. # Build according to the dockerfile in the current folder, only fix the module name. + volumes: + - ./modules/my-cool-new-module/app:/app # Bind mount, for syncing code changes, only fix the module name. + depends_on: ["redis"] # Modules that this module depends on and that will be started/built along with it. +``` -- [GET] `/`: The base route, welcomes you to the module -- [POST] `/query-knowledge`: Allows modules to query the knowledge, not implemented yet. - - Expected format: TBD -- [POST] `/store-knowledge`: Allows modules to store new knowledge, and notifies the response-generator that it should formulate a response. - - Expected format: - ```json - { - "triples": [ - { - "subject":, - "object": , - "predicate": - }, - ... - ] - } - ``` +Modules should generally use the Python Flask backend, which means that somewhere in the module's directory (often the root, but sometimes it is nested, e.g. see `front-end-quasar`) there will be an `app` directory, which is the Flask app. The Flask apps are always structured as follows: +``` +app +|- tests... --> The tests +|- util... --> All custom utilities and code +|- routes.py --> All the routes and communication related code +|- __init__.py --> Flask initialization/setup +``` -### `response-generator` -This module is responsible for formulating a human-readable response, based on the sentence received from the front-end and the data received from the reasoner. +The idea is to keep a clean separation of concerns: functionality related code will only be found in `util`, while `routes.py` only concerns itself with route definitions and request sending. + +The `requirements.txt` file should generally reside next to the `app` directory, which are both usually found in the root directory for most modules. Hence, the typical module looks like this: +``` +my-cool-new-module +|- Dockerfile +|- compose.yml +|- README.md +|- app... + |- tests... + |- util... + |- routes.py + |- __init__.py +|- requirements.txt +``` + + +## Extension Guide + +The previous section should already have outlined most of the details regarding the module structure, but here is a quick guide to get you started right away. + +1. The easiest way to get started is to just copy an existing module that most closely resembles what you want to create. + +2. Then, think of a good name. The naming convention used for the core modules is as follows: `-`. If the module is non-core, then you can use any name, as long as it doesn't clash with the core module type names (e.g. the Redis module is just called `redis`). Say you want to make a new Reasoner, by the name "brain", then you call it `reasoner-brain`. + +3. Rename everything that needs to be renamed: + - [ ] The module directory name + - [ ] The service name in the `compose.yml` file + - [ ] Fix the paths for the volumes and the build directory in the `compose.yml` file. + +4. Adjust/Add anything else you need in terms of configuration in the `compose.yml` file: + - [ ] Did you expose all the ports you need? + - [ ] Are there dependencies such as `redis`? Did you add them? + - [ ] Do you need additional bind mount or data volumes? + - [ ] Environment variables? + + Check https://docs.docker.com/compose/ for detailed configuration help. + +5. Tweak the Dockerfile: + - [ ] Change the base image, if you need something specific + - [ ] Install specific packages that you may need + - [ ] Install special requirements that you cannot put in `requirements.txt`, e.g. due to using a different package manager than pip for them. + - [ ] Other custom/misc. system setup that you may need. + + Check https://docs.docker.com/reference/dockerfile/ for a detailed Dockerfile reference. + + You can build the module from the root folder using `./chip.sh build `. + +6. Tweak the Python code to your liking: + - [ ] Add your requirements to `requirements.txt` + - [ ] Include your own code and utilities in `util` + - [ ] Expose the routes you need in `routes.py` and call your code from `util` + - [ ] Write your own tests + - [ ] Change the module's appearance in the logs, by changing the service name in the `__init__.py` of the `ServiceNameFilter` + + If the bind mounts are setup properly in `compose.yml`, the code should automatically sync. If the module uses Flask, it will auto-reload the application whenever you make a change. + + You can run the module from the root folder using `./chip.sh start `. + +7. Add any configuration you want to expose to `config.env.default`. They are made available as environment variables within the container, and the user may edit the generated `config.env` to tweak the settings you wish to make configurable. Think of things such as API keys, model name, performance settings, etc. + +## Tests and CI +CI is setup for the project, and will run automatically for any module that has a `tests` folder within an `app` folder, which is generally the file structure that Flask adheres to. Modules that have no such folder will not be considered for the test runner. -- [GET] `/`: The base route, welcomes you to the module -- [POST] `/submit-sentence`: Allows the front-end to submit a sentence sent by a patient/user to this module. - - Expected format: - ```json - { - "patient_name": , - "sentence": , - "timestamp": - } - ``` -- [POST] `/submit-reasoner-response`: Allows the reasoning module to submit a reasoning type and data that goes with it. - - Expected format (this might change depending on what we need): - ```json - { - "type": , - "data": - } - ``` \ No newline at end of file diff --git a/chip.sh b/chip.sh new file mode 100755 index 0000000..014f017 --- /dev/null +++ b/chip.sh @@ -0,0 +1,104 @@ +#!/bin/bash +if [ ! -f ./core-modules.yaml ]; then + echo "No core module configuration found, creating one from defaults..." + cp ./core-modules.yaml.default ./core-modules.yaml +fi + +for dir in ./modules/*; do + str+=" -f ${dir}/compose.yml" + if [ ! -f $dir/config.env ]; then + echo "No module configuration found for module ${dir%%:*}, creating one from defaults..." + cp $dir/config.env.default $dir/config.env + fi +done + +docker compose -f docker-compose-base.yml ${str} config > /tmp/chiptemp +modules=($(docker run --rm -v "/tmp":/workdir mikefarah/yq '.services.* | key + ":" + .expose[0]' chiptemp)) +rm /tmp/chiptemp + +core_modules=($(docker run --rm -v "${PWD}":/workdir mikefarah/yq '.* | key + ":" + .' core-modules.yaml)) + +> setup.env +for module in ${modules[@]}; do + name=${module%%:*} + name=${name//-/_} + name=$( echo $name | tr '[:lower:]' '[:upper:]') + echo ${name}=${module} >> setup.env + for core_module in ${core_modules[@]}; do + core=${core_module%%:*} + core=$( echo $core | tr '[:lower:]' '[:upper:]') + core_name=${core_module##*:} + core_name=${core_name//-/_} + core_name=$( echo $core_name | tr '[:lower:]' '[:upper:]') + if [[ "$core_name" == "$name" ]]; then + echo $core=$name >> setup.env + fi + done +done +echo "Created module-address and core-module mappings in setup.env..." + +modules_up="" +for core_module in ${core_modules[@]}; do + modules_up+=${core_module##*:}" " +done + + +if [[ -z "$1" ]] ; then +echo "Finished setting up!" +exit 0 +fi + +case $1 in + build) + if [[ -z "$2" ]] ; then + echo "Building core modules:" + echo $modules_up + docker compose -f docker-compose-base.yml ${str} build ${modules_up} + else + echo "Building specific modules: "+"${@:2}" + docker compose -f docker-compose-base.yml ${str} build "${@:2}" + fi + ;; + start) + if [[ -z "$2" ]] ; then + echo "Starting system with core modules:" + echo $modules_up + docker compose -f docker-compose-base.yml ${str} build ${modules_up} + docker compose -f docker-compose-base.yml ${str} up ${modules_up} + else + echo "Starting specific modules: "+"${@:2}" + docker compose -f docker-compose-base.yml ${str} build "${@:2}" + docker compose -f docker-compose-base.yml ${str} up "${@:2}" + fi + ;; + stop) + echo "Taking down full system..." + docker compose -f docker-compose-base.yml ${str} down + ;; + clean) + echo "Taking down full system and removing volume data..." + docker compose -f docker-compose-base.yml ${str} down -v + ;; + config) + echo "Showing the merged compose file that will be used..." + docker compose -f docker-compose-base.yml ${str} config + ;; + list) + echo "Listing all available modules..." + for module in ${modules[@]}; do + echo - ${module%%:*}" " + done + ;; + auto-completion) + the_source=$(readlink -f -- "${BASH_SOURCE[0]}") + the_dirname=$(dirname "${the_source}") + the_filename=$(basename "${the_source}") + echo $'\n'complete -W \"\$"(ls -C ${the_dirname}/modules/)"\" ./${the_filename} >> ~/.bashrc + . ~/.bashrc + echo "Added auto-completion to .bashrc" + ;; + *) + echo "Please use either 'build', 'start', 'stop', 'clean', 'config', 'list', 'auto-completion', or call without args to generate the necessary configuration without doing anything else." + echo "Add space-separated module names afterwards to perform the operation for specific modules." + ;; +esac \ No newline at end of file diff --git a/config.env b/config.env deleted file mode 100644 index 77e2d69..0000000 --- a/config.env +++ /dev/null @@ -1,7 +0,0 @@ -LOGGER_ADDRESS=logger:5000 -FRONTEND_ADDRESS=quasar:5000 -RESPONSE_GENERATOR_ADDRESS=response-generator:5000 -KNOWLEDGE_ADDRESS=knowledge:7200 -REASONING_ADDRESS=reasoning:5000 -TEXT_TO_TRIPLE_ADDRESS=text-to-triples:5000 -REDIS_ADDRESS=redis:6379 \ No newline at end of file diff --git a/core-modules.yaml.default b/core-modules.yaml.default new file mode 100644 index 0000000..a97c415 --- /dev/null +++ b/core-modules.yaml.default @@ -0,0 +1,5 @@ +logger_module: logger-default +frontend_module: front-end-quasar +response_generator_module: response-generator-demo +reasoner_module: reasoning-demo +triple_extractor_module: text-to-triples-rule-based \ No newline at end of file diff --git a/docker-compose-base.yml b/docker-compose-base.yml new file mode 100644 index 0000000..feab634 --- /dev/null +++ b/docker-compose-base.yml @@ -0,0 +1 @@ +services: {} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index cc340c6..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,73 +0,0 @@ -x-service-defaults: &service_defaults - env_file: config.env - networks: - - dockernetwork - expose: - - 5000 - -services: - knowledge: - << : *service_defaults - build: ./knowledge/. - expose: - - 7200 - entrypoint: - - "/entrypoint.sh" - - # Johanna + Shihan + Selene + Pei-Yu + Floris - # - Question Reasoning (rule-based): Floris - # - Advice Reasoning: Johanna - reasoning: - << : *service_defaults - build: ./reasoning/. - volumes: - - ./reasoning/app:/app - - ./reasoning/data:/data - - # Cristina + Floris - response-generator: - << : *service_defaults - build: ./response-generator/. - volumes: - - ./response-generator/app:/app - - # Stergios + Selene/Floris - text-to-triples: - << : *service_defaults - build: ./text-to-triples/. - volumes: - - ./text-to-triples/app:/app - - quasar: - << : *service_defaults - build: ./quasar/. - ports: - - "9000:9000" - volumes: - - ./quasar/backend:/backend - - ./quasar/frontend:/frontend - - redis: - << : *service_defaults - image: redis:latest - volumes: - - redisdata:/data - - # Shaad - logger: - << : *service_defaults - build: ./logger/. - ports: ["8010:5000"] - volumes: - - ./logger/app:/app - - ./logger/logs:/logs - # Otherwise we get double logging in the console. NOTE: for some reason not working... - logging: - driver: none - -networks: - dockernetwork: - driver: bridge - -volumes: - redisdata: \ No newline at end of file diff --git a/front-end/src/app.py b/front-end/src/app.py deleted file mode 100644 index e288154..0000000 --- a/front-end/src/app.py +++ /dev/null @@ -1,43 +0,0 @@ -from flask import Flask, request -import requests -import json - -app = Flask(__name__) -app.debug = True - -# This is the front-end's "back-end" (as silly as that sounds) -# Here we can get input from the other containers - -# Since this is the front-end, it is exposed to the host -# Meaning you can just go to "localhost:5000" in your web browser -# And access it. By default, it should show the "hello" sentence below. - -# The default route -@app.route('/') -def hello(): - return 'Hello, I am the front end module!' - -# Initialize everything - for now just configuring the knowledge base -@app.route('/init') -def init(): - files = {'config': ('config', open('/data/repo-config.ttl', 'rb'))} - res = requests.post(f'http://knowledge:7200/rest/repositories', files=files) - if res.status_code in range(200, 300): - return f"Successfully initialized GraphDB repository (status code {res.status_code})" - return f"There was potentially a problem with initializing the repository (status code {res.status_code}): {res.text}" - -# Can ping the other containers with this -# Type e.g. "localhost:5000/ping/text-to-triples" to say hi to the text-to-triples container -@app.route('/ping/') -def ping(name): - r = requests.get(f'http://{name}:5000/') - return r.text - -@app.route('/response', methods=['POST']) -def response(): - data = request.json - print(f"Received a reply! {data}", flush=True) - return 'OK' - -if __name__ == '__main__': - app.run(host='0.0.0.0') diff --git a/front-end/Dockerfile b/modules/front-end-gradio/Dockerfile similarity index 95% rename from front-end/Dockerfile rename to modules/front-end-gradio/Dockerfile index 5593207..4907e14 100644 --- a/front-end/Dockerfile +++ b/modules/front-end-gradio/Dockerfile @@ -1,21 +1,21 @@ -FROM python:3.10-slim - -# Install python deps -COPY requirements.txt / -RUN pip3 install -r /requirements.txt - -# Copy source and data files -COPY src /src -COPY data /data - -# Copy and chmod start script -COPY start.sh / -RUN chmod a+x start.sh - -# Configure Gradio -ENV no_proxy="localhost,127.0.0.1" -ENV GRADIO_SERVER_NAME="0.0.0.0" -ENV COMMANDLINE_ARGS="--no-gradio-queue" -# ENV GRADIO_ROOT_PATH="0.0.0.0/gradio" -# Run the server +FROM python:3.10-slim + +# Install python deps +COPY requirements.txt / +RUN pip3 install -r /requirements.txt + +# Copy source and data files +COPY src /src +COPY data /data + +# Copy and chmod start script +COPY start.sh / +RUN chmod a+x start.sh + +# Configure Gradio +ENV no_proxy="localhost,127.0.0.1" +ENV GRADIO_SERVER_NAME="0.0.0.0" +ENV COMMANDLINE_ARGS="--no-gradio-queue" +# ENV GRADIO_ROOT_PATH="0.0.0.0/gradio" +# Run the server CMD [ "/start.sh" ] \ No newline at end of file diff --git a/modules/front-end-gradio/README.md b/modules/front-end-gradio/README.md new file mode 100644 index 0000000..ad2851b --- /dev/null +++ b/modules/front-end-gradio/README.md @@ -0,0 +1,49 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Gradio Front-End | +| **Type** | Front-End | +| **Core** | Yes | +| **Access URL** | http://localhost:8000/gradio/ | + +## Description +This is the old Gradio front-end. Since this module never got refactored, it is still based on an old format. It runs both the front-end and the accompanying backend in the same file. It features a chat input, that waits for a response from the response generator and then shows it. + +It is not recommended to use this, but it is still nice to have it as an example regardless. + +## Usage +Instructions: +1. Configure `core-modules.yaml` to use this module as the front-end. + +## Input/Output +Since this module still follows the old format, it uses a different route for dealing with input: + +### Input from `Response Generator` via `/response` route +```JSON + { + "message": // The generated message. + } +``` + +### Output to `Triple Extractor` +```JSON +{ + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) +} +``` + +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + +- [GET] `/ping/`: pings another module in the system, by triggering their `hello` route. + +- [GET] `/ping//`: allows you to reach the other routes of the other modules, useful for debugging. + + +## Internal Dependencies +None. + +## Required Resources +None. \ No newline at end of file diff --git a/modules/front-end-gradio/compose.yml b/modules/front-end-gradio/compose.yml new file mode 100644 index 0000000..2a1b7a7 --- /dev/null +++ b/modules/front-end-gradio/compose.yml @@ -0,0 +1,13 @@ +services: + front-end-gradio: + expose: + - 5000 + env_file: + - setup.env + - ./modules/front-end-gradio/config.env + build: ./modules/front-end-gradio/. + ports: ["8000:8000"] + volumes: + - ./modules/front-end-gradio/src:/src + - ./modules/front-end-gradio/data:/data + depends_on: [] \ No newline at end of file diff --git a/logger/logs/.keep b/modules/front-end-gradio/config.env.default similarity index 100% rename from logger/logs/.keep rename to modules/front-end-gradio/config.env.default diff --git a/front-end/requirements.txt b/modules/front-end-gradio/requirements.txt similarity index 95% rename from front-end/requirements.txt rename to modules/front-end-gradio/requirements.txt index 0c64e71..42eb7a2 100644 --- a/front-end/requirements.txt +++ b/modules/front-end-gradio/requirements.txt @@ -1,5 +1,5 @@ -requests==2.31.0 -Flask==3.0.3 -gradio==3.50.2 -fastapi==0.110.3 +requests==2.31.0 +Flask==3.0.3 +gradio==3.50.2 +fastapi==0.110.3 uvicorn==0.29.0 \ No newline at end of file diff --git a/front-end/src/gradio_app.py b/modules/front-end-gradio/src/gradio_app.py similarity index 86% rename from front-end/src/gradio_app.py rename to modules/front-end-gradio/src/gradio_app.py index 27cee88..d509a50 100644 --- a/front-end/src/gradio_app.py +++ b/modules/front-end-gradio/src/gradio_app.py @@ -1,77 +1,85 @@ -from fastapi import FastAPI, Request -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse -import gradio as gr -import requests -import datetime -import time - -CUSTOM_PATH = "/gradio" - -app = FastAPI() - -# For now used to wait for a response -# Will be set by the response generator via the /response route -resp = None - - -# NOTE: This is great for debugging, but we shouldn't do this in production... -@app.exception_handler(500) -async def internal_exception_handler(request: Request, exc: Exception): - return JSONResponse(status_code=500, content=jsonable_encoder({"code": 500, "msg": repr(exc)})) - -@app.get("/") -def read_main(): - return {"message": "Welcome to the CHIP demo!"} - -@app.post("/response") -async def response(request: Request): - data = await request.json() - print("Got a response", data, flush=True) - global resp - resp = data["reply"] - return {"message": "OK!"} - -@app.get('/ping/{name}') -def ping(name: str): - r = requests.get(f'http://{name}:5000/') - return r.text - -@app.get('/ping/{name}/{endpoint}') -def ping_endpoint(name: str, endpoint: str): - r = requests.get(f'http://{name}:5000/{endpoint}') - return r.text - -# I suspect that gradio only sends the sentence text to the method -# So for all intents and purposes we can just generate a timestamp -# And fix the patient's name. If we can somehow store the patient's -# name, then we should use that of course. -def send_to_t2t(chat_message): - payload = { - "patient_name": "John", - "sentence": chat_message, - "timestamp": datetime.datetime.now().isoformat() - } - requests.post(f"http://response-generator:5000/subject-sentence", json=payload) - requests.post(f"http://text-to-triples:5000/new-sentence", json=payload) - - # This will definitely change, but is good enough for the demo - # I just haven't found a way yet to make gradio update its UI from an - # API call... - global resp - reply = resp - while True: - time.sleep(0.2) - if resp is not None: - reply = resp - print(reply, flush=True) - resp = None - break - - print("Returning reply:", resp) - return reply - - -io = gr.Interface(fn=send_to_t2t, inputs="textbox", outputs="textbox") -gradio_app = gr.routes.App.create_app(io) +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +import gradio as gr +import requests +import datetime +import os +import time + +CUSTOM_PATH = "/gradio" + +app = FastAPI() + +# For now used to wait for a response +# Will be set by the response generator via the /response route +resp = None + +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None + +# NOTE: This is great for debugging, but we shouldn't do this in production... +@app.exception_handler(500) +async def internal_exception_handler(request: Request, exc: Exception): + return JSONResponse(status_code=500, content=jsonable_encoder({"code": 500, "msg": repr(exc)})) + +@app.get("/") +def read_main(): + return {"message": "Welcome to the CHIP demo!"} + +@app.post("/response") +async def response(request: Request): + data = await request.json() + print("Got a response", data, flush=True) + global resp + resp = data["reply"] + return {"message": "OK!"} + +@app.get('/ping/{name}') +def ping(name: str): + r = requests.get(f'http://{name}:5000/') + return r.text + +@app.get('/ping/{name}/{endpoint}') +def ping_endpoint(name: str, endpoint: str): + r = requests.get(f'http://{name}:5000/{endpoint}') + return r.text + +# I suspect that gradio only sends the sentence text to the method +# So for all intents and purposes we can just generate a timestamp +# And fix the patient's name. If we can somehow store the patient's +# name, then we should use that of course. +def send_to_t2t(chat_message): + payload = { + "patient_name": "John", + "sentence": chat_message, + "timestamp": datetime.datetime.now().isoformat() + } + + triple_extractor_address = core_module_address('TRIPLE_EXTRACTOR_MODULE') + if triple_extractor_address: + requests.post(f"http://{triple_extractor_address}/new-sentence", json=payload) + + # This will definitely change, but is good enough for the demo + # I just haven't found a way yet to make gradio update its UI from an + # API call... + global resp + reply = resp + while True: + time.sleep(0.2) + if resp is not None: + reply = resp + print(reply, flush=True) + resp = None + break + + print("Returning reply:", resp) + return reply + + +io = gr.Interface(fn=send_to_t2t, inputs="textbox", outputs="textbox") +gradio_app = gr.routes.App.create_app(io) app.mount(CUSTOM_PATH, gradio_app) \ No newline at end of file diff --git a/front-end/start.sh b/modules/front-end-gradio/start.sh similarity index 100% rename from front-end/start.sh rename to modules/front-end-gradio/start.sh diff --git a/quasar/Dockerfile b/modules/front-end-quasar/Dockerfile similarity index 100% rename from quasar/Dockerfile rename to modules/front-end-quasar/Dockerfile diff --git a/modules/front-end-quasar/README.md b/modules/front-end-quasar/README.md new file mode 100644 index 0000000..e17ae03 --- /dev/null +++ b/modules/front-end-quasar/README.md @@ -0,0 +1,48 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Quasar Front-End | +| **Type** | Front-End | +| **Core** | Yes | +| **Access URL** | http://localhost:9000/ | + +## Description +This is the main front-end that the demo uses. It features a chat window with scrollable history, and a panel that gives a view of the GraphDB knowledge base. + +It is built in Quasar (based on Vue3), and has an accompanying backend that handles communication with the rest of the modules, through HTTP requests and SSE (for communicating back to the front-end). + +## Usage +Instructions: +1. Configure `core-modules.yaml` to use this module as the front-end. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Response Generator` +```JSON + { + "message": // The generated message. + } +``` + +### Output to `Triple Extractor` +```JSON +{ + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) +} +``` + +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + +- [POST] `/submit`: used to submit a sentence from the UI to the backend. See `Output` above for format. + + +## Internal Dependencies +- `redis` - for SSE +- `knowledge` - for GraphDB visualization and DB + +## Required Resources +None. \ No newline at end of file diff --git a/quasar/backend/app/__init__.py b/modules/front-end-quasar/backend/app/__init__.py similarity index 59% rename from quasar/backend/app/__init__.py rename to modules/front-end-quasar/backend/app/__init__.py index d686e4a..d7d7145 100644 --- a/quasar/backend/app/__init__.py +++ b/modules/front-end-quasar/backend/app/__init__.py @@ -3,31 +3,40 @@ from flask_cors import CORS from logging.handlers import HTTPHandler from logging import Filter -from app.db import close_db import os -class ServiceNameFilter(Filter): +class ServiceNameFilter(Filter): # pragma: no cover def filter(self, record): record.service_name = "Website Backend" return True +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None + + def create_app(test=False): flask_app = Flask(__name__) CORS(flask_app) + logger_address = core_module_address('LOGGER_MODULE') - logger_address = os.environ.get("LOGGER_ADDRESS", None) - if logger_address and not test: + if logger_address and not test: # pragma: no cover http_handler = HTTPHandler(logger_address, "/log", method="POST") flask_app.logger.addFilter(ServiceNameFilter()) flask_app.logger.addHandler(http_handler) - redis_address = os.environ.get("REDIS_ADDRESS", None) + triple_extractor_address = core_module_address('TRIPLE_EXTRACTOR_MODULE') + if triple_extractor_address: + flask_app.config['TRIPLE_EXTRACTOR_ADDRESS'] = triple_extractor_address + + redis_address = os.environ.get("REDIS", None) if redis_address: flask_app.config['REDIS_ADDRESS'] = redis_address flask_app.config['REDIS_URL'] = f'redis://{redis_address}' - flask_app.teardown_appcontext(close_db) from app.routes import bp flask_app.register_blueprint(bp) diff --git a/quasar/backend/app/routes.py b/modules/front-end-quasar/backend/app/routes.py similarity index 50% rename from quasar/backend/app/routes.py rename to modules/front-end-quasar/backend/app/routes.py index 93ee63b..eb28ead 100644 --- a/quasar/backend/app/routes.py +++ b/modules/front-end-quasar/backend/app/routes.py @@ -1,8 +1,7 @@ -from flask import Blueprint, current_app, request, jsonify, redirect, Response +from flask import Blueprint, current_app, request import flask_sse import requests -import os -from datetime import datetime + bp = Blueprint('main', __name__) @@ -12,7 +11,7 @@ def hello(): return 'Hello, I am the website backend module!' -@bp.route('/response', methods=['POST']) +@bp.route('/process', methods=['POST']) def response(): data = request.json flask_sse.sse.publish({'message': data['message']}, type='response') @@ -22,13 +21,9 @@ def response(): @bp.route('/submit', methods=['POST']) def submit(): data = request.json - - resgen_address = os.environ.get("RESPONSE_GENERATOR_ADDRESS", None) - if resgen_address: - requests.post(f"http://{resgen_address}/subject-sentence", json=data) - t2t_address = os.environ.get("TEXT_TO_TRIPLE_ADDRESS", None) + t2t_address = current_app.config.get("TRIPLE_EXTRACTOR_ADDRESS", None) if t2t_address: - requests.post(f"http://{t2t_address}/new-sentence", json=data) + requests.post(f"http://{t2t_address}/process", json=data) return f"Submitted sentence '{data['sentence']}' from {data['patient_name']} to t2t!" diff --git a/quasar/backend/app/tests/conftest.py b/modules/front-end-quasar/backend/app/tests/conftest.py similarity index 65% rename from quasar/backend/app/tests/conftest.py rename to modules/front-end-quasar/backend/app/tests/conftest.py index c71d1c6..9a59c11 100644 --- a/quasar/backend/app/tests/conftest.py +++ b/modules/front-end-quasar/backend/app/tests/conftest.py @@ -9,7 +9,20 @@ def __eq__(self, other): @pytest.fixture() -def application(): +def triple_address(): + return "dummy" + + +@pytest.fixture() +def redis_address(): + return "dummy" + + +@pytest.fixture() +def application(monkeypatch, triple_address, redis_address): + monkeypatch.setenv("TRIPLE_EXTRACTOR_MODULE", "TEST_MOD_1") + monkeypatch.setenv("TEST_MOD_1", triple_address) + monkeypatch.setenv("REDIS", redis_address) yield create_app(test=True) diff --git a/quasar/backend/app/tests/test_app.py b/modules/front-end-quasar/backend/app/tests/test_app.py similarity index 61% rename from quasar/backend/app/tests/test_app.py rename to modules/front-end-quasar/backend/app/tests/test_app.py index f74d4e0..d792a69 100644 --- a/quasar/backend/app/tests/test_app.py +++ b/modules/front-end-quasar/backend/app/tests/test_app.py @@ -8,19 +8,16 @@ def test_hello(client): def test_response(client, message_data): with patch('flask_sse.sse') as sse: - res = client.post(f"/response", json=message_data) + res = client.post(f"/process", json=message_data) sse.publish.assert_called_once_with(message_data, type='response') assert res.status_code == 200 and len(res.text) > 0 -def test_submit(client, monkeypatch, sentence_data): - dummy_address = 'dummy' - monkeypatch.setenv("TEXT_TO_TRIPLE_ADDRESS", dummy_address) - monkeypatch.setenv("RESPONSE_GENERATOR_ADDRESS", dummy_address) +def test_submit(client, sentence_data, triple_address): with patch('app.routes.requests') as r: res = client.post(f"/submit", json=sentence_data) - r.post.assert_called_with(AnyStringWith(dummy_address), json=sentence_data) + r.post.assert_called_with(AnyStringWith(triple_address), json=sentence_data) assert res.status_code == 200 and sentence_data['sentence'] in res.text diff --git a/quasar/frontend/src/boot/.gitkeep b/modules/front-end-quasar/backend/app/util/__init__.py similarity index 100% rename from quasar/frontend/src/boot/.gitkeep rename to modules/front-end-quasar/backend/app/util/__init__.py diff --git a/quasar/backend/requirements.txt b/modules/front-end-quasar/backend/requirements.txt similarity index 100% rename from quasar/backend/requirements.txt rename to modules/front-end-quasar/backend/requirements.txt diff --git a/modules/front-end-quasar/compose.yml b/modules/front-end-quasar/compose.yml new file mode 100644 index 0000000..a9910b8 --- /dev/null +++ b/modules/front-end-quasar/compose.yml @@ -0,0 +1,16 @@ +services: + front-end-quasar: + expose: + - 5000 + env_file: + - setup.env + - ./modules/front-end-quasar/config.env + build: ./modules/front-end-quasar/. + ports: + - "9000:9000" + volumes: + - ./modules/front-end-quasar/backend:/backend + - ./modules/front-end-quasar/frontend:/frontend + depends_on: + - knowledge-demo + - redis \ No newline at end of file diff --git a/modules/front-end-quasar/config.env.default b/modules/front-end-quasar/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/quasar/frontend/.editorconfig b/modules/front-end-quasar/frontend/.editorconfig similarity index 100% rename from quasar/frontend/.editorconfig rename to modules/front-end-quasar/frontend/.editorconfig diff --git a/quasar/frontend/.eslintignore b/modules/front-end-quasar/frontend/.eslintignore similarity index 100% rename from quasar/frontend/.eslintignore rename to modules/front-end-quasar/frontend/.eslintignore diff --git a/quasar/frontend/.eslintrc.cjs b/modules/front-end-quasar/frontend/.eslintrc.cjs similarity index 100% rename from quasar/frontend/.eslintrc.cjs rename to modules/front-end-quasar/frontend/.eslintrc.cjs diff --git a/quasar/frontend/.gitignore b/modules/front-end-quasar/frontend/.gitignore similarity index 100% rename from quasar/frontend/.gitignore rename to modules/front-end-quasar/frontend/.gitignore diff --git a/quasar/frontend/.npmrc b/modules/front-end-quasar/frontend/.npmrc similarity index 100% rename from quasar/frontend/.npmrc rename to modules/front-end-quasar/frontend/.npmrc diff --git a/quasar/frontend/.prettierrc b/modules/front-end-quasar/frontend/.prettierrc similarity index 100% rename from quasar/frontend/.prettierrc rename to modules/front-end-quasar/frontend/.prettierrc diff --git a/quasar/frontend/.vscode/extensions.json b/modules/front-end-quasar/frontend/.vscode/extensions.json similarity index 100% rename from quasar/frontend/.vscode/extensions.json rename to modules/front-end-quasar/frontend/.vscode/extensions.json diff --git a/quasar/frontend/.vscode/settings.json b/modules/front-end-quasar/frontend/.vscode/settings.json similarity index 100% rename from quasar/frontend/.vscode/settings.json rename to modules/front-end-quasar/frontend/.vscode/settings.json diff --git a/quasar/frontend/README.md b/modules/front-end-quasar/frontend/README.md similarity index 100% rename from quasar/frontend/README.md rename to modules/front-end-quasar/frontend/README.md diff --git a/quasar/frontend/index.html b/modules/front-end-quasar/frontend/index.html similarity index 100% rename from quasar/frontend/index.html rename to modules/front-end-quasar/frontend/index.html diff --git a/quasar/frontend/package-lock.json b/modules/front-end-quasar/frontend/package-lock.json similarity index 100% rename from quasar/frontend/package-lock.json rename to modules/front-end-quasar/frontend/package-lock.json diff --git a/quasar/frontend/package.json b/modules/front-end-quasar/frontend/package.json similarity index 100% rename from quasar/frontend/package.json rename to modules/front-end-quasar/frontend/package.json diff --git a/quasar/frontend/postcss.config.mjs b/modules/front-end-quasar/frontend/postcss.config.mjs similarity index 100% rename from quasar/frontend/postcss.config.mjs rename to modules/front-end-quasar/frontend/postcss.config.mjs diff --git a/quasar/frontend/public/favicon.ico b/modules/front-end-quasar/frontend/public/favicon.ico similarity index 100% rename from quasar/frontend/public/favicon.ico rename to modules/front-end-quasar/frontend/public/favicon.ico diff --git a/quasar/frontend/public/icons/favicon-128x128.png b/modules/front-end-quasar/frontend/public/icons/favicon-128x128.png similarity index 100% rename from quasar/frontend/public/icons/favicon-128x128.png rename to modules/front-end-quasar/frontend/public/icons/favicon-128x128.png diff --git a/quasar/frontend/public/icons/favicon-16x16.png b/modules/front-end-quasar/frontend/public/icons/favicon-16x16.png similarity index 100% rename from quasar/frontend/public/icons/favicon-16x16.png rename to modules/front-end-quasar/frontend/public/icons/favicon-16x16.png diff --git a/quasar/frontend/public/icons/favicon-32x32.png b/modules/front-end-quasar/frontend/public/icons/favicon-32x32.png similarity index 100% rename from quasar/frontend/public/icons/favicon-32x32.png rename to modules/front-end-quasar/frontend/public/icons/favicon-32x32.png diff --git a/quasar/frontend/public/icons/favicon-96x96.png b/modules/front-end-quasar/frontend/public/icons/favicon-96x96.png similarity index 100% rename from quasar/frontend/public/icons/favicon-96x96.png rename to modules/front-end-quasar/frontend/public/icons/favicon-96x96.png diff --git a/quasar/frontend/quasar.config.ts b/modules/front-end-quasar/frontend/quasar.config.ts similarity index 99% rename from quasar/frontend/quasar.config.ts rename to modules/front-end-quasar/frontend/quasar.config.ts index 263891b..4c6ace5 100644 --- a/quasar/frontend/quasar.config.ts +++ b/modules/front-end-quasar/frontend/quasar.config.ts @@ -113,7 +113,7 @@ export default defineConfig((/* ctx */) => { rewrite: (path) => path.replace(/^\/api/, ''), }, '/kgraph': { - target: 'http://knowledge:7200', + target: `http://${process.env.KNOWLEDGE_DEMO}`, changeOrigin: true, rewrite: (path) => path.replace(/^\/kgraph/, ''), }, diff --git a/quasar/frontend/src/App.vue b/modules/front-end-quasar/frontend/src/App.vue similarity index 100% rename from quasar/frontend/src/App.vue rename to modules/front-end-quasar/frontend/src/App.vue diff --git a/quasar/frontend/src/assets/quasar-logo-vertical.svg b/modules/front-end-quasar/frontend/src/assets/quasar-logo-vertical.svg similarity index 100% rename from quasar/frontend/src/assets/quasar-logo-vertical.svg rename to modules/front-end-quasar/frontend/src/assets/quasar-logo-vertical.svg diff --git a/modules/front-end-quasar/frontend/src/boot/.gitkeep b/modules/front-end-quasar/frontend/src/boot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/quasar/frontend/src/components/ChatWindow.vue b/modules/front-end-quasar/frontend/src/components/ChatWindow.vue similarity index 88% rename from quasar/frontend/src/components/ChatWindow.vue rename to modules/front-end-quasar/frontend/src/components/ChatWindow.vue index 2921e8d..10490b1 100644 --- a/quasar/frontend/src/components/ChatWindow.vue +++ b/modules/front-end-quasar/frontend/src/components/ChatWindow.vue @@ -11,6 +11,13 @@ :sent="item.user?.human" :name="item.user?.name" /> + + + - console.log(response), + fetch('/api/submit', requestOptions).then((response) => { + console.log(response); + } ); + waiting = true; scrollToBottom(); emit('reloadVisualization'); @@ -94,6 +104,7 @@ onMounted(() => { user: botUser, timestamp: new Date().toISOString() }); + waiting = false; scrollToBottom(); console.log(`Received response: ${data.message}`); }, diff --git a/quasar/frontend/src/components/EssentialLink.vue b/modules/front-end-quasar/frontend/src/components/EssentialLink.vue similarity index 100% rename from quasar/frontend/src/components/EssentialLink.vue rename to modules/front-end-quasar/frontend/src/components/EssentialLink.vue diff --git a/quasar/frontend/src/components/models.ts b/modules/front-end-quasar/frontend/src/components/models.ts similarity index 100% rename from quasar/frontend/src/components/models.ts rename to modules/front-end-quasar/frontend/src/components/models.ts diff --git a/quasar/frontend/src/css/app.scss b/modules/front-end-quasar/frontend/src/css/app.scss similarity index 100% rename from quasar/frontend/src/css/app.scss rename to modules/front-end-quasar/frontend/src/css/app.scss diff --git a/quasar/frontend/src/css/quasar.variables.scss b/modules/front-end-quasar/frontend/src/css/quasar.variables.scss similarity index 100% rename from quasar/frontend/src/css/quasar.variables.scss rename to modules/front-end-quasar/frontend/src/css/quasar.variables.scss diff --git a/quasar/frontend/src/env.d.ts b/modules/front-end-quasar/frontend/src/env.d.ts similarity index 100% rename from quasar/frontend/src/env.d.ts rename to modules/front-end-quasar/frontend/src/env.d.ts diff --git a/quasar/frontend/src/layouts/MainLayout.vue b/modules/front-end-quasar/frontend/src/layouts/MainLayout.vue similarity index 100% rename from quasar/frontend/src/layouts/MainLayout.vue rename to modules/front-end-quasar/frontend/src/layouts/MainLayout.vue diff --git a/quasar/frontend/src/pages/ErrorNotFound.vue b/modules/front-end-quasar/frontend/src/pages/ErrorNotFound.vue similarity index 100% rename from quasar/frontend/src/pages/ErrorNotFound.vue rename to modules/front-end-quasar/frontend/src/pages/ErrorNotFound.vue diff --git a/quasar/frontend/src/pages/IndexPage.vue b/modules/front-end-quasar/frontend/src/pages/IndexPage.vue similarity index 100% rename from quasar/frontend/src/pages/IndexPage.vue rename to modules/front-end-quasar/frontend/src/pages/IndexPage.vue diff --git a/quasar/frontend/src/router/index.ts b/modules/front-end-quasar/frontend/src/router/index.ts similarity index 100% rename from quasar/frontend/src/router/index.ts rename to modules/front-end-quasar/frontend/src/router/index.ts diff --git a/quasar/frontend/src/router/routes.ts b/modules/front-end-quasar/frontend/src/router/routes.ts similarity index 100% rename from quasar/frontend/src/router/routes.ts rename to modules/front-end-quasar/frontend/src/router/routes.ts diff --git a/quasar/frontend/src/stores/index.ts b/modules/front-end-quasar/frontend/src/stores/index.ts similarity index 100% rename from quasar/frontend/src/stores/index.ts rename to modules/front-end-quasar/frontend/src/stores/index.ts diff --git a/quasar/frontend/src/stores/message-store.ts b/modules/front-end-quasar/frontend/src/stores/message-store.ts similarity index 100% rename from quasar/frontend/src/stores/message-store.ts rename to modules/front-end-quasar/frontend/src/stores/message-store.ts diff --git a/quasar/frontend/src/stores/user-store.ts b/modules/front-end-quasar/frontend/src/stores/user-store.ts similarity index 100% rename from quasar/frontend/src/stores/user-store.ts rename to modules/front-end-quasar/frontend/src/stores/user-store.ts diff --git a/quasar/frontend/tsconfig.json b/modules/front-end-quasar/frontend/tsconfig.json similarity index 100% rename from quasar/frontend/tsconfig.json rename to modules/front-end-quasar/frontend/tsconfig.json diff --git a/quasar/start.sh b/modules/front-end-quasar/start.sh similarity index 100% rename from quasar/start.sh rename to modules/front-end-quasar/start.sh diff --git a/knowledge/Dockerfile b/modules/knowledge-demo/Dockerfile similarity index 57% rename from knowledge/Dockerfile rename to modules/knowledge-demo/Dockerfile index 5c76885..098fc52 100644 --- a/knowledge/Dockerfile +++ b/modules/knowledge-demo/Dockerfile @@ -1,7 +1,9 @@ -FROM ontotext/graphdb:10.6.3 - -# Copy over data -COPY data /data - -# Copy over entrypoint -COPY entrypoint.sh /entrypoint.sh \ No newline at end of file +FROM ontotext/graphdb:10.6.3 + +# Copy over data +COPY data /data + +# Copy over entrypoint +COPY entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/modules/knowledge-demo/compose.yml b/modules/knowledge-demo/compose.yml new file mode 100644 index 0000000..3e9644a --- /dev/null +++ b/modules/knowledge-demo/compose.yml @@ -0,0 +1,12 @@ +services: + knowledge-demo: + env_file: + - setup.env + - ./modules/knowledge-demo/config.env + build: ./modules/knowledge-demo/. + expose: + - 7200 + ports: ["7200:7200"] + entrypoint: + - "/entrypoint.sh" + depends_on: [] \ No newline at end of file diff --git a/modules/knowledge-demo/config.env.default b/modules/knowledge-demo/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/knowledge/data/data.rdf b/modules/knowledge-demo/data/data.rdf similarity index 100% rename from knowledge/data/data.rdf rename to modules/knowledge-demo/data/data.rdf diff --git a/knowledge/data/repo-config.ttl b/modules/knowledge-demo/data/repo-config.ttl similarity index 100% rename from knowledge/data/repo-config.ttl rename to modules/knowledge-demo/data/repo-config.ttl diff --git a/knowledge/data/userKG.owl b/modules/knowledge-demo/data/userKG.owl similarity index 100% rename from knowledge/data/userKG.owl rename to modules/knowledge-demo/data/userKG.owl diff --git a/knowledge/data/userKG_inferred.rdf b/modules/knowledge-demo/data/userKG_inferred.rdf similarity index 100% rename from knowledge/data/userKG_inferred.rdf rename to modules/knowledge-demo/data/userKG_inferred.rdf diff --git a/knowledge/data/userKG_inferred_stripped.rdf b/modules/knowledge-demo/data/userKG_inferred_stripped.rdf similarity index 100% rename from knowledge/data/userKG_inferred_stripped.rdf rename to modules/knowledge-demo/data/userKG_inferred_stripped.rdf diff --git a/knowledge/entrypoint.sh b/modules/knowledge-demo/entrypoint.sh old mode 100644 new mode 100755 similarity index 91% rename from knowledge/entrypoint.sh rename to modules/knowledge-demo/entrypoint.sh index fc626d1..46f1071 --- a/knowledge/entrypoint.sh +++ b/modules/knowledge-demo/entrypoint.sh @@ -2,4 +2,4 @@ # curl -F config=@/data/repo-config.ttl localhost:7200/rest/repositories /opt/graphdb/dist/bin/importrdf preload -f -c /data/repo-config.ttl /data/userKG_inferred_stripped.rdf -/opt/graphdb/dist/bin/graphdb -Dgraphdb.external-url=http://localhost:9000/kgraph/ +/opt/graphdb/dist/bin/graphdb -Dgraphdb.external-url=http://localhost:9000/kgraph/ -Xmx1G \ No newline at end of file diff --git a/logger/Dockerfile b/modules/logger-default/Dockerfile similarity index 94% rename from logger/Dockerfile rename to modules/logger-default/Dockerfile index f232df5..c76651d 100644 --- a/logger/Dockerfile +++ b/modules/logger-default/Dockerfile @@ -1,13 +1,13 @@ -FROM python:3.10-slim - -# Install python deps -COPY requirements.txt / -RUN pip3 install -r /requirements.txt - -# Copy over source -COPY app /app - -RUN mkdir /logs - -# Run the server -CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] +FROM python:3.10-slim + +# Install python deps +COPY requirements.txt / +RUN pip3 install -r /requirements.txt + +# Copy over source +COPY app /app + +RUN mkdir /logs + +# Run the server +CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] diff --git a/logger/app/__init__.py b/modules/logger-default/app/__init__.py similarity index 100% rename from logger/app/__init__.py rename to modules/logger-default/app/__init__.py diff --git a/logger/app/routes.py b/modules/logger-default/app/routes.py similarity index 100% rename from logger/app/routes.py rename to modules/logger-default/app/routes.py diff --git a/logger/app/tests/conftest.py b/modules/logger-default/app/tests/conftest.py similarity index 100% rename from logger/app/tests/conftest.py rename to modules/logger-default/app/tests/conftest.py diff --git a/logger/app/tests/test_app.py b/modules/logger-default/app/tests/test_app.py similarity index 100% rename from logger/app/tests/test_app.py rename to modules/logger-default/app/tests/test_app.py diff --git a/modules/logger-default/compose.yml b/modules/logger-default/compose.yml new file mode 100644 index 0000000..1a3e472 --- /dev/null +++ b/modules/logger-default/compose.yml @@ -0,0 +1,16 @@ +services: + logger-default: + env_file: + - setup.env + - ./modules/logger-default/config.env + expose: + - 5000 + build: ./modules/logger-default/. + ports: ["8010:5000"] + volumes: + - ./modules/logger-default/app:/app + - ./modules/logger-default/logs:/logs + # Otherwise we get double logging in the console. + logging: + driver: none + depends_on: [] \ No newline at end of file diff --git a/modules/logger-default/config.env.default b/modules/logger-default/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/modules/logger-default/logs/.keep b/modules/logger-default/logs/.keep new file mode 100644 index 0000000..e69de29 diff --git a/logger/requirements.txt b/modules/logger-default/requirements.txt similarity index 96% rename from logger/requirements.txt rename to modules/logger-default/requirements.txt index 49bc42f..ec682c4 100644 --- a/logger/requirements.txt +++ b/modules/logger-default/requirements.txt @@ -1,2 +1,2 @@ -requests==2.31.0 +requests==2.31.0 Flask==3.0.3 \ No newline at end of file diff --git a/modules/logger-default/util/__init__.py b/modules/logger-default/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reasoning/Dockerfile b/modules/reasoning-demo/Dockerfile similarity index 95% rename from reasoning/Dockerfile rename to modules/reasoning-demo/Dockerfile index 697c5e8..683cac8 100644 --- a/reasoning/Dockerfile +++ b/modules/reasoning-demo/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.10-slim - -# Install python deps -COPY requirements.txt / -RUN pip3 install -r /requirements.txt - -# Copy over source -COPY app /app - -# Run the server -CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] +FROM python:3.10-slim + +# Install python deps +COPY requirements.txt / +RUN pip3 install -r /requirements.txt + +# Copy over source +COPY app /app + +# Run the server +CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] diff --git a/modules/reasoning-demo/README.md b/modules/reasoning-demo/README.md new file mode 100644 index 0000000..765a619 --- /dev/null +++ b/modules/reasoning-demo/README.md @@ -0,0 +1,84 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Demo Reasoning | +| **Type** | Reasoner | +| **Core** | Yes | +| **Access URL** | N/A | + +## Description +This Reasoner is intended as a demo module, for following the demo scenario outlined in `demo_scenario.md`. It queries the `knowledge-demo` GraphDB database to infer suitable activities to partake in for a diabetes patient by the name "John", based on his values and preferences. + +To obtain these values and preferences, it also inserts RDF triples into `knowledge-demo` based on information obtained from the Triple Extractor. + +## Usage +Instructions: +1. Configure `core-modules.yaml` to use this module as the reasoner. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Triple Extractor` +```JSON +{ + "sentence_data": { + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) + }, + "triples": [ + { + "subject":, + "object": , + "predicate": + }, + ... + ] +} +``` +### Output to `Response Generator` +```JSON + { + "sentence_data": { + "patient_name": , // The name of the user currently chatting. + "sentence": , // The sentence that the user submitted. + "timestamp": // The time at which the user submitted the sentence (ISO format). + }, + "type": , // Whether the reasoner decided to give an answer (A) or to request more information (Q). + "data": // A dict containing the output of the reasoner. + } +``` +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. +--- +- [POST] `/store`: stores RDF triples into the knowledge based on a list of SPO triples. + +**Model** +```JSON + [ // An array of SPO triples. + { + "subject":, + "object": , + "predicate": + }, + ... + ] +``` +--- +- [POST] `/reason`: reasons over the currently available knowledge based on the sentence submitted by the user. + +**Model** +```JSON + "sentence_data": { + "patient_name": , // The name of the user currently chatting. + "sentence": , // The sentence that the user submitted. + "timestamp": // The time at which the user submitted the sentence (ISO format). + }, +``` +--- + +## Internal Dependencies +- `knowledge-demo` + +## Required Resources +None. \ No newline at end of file diff --git a/reasoning/app/__init__.py b/modules/reasoning-demo/app/__init__.py similarity index 62% rename from reasoning/app/__init__.py rename to modules/reasoning-demo/app/__init__.py index 766b448..49026b9 100644 --- a/reasoning/app/__init__.py +++ b/modules/reasoning-demo/app/__init__.py @@ -1,7 +1,7 @@ from flask import Flask from logging.handlers import HTTPHandler from logging import Filter -from app.db import close_db +from app.util.db import close_db import os @@ -10,17 +10,26 @@ def filter(self, record): record.service_name = "Reasoner" return True +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None def create_app(test=False): flask_app = Flask(__name__) - logger_address = os.environ.get("LOGGER_ADDRESS", None) + logger_address = core_module_address("LOGGER_MODULE") if logger_address and not test: http_handler = HTTPHandler(logger_address, "/log", method="POST") flask_app.logger.addFilter(ServiceNameFilter()) flask_app.logger.addHandler(http_handler) - knowledge_address = os.environ.get("KNOWLEDGE_ADDRESS", None) + response_generator_address = core_module_address("RESPONSE_GENERATOR_MODULE") + if response_generator_address: + flask_app.config["RESPONSE_GENERATOR_ADDRESS"] = response_generator_address + + knowledge_address = os.environ.get("KNOWLEDGE_DEMO", None) if knowledge_address: repository_name = 'repo-test-1' # This is temporary flask_app.config['knowledge_url'] = f"http://{knowledge_address}/repositories/{repository_name}" diff --git a/modules/reasoning-demo/app/routes.py b/modules/reasoning-demo/app/routes.py new file mode 100644 index 0000000..897f4c7 --- /dev/null +++ b/modules/reasoning-demo/app/routes.py @@ -0,0 +1,44 @@ +from flask import Blueprint, current_app, request +from contextlib import suppress +import app.util + +bp = Blueprint('main', __name__) + + +@bp.route('/') +def hello(): + return 'Hello, I am the reasoning module!' + + +@bp.route('/process', methods=['POST']) +def process(): + current_app.logger.info(f"Triples received: {request.json}") + json_data = request.json + triples = json_data['triples'] + sentence_data = json_data['sentence_data'] + + # Keep the flow going regardless of the storage error. + with suppress(RuntimeError): + app.util.store_knowledge(triples) + app.util.reason_and_notify_response_generator(sentence_data) + return 'OK', 200 + + +@bp.route('/store', methods=['POST']) +def store(): + json_data = request.json + triples = json_data['triples'] + + try: + app.util.store_knowledge(triples) + return 'OK', 200 + except RuntimeError as e: + return str(e), 500 + + +@bp.route('/reason', methods=['POST']) +def reason(): + sentence_data = request.json + app.util.reason_and_notify_response_generator(sentence_data) + return 'OK', 200 + diff --git a/modules/reasoning-demo/app/tests/conftest.py b/modules/reasoning-demo/app/tests/conftest.py new file mode 100644 index 0000000..840dae0 --- /dev/null +++ b/modules/reasoning-demo/app/tests/conftest.py @@ -0,0 +1,112 @@ +import pytest +from app import create_app +from unittest.mock import Mock, MagicMock, patch +from types import SimpleNamespace + + +class AnyStringWith(str): + def __eq__(self, other): + return self in other + + +@pytest.fixture() +def response_generator_address(): + return "dummy_response_generator" + + +@pytest.fixture() +def knowledge_address(): + return "dummy_knowledge" + + + +@pytest.fixture() +def application(monkeypatch, response_generator_address, knowledge_address): + monkeypatch.setenv("KNOWLEDGE_DEMO", knowledge_address) + monkeypatch.setenv("RESPONSE_GENERATOR_MODULE", "TEST_MOD_1") + monkeypatch.setenv("TEST_MOD_1", response_generator_address) + app = create_app(test=True) + # For detecting errors and disabling logging in general + setattr(app, "logger", Mock(app.logger)) + app.config["DEBUG"] = True # Actually give stack-traces on client failures. + yield app + + +@pytest.fixture() +def client(application): + return application.test_client() + + +@pytest.fixture() +def util(): + with patch('app.util') as util: + yield util + + +@pytest.fixture() +def reason_question(): + with patch('app.util.reason_question') as reason_question: + yield reason_question + + +@pytest.fixture() +def reason_advice(): + with patch('app.util.reason_advice') as reason_advice: + yield reason_advice + + +@pytest.fixture() +def reason(): + with patch('app.util.reason') as reason_advice: + yield reason_advice + + +@pytest.fixture() +def get_db_connection(): + with patch('app.util.db.get_db_connection') as get_db_connection: + conn = MagicMock() + get_db_connection.return_value = conn + yield get_db_connection, conn + + +@pytest.fixture() +def triples(): + tr = SimpleNamespace() + tr.empty = {'triples': []} + tr.one = {'triples': [{"subject": "foo", "predicate": "bar", "object": "baz"}]} + tr.many = {'triples': 3*[{"subject": "foo", "predicate": "bar", "object": "baz"}]} + return tr + + +@pytest.fixture() +def sample_sentence(): + return "Some cool sentence." + + +@pytest.fixture() +def sample_name(): + return 'SomeRandomName' + + +@pytest.fixture() +def sample_timestamp(): + return '...' + + +@pytest.fixture() +def sample_sentence_data(sample_sentence, sample_name, sample_timestamp): + return { + 'patient_name': sample_name, + 'sentence': sample_sentence, + 'timestamp': sample_timestamp + } + + +@pytest.fixture() +def sample_t2t_data(sample_sentence_data, triples): + t2t = SimpleNamespace() + sentence_data = {'sentence_data': sample_sentence_data} + t2t.empty = triples.empty | sentence_data + t2t.one = triples.one | sentence_data + t2t.many = triples.many | sentence_data + return t2t diff --git a/modules/reasoning-demo/app/tests/test_app.py b/modules/reasoning-demo/app/tests/test_app.py new file mode 100644 index 0000000..e66dbff --- /dev/null +++ b/modules/reasoning-demo/app/tests/test_app.py @@ -0,0 +1,25 @@ +from unittest.mock import Mock, patch, ANY +from app.tests.conftest import AnyStringWith + + +def test_hello(client): + response = client.get('/') + assert b'Hello' in response.data + + +def test_store_knowledge_empty(client, triples): + res = client.post(f"/store", json=triples.empty) + assert res.status_code == 200 + + +def test_store_knowledge_success(client, util, triples): + res = client.post(f"/store", json=triples.one) + util.store_knowledge.assert_called_once() + assert res.status_code == 200 + + +def test_store_knowledge_inference_failed(client, util, triples): + util.store_knowledge.side_effect = RuntimeError('Test') + res = client.post(f"/store", json=triples.one) + util.store_knowledge.assert_called_once() + assert not res.status_code < 400 # Equivalent of requests.Response.ok diff --git a/reasoning/app/tests/test_db.py b/modules/reasoning-demo/app/tests/test_db.py similarity index 67% rename from reasoning/app/tests/test_db.py rename to modules/reasoning-demo/app/tests/test_db.py index 3f82e43..2d335df 100644 --- a/reasoning/app/tests/test_db.py +++ b/modules/reasoning-demo/app/tests/test_db.py @@ -1,10 +1,10 @@ from unittest.mock import patch, ANY -from app.db import get_db_connection +from app.util.db import get_db_connection from app.tests.conftest import AnyStringWith def test_get_db_connection(application): - with patch('app.db.SPARQLWrapper.__new__') as SPARQLWrapper, application.app_context(): + with patch('app.util.db.SPARQLWrapper.__new__') as SPARQLWrapper, application.app_context(): get_db_connection() SPARQLWrapper.assert_called_once_with( ANY, diff --git a/modules/reasoning-demo/app/tests/test_reason_advice.py b/modules/reasoning-demo/app/tests/test_reason_advice.py new file mode 100644 index 0000000..f4d6156 --- /dev/null +++ b/modules/reasoning-demo/app/tests/test_reason_advice.py @@ -0,0 +1,28 @@ +from unittest.mock import ANY, patch +from flask import g +from app.util.reason_advice import reason_advice, recommended_activities_sorted, rule_based_advice +from SPARQLWrapper import JSON +from app.tests.conftest import AnyStringWith + + +def test_reason_advice(application, get_db_connection, sample_name): + with application.app_context(): + ret = reason_advice(sample_name) + assert 'data' in ret + + +def test_recommended_activities_sorted(application, get_db_connection, sample_name): + with application.app_context(): + _, conn = get_db_connection + + recommended_activities_sorted(sample_name) + + conn.setQuery.assert_called_once() + conn.setReturnFormat.assert_called_once_with(JSON) + conn.addParameter.assert_called_once_with(ANY, AnyStringWith('json')) + +# TODO: Figure out how to mock/patch methods in packages +# def test_rule_based_advice(application, sample_name): +# with application.app_context(), patch('app.util.recommended_activities_sorted') as rec: +# rule_based_advice(sample_name) +# rec.assert_called_once_with(sample_name) diff --git a/modules/reasoning-demo/app/tests/test_reason_question.py b/modules/reasoning-demo/app/tests/test_reason_question.py new file mode 100644 index 0000000..b65ad51 --- /dev/null +++ b/modules/reasoning-demo/app/tests/test_reason_question.py @@ -0,0 +1,97 @@ +from unittest.mock import ANY, patch, MagicMock +from flask import g +from app.util.reason_question import get_missing_facts, get_required_facts, query_for_presence, reason_question, rule_based_question +from SPARQLWrapper import JSON +from app.tests.conftest import AnyStringWith + + +def test_reason_question(application, get_db_connection, sample_name): + with application.app_context(): + ret = reason_question(sample_name) + assert 'data' in ret + + +# def test_rule_based_question_empty(application, sample_name): +# with application.app_context(), \ +# patch('app.util.reason_question.get_required_facts') as req, \ +# patch('app.util.reason_question.get_missing_facts') as mis, \ +# patch('app.util.reason_question.sort_missing_facts') as srt: + +# srt.return_value = [] + +# mf = rule_based_question(sample_name) + +# req.assert_called_once() +# mis.assert_called_once() +# srt.assert_called_once() + +# assert mf is None + + +# def test_rule_based_question_non_empty(application, sample_name): +# with application.app_context(), \ +# patch('app.util.reason_question.get_required_facts') as req, \ +# patch('app.util.reason_question.get_missing_facts') as mis, \ +# patch('app.util.reason_question.sort_missing_facts') as srt: + +# mock = MagicMock() +# srt.return_value = [mock] + +# mf = rule_based_question(sample_name) + +# req.assert_called_once() +# mis.assert_called_once() +# srt.assert_called_once() + +# assert mf is mock + + +def test_query_for_presence(application, get_db_connection): + with application.app_context(): + _, conn = get_db_connection + fact = 'test' + query_ret = MagicMock() + conn.query.return_value = query_ret + + query_for_presence(fact) + + conn.setQuery.assert_called_once_with(AnyStringWith(fact)) + conn.setReturnFormat.assert_called_once_with(JSON) + conn.addParameter.assert_called_once_with(ANY, AnyStringWith('json')) + conn.query.assert_called_once() + query_ret.convert.assert_called() + + +# All the queries should operate on the given user's KG +def test_get_required_facts(application, sample_name): + with application.app_context(): + ret = get_required_facts(sample_name) + for query in ret: + assert f'userKG:{sample_name}' in query + + +def test_get_missing_facts_empty(application): + with application.app_context(): + ret = get_missing_facts([]) + assert ret == [] + + +# def test_get_missing_facts_missing(application): +# with application.app_context(), patch('app.util.reason_question.query_for_presence') as qfp: +# fact = 'test' +# qfp_ret = False +# qfp.return_value = qfp_ret +# ret = get_missing_facts([fact]) +# qfp.assert_called_with(fact) +# assert fact in ret + + +# def test_get_missing_facts_present(application): +# with application.app_context(), patch('app.util.reason_question.query_for_presence') as qfp: +# fact = 'test' +# qfp_ret = True +# qfp.return_value = qfp_ret +# ret = get_missing_facts([fact]) +# qfp.assert_called_with(fact) +# assert fact not in ret +# assert len(ret) == 0 diff --git a/reasoning/app/tests/test_util.py b/modules/reasoning-demo/app/tests/test_util.py similarity index 55% rename from reasoning/app/tests/test_util.py rename to modules/reasoning-demo/app/tests/test_util.py index df14cdf..e06731a 100644 --- a/reasoning/app/tests/test_util.py +++ b/modules/reasoning-demo/app/tests/test_util.py @@ -1,5 +1,5 @@ from unittest.mock import Mock, patch, ANY -from app.util import json_triple_to_rdf, upload_rdf_data, reason +from app.util import json_triple_to_rdf, upload_rdf_data, reason, reason_and_notify_response_generator from app.tests.conftest import AnyStringWith @@ -35,45 +35,45 @@ def test_json_triple_to_rdf_serialization_result(triples, application): assert v in ret -def test_upload_rdf_data(application): - with patch("app.util.requests.post") as post, application.app_context(): - res = Mock() - res.ok = True - post.return_value = res - rdf_data = Mock() +# def test_upload_rdf_data(application): +# with patch("app.util.requests.post") as post, application.app_context(): +# res = Mock() +# res.ok = True +# post.return_value = res +# rdf_data = Mock() - # Call the method - upload_rdf_data(rdf_data) +# # Call the method +# upload_rdf_data(rdf_data) - # Posted with the correct data - post.assert_called_with(ANY, data=rdf_data, headers=ANY) +# # Posted with the correct data +# post.assert_called_with(ANY, data=rdf_data, headers=ANY) - # And confirm we didn't log an error, because the post was succesful - application.logger.error.assert_not_called() +# # And confirm we didn't log an error, because the post was succesful +# application.logger.error.assert_not_called() -def test_upload_rdf_data_error(application): - with patch("app.util.requests.post") as post, application.app_context(): - res = Mock() - res.ok = False - post.return_value = res +# def test_upload_rdf_data_error(application): +# with patch("app.util.requests.post") as post, application.app_context(): +# res = Mock() +# res.ok = False +# post.return_value = res - # Call the method - upload_rdf_data(Mock()) +# # Call the method +# upload_rdf_data(Mock()) - # Confirm that we logged an error because the post failed - application.logger.error.assert_called() +# # Confirm that we logged an error because the post failed +# application.logger.error.assert_called() -def test_upload_rdf_data_no_knowledge(application): - with patch("app.util.requests.post") as post, application.app_context(): - application.config['knowledge_url'] = None +# def test_upload_rdf_data_no_knowledge(application): +# with patch("app.util.requests.post") as post, application.app_context(): +# application.config['knowledge_url'] = None - # Call the method - res = upload_rdf_data(Mock()) +# # Call the method +# res = upload_rdf_data(Mock()) - # Confirm that we return a 503 due to missing knowledge DB - assert 503 in res +# # Confirm that we return a 503 due to missing knowledge DB +# assert res.status_code == 503 def test_reason_advice_success(application, reason_advice, reason_question): @@ -102,3 +102,11 @@ def test_reason_advice_failure(application, reason_advice, reason_question): reason_question.assert_called_once() assert ret['type'] == 'Q' assert 'data' in ret + + +def test_reason_and_notify_response_generator(application, sample_sentence_data, response_generator_address, reason): + with patch('app.util.requests') as r, application.app_context(): + reason.return_value = {} + reason_and_notify_response_generator(sample_sentence_data) + reason.assert_called_once() + r.post.assert_called_once_with(AnyStringWith(response_generator_address), json=ANY) \ No newline at end of file diff --git a/reasoning/app/util.py b/modules/reasoning-demo/app/util/__init__.py similarity index 68% rename from reasoning/app/util.py rename to modules/reasoning-demo/app/util/__init__.py index ac9e1ac..b38e216 100644 --- a/reasoning/app/util.py +++ b/modules/reasoning-demo/app/util/__init__.py @@ -1,6 +1,6 @@ from rdflib import Graph, Namespace, URIRef, Literal -from app.reason_question import reason_question -from app.reason_advice import reason_advice +from app.util.reason_question import reason_question +from app.util.reason_advice import reason_advice from flask import current_app import requests @@ -45,29 +45,24 @@ def upload_rdf_data(rdf_data, content_type='application/x-turtle'): :param content_type: MIME type of the RDF data (default is Turtle) :return: Response object """ - headers = { - 'Content-Type': content_type - } - - # Construct the full endpoint URL url = current_app.config.get('knowledge_url', None) - - # TODO: Improve consistency in return types - # Maybe we should only return what we want and throw exceptions in the other cases - # These exceptions can be caught, and then handled appropriately in the route itself via status codes if not url: - return "No address configured for knowledge database", 503 - endpoint = f"{url}/statements" + current_app.logger.warning("No URL configured for knowledge DB, not uploading anything...") + return + # Send a POST request to upload the RDF data + endpoint = f"{url}/statements" + headers = { + 'Content-Type': content_type + } response = requests.post(endpoint, data=rdf_data, headers=headers) - if not response.ok: - current_app.logger.error(f"Failed to upload RDF data: {response.status_code}, Response: {response.text}") - else: + if response.ok: current_app.logger.info('Successfully uploaded RDF data.') - - return response + else: + current_app.logger.error(f"Failed to upload data: {response.status_code}, {response.text}") + raise RuntimeError(f"Could not store data in knowledge base (status: {response.status_code}, {response.text})") def reason(): @@ -89,3 +84,24 @@ def reason(): current_app.logger.info(f"reasoning result: {response}") return {"type": reason_type, "data": response} + +def reason_and_notify_response_generator(sentence_data): + payload = reason() + payload['sentence_data'] = sentence_data + response_generator_address = current_app.config.get("RESPONSE_GENERATOR_ADDRESS", None) + if response_generator_address: + requests.post(f"http://{response_generator_address}/process", json=payload) + + +def store_knowledge(triples): + if len(triples) == 0: + current_app.logger.warning("Triple list was empty, nothing to store...") + return + + current_app.logger.debug(f"triples: {triples}") + triple = triples[0] + rdf_data = json_triple_to_rdf(triple) # Convert JSON triple to RDF data. + current_app.logger.debug(f"rdf_data: {rdf_data}") + + # Upload RDF data to GraphDB + upload_rdf_data(rdf_data) diff --git a/reasoning/app/db.py b/modules/reasoning-demo/app/util/db.py similarity index 100% rename from reasoning/app/db.py rename to modules/reasoning-demo/app/util/db.py diff --git a/reasoning/app/reason_advice.py b/modules/reasoning-demo/app/util/reason_advice.py similarity index 97% rename from reasoning/app/reason_advice.py rename to modules/reasoning-demo/app/util/reason_advice.py index b1e956d..0657e24 100644 --- a/reasoning/app/reason_advice.py +++ b/modules/reasoning-demo/app/util/reason_advice.py @@ -1,7 +1,7 @@ from SPARQLWrapper import JSON from typing import Tuple from flask import current_app -import app.db +import app.util.db # Should probably somehow talk to the knowledge graph and get info from there? @@ -45,7 +45,7 @@ def recommended_activities_sorted(name): ORDER BY DESC(?nSecondaryValues)}} """ current_app.logger.debug(query) - db_connection = app.db.get_db_connection() + db_connection = app.util.db.get_db_connection() db_connection.setQuery(query) db_connection.setReturnFormat(JSON) db_connection.addParameter('Accept', 'application/sparql-results+json') diff --git a/reasoning/app/reason_question.py b/modules/reasoning-demo/app/util/reason_question.py similarity index 97% rename from reasoning/app/reason_question.py rename to modules/reasoning-demo/app/util/reason_question.py index 6e402a3..7af16a8 100644 --- a/reasoning/app/reason_question.py +++ b/modules/reasoning-demo/app/util/reason_question.py @@ -1,5 +1,5 @@ from SPARQLWrapper import JSON -import app.db +import app.util.db # Should probably somehow talk to the knowledge graph and get info from there? @@ -33,7 +33,7 @@ def query_for_presence(fact: str) -> bool: {fact} }} """ - db_connection = app.db.get_db_connection() + db_connection = app.util.db.get_db_connection() db_connection.setQuery(query) db_connection.setReturnFormat(JSON) db_connection.addParameter('Accept', 'application/sparql-results+json') diff --git a/modules/reasoning-demo/compose.yml b/modules/reasoning-demo/compose.yml new file mode 100644 index 0000000..f5b98eb --- /dev/null +++ b/modules/reasoning-demo/compose.yml @@ -0,0 +1,13 @@ +services: + reasoning-demo: + env_file: + - setup.env + - ./modules/reasoning-demo/config.env + expose: + - 5000 + build: ./modules/reasoning-demo/. + volumes: + - ./modules/reasoning-demo/app:/app + - ./modules/reasoning-demo/data:/data + depends_on: + - knowledge-demo \ No newline at end of file diff --git a/modules/reasoning-demo/config.env.default b/modules/reasoning-demo/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/reasoning/requirements.txt b/modules/reasoning-demo/requirements.txt similarity index 95% rename from reasoning/requirements.txt rename to modules/reasoning-demo/requirements.txt index 84aa6be..079ef36 100644 --- a/reasoning/requirements.txt +++ b/modules/reasoning-demo/requirements.txt @@ -1,4 +1,4 @@ -requests==2.31.0 -Flask==3.0.3 -rdflib==7.0.0 +requests==2.31.0 +Flask==3.0.3 +rdflib==7.0.0 SPARQLwrapper==2.0.0 \ No newline at end of file diff --git a/modules/redis/compose.yml b/modules/redis/compose.yml new file mode 100644 index 0000000..12ee158 --- /dev/null +++ b/modules/redis/compose.yml @@ -0,0 +1,13 @@ +services: + redis: + env_file: + - setup.env + - ./modules/redis/config.env + expose: + - 6379 + image: redis:latest + volumes: + - redisdata:/data + +volumes: + redisdata: \ No newline at end of file diff --git a/modules/redis/config.env.default b/modules/redis/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/text-to-triples/Dockerfile b/modules/response-generator-demo/Dockerfile similarity index 95% rename from text-to-triples/Dockerfile rename to modules/response-generator-demo/Dockerfile index 697c5e8..ed34628 100644 --- a/text-to-triples/Dockerfile +++ b/modules/response-generator-demo/Dockerfile @@ -1,11 +1,12 @@ -FROM python:3.10-slim - -# Install python deps -COPY requirements.txt / -RUN pip3 install -r /requirements.txt - -# Copy over source -COPY app /app - -# Run the server -CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] +FROM python:3.10-slim + +# Install python deps +COPY requirements.txt / +RUN pip3 install -r /requirements.txt + +# Copy over source +COPY app /app + +# Run the server +CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] + diff --git a/modules/response-generator-demo/README.md b/modules/response-generator-demo/README.md new file mode 100644 index 0000000..655e834 --- /dev/null +++ b/modules/response-generator-demo/README.md @@ -0,0 +1,44 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Demo Response Generator | +| **Type** | Response Generator | +| **Core** | Yes | +| **Access URL** | N/A | + +## Description +This Response Generator is intended as a demo module, for following the demo scenario outlined in `demo_scenario.md` in the root folder of the CHIP project. It scans the output obtained by the reasoner and then picks a pre-defined response based on that. + +## Usage +Instructions: +1. Configure `core-modules.yaml` to use this module as the response generator. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Reasoner` +```JSON + { + "sentence_data": { + "patient_name": , // The name of the user currently chatting. + "sentence": , // The sentence that the user submitted. + "timestamp": // The time at which the user submitted the sentence (ISO format). + }, + "type": , // Whether the reasoner decided to give an answer (A) or to request more information (Q). + "data": // A dict containing the output of the reasoner. + } +``` +### Output to `Front End` +```JSON + { + "message": // The generated message. + } +``` +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + +## Internal Dependencies +None. + +## Required Resources +None. \ No newline at end of file diff --git a/response-generator/app/__init__.py b/modules/response-generator-demo/app/__init__.py similarity index 63% rename from response-generator/app/__init__.py rename to modules/response-generator-demo/app/__init__.py index 27d06c5..122ed7d 100644 --- a/response-generator/app/__init__.py +++ b/modules/response-generator-demo/app/__init__.py @@ -9,17 +9,25 @@ def filter(self, record): record.service_name = "Response Generator" return True +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None def create_app(test=False): flask_app = Flask(__name__) - logger_address = os.environ.get("LOGGER_ADDRESS", None) - + logger_address = core_module_address("LOGGER_MODULE") if logger_address and not test: http_handler = HTTPHandler(logger_address, "/log", method="POST") flask_app.logger.addFilter(ServiceNameFilter()) flask_app.logger.addHandler(http_handler) + frontend_address = core_module_address("FRONTEND_MODULE") + if frontend_address: + flask_app.config["FRONTEND_ADDRESS"] = frontend_address + from app.routes import bp flask_app.register_blueprint(bp) diff --git a/modules/response-generator-demo/app/routes.py b/modules/response-generator-demo/app/routes.py new file mode 100644 index 0000000..8415d80 --- /dev/null +++ b/modules/response-generator-demo/app/routes.py @@ -0,0 +1,20 @@ +from flask import Blueprint, current_app, request +import app.util + +bp = Blueprint('main', __name__) + +@bp.route('/') +def hello(): + return 'Hello, I am the response generator module!' + + +@bp.route('/process', methods=['POST']) +def submit_reasoner_response(): + data = request.json + current_app.logger.info(f"Received data from reasoner: {data}") + reasoner_response = data + + app.util.send_message(reasoner_response) + + return 'OK' + diff --git a/response-generator/app/tests/conftest.py b/modules/response-generator-demo/app/tests/conftest.py similarity index 75% rename from response-generator/app/tests/conftest.py rename to modules/response-generator-demo/app/tests/conftest.py index 64f4f4c..b0e941c 100644 --- a/response-generator/app/tests/conftest.py +++ b/modules/response-generator-demo/app/tests/conftest.py @@ -1,6 +1,6 @@ import pytest from app import create_app -from unittest.mock import Mock +from unittest.mock import Mock, patch from types import SimpleNamespace @@ -11,10 +11,8 @@ def __eq__(self, other): @pytest.fixture() def util(): - import sys - del sys.modules['app.util'] - import app.util - return app.util + with patch('app.util') as util: + yield util @pytest.fixture() @@ -40,9 +38,9 @@ def sentence_data(): @pytest.fixture() -def reasoner_response(): +def reasoner_response(sentence_data): rr = SimpleNamespace() - rr.greet = {"data": None, "type": "Q"} - rr.question = {"data": {"data": "prioritizedOver"}, "type": "Q"} - rr.advice = {"data": {"data": [None, "some activity"]}, "type": "A"} + rr.greet = {"data": None, "type": "Q", "sentence_data": sentence_data.greet} + rr.question = {"data": {"data": "prioritizedOver"}, "type": "Q", "sentence_data": sentence_data.other} + rr.advice = {"data": {"data": [None, "some activity"]}, "type": "A", "sentence_data": sentence_data.other} return rr diff --git a/modules/response-generator-demo/app/tests/test_app.py b/modules/response-generator-demo/app/tests/test_app.py new file mode 100644 index 0000000..6c8257f --- /dev/null +++ b/modules/response-generator-demo/app/tests/test_app.py @@ -0,0 +1,14 @@ +from unittest.mock import patch + + +def test_hello(client): + response = client.get('/') + assert b'Hello' in response.data + + +def test_submit_reasoner_response(client, reasoner_response): + with patch('app.util') as util: + client.post(f"/process", json=reasoner_response.question) + util.send_message.assert_called_once() + + diff --git a/modules/response-generator-demo/app/tests/test_util.py b/modules/response-generator-demo/app/tests/test_util.py new file mode 100644 index 0000000..8bbecdc --- /dev/null +++ b/modules/response-generator-demo/app/tests/test_util.py @@ -0,0 +1,56 @@ +from unittest.mock import Mock, patch +import app.util as util + +def test_check_responses_both_set(application, reasoner_response): + with application.app_context(), patch('app.util.generate_response'): + # util.generate_response = Mock() + # util.sentence_data = sentence_data.greet + # util.reasoner_response = reasoner_response.question + # util.check_responses() + util.send_message(reasoner_response) + + util.generate_response.assert_called_once() + + # # Secondly it should reset the values of both sentence_data and reasoner_response + # assert util.sentence_data is None + # assert util.reasoner_response is None + + +# A greeting is sent back upon greeting, no question or advice formulated +def test_generate_response_greeting(application, reasoner_response): + with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): + res = util.generate_response(reasoner_response.greet) + + util.formulate_question.assert_not_called() + util.formulate_advice.assert_not_called() + assert "hi" in res.lower() + + +# A question is formulated if the reasoner comes up with a question. +def test_generate_response_question(application, reasoner_response): + with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): + res = util.generate_response(reasoner_response.question) + + util.formulate_question.assert_called_once() + util.formulate_advice.assert_not_called() + assert "?" in res.lower() + + +# Advice is formulated if the reasoner comes up with advice. +def test_generate_response_advice(application, reasoner_response): + with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): + res = util.generate_response(reasoner_response.advice) + + util.formulate_question.assert_not_called() + util.formulate_advice.assert_called_once() + assert "activity" in res.lower() + + +# Missing patient_name should result in "Unknown patient" being used as name. +def test_generate_response_no_patient(application, sentence_data, reasoner_response): + with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): + del sentence_data.greet["patient_name"] + res = util.generate_response(reasoner_response.greet) + assert "Unknown Patient".lower() in res.lower() + + diff --git a/response-generator/app/util.py b/modules/response-generator-demo/app/util/__init__.py similarity index 65% rename from response-generator/app/util.py rename to modules/response-generator-demo/app/util/__init__.py index 2ae337a..a701e1b 100644 --- a/response-generator/app/util.py +++ b/modules/response-generator-demo/app/util/__init__.py @@ -1,3 +1,4 @@ +from logging import currentframe from flask import current_app from enum import auto from strenum import StrEnum @@ -5,14 +6,11 @@ import os - -reasoner_response = None -sentence_data = None - GREETINGS = ( "hi", "hello", - "yo" + "yo", + "hey" ) CLOSING = ( @@ -60,38 +58,40 @@ def formulate_advice(activity: str) -> str: return activity -def generate_response(sentence_data, reasoner_response): +def generate_response(reasoner_response): + sentence_data = reasoner_response['sentence_data'] try: name = sentence_data['patient_name'] except KeyError: name = "Unknown patient" - current_app.logger.debug(f"sentence_data: {sentence_data}") + current_app.logger.debug(f"reasoner_response: {reasoner_response}") response_type = ResponseType(reasoner_response["type"]) response_data = reasoner_response["data"] + message = "I don't understand, could you try rephrasing it?" + if sentence_data['sentence'].lower() in GREETINGS: - return f"Hi, {name}" + message = f"Hi, {name}" - if sentence_data['sentence'].lower() in CLOSING: - return f"Goodbye {name}" + elif sentence_data['sentence'].lower() in CLOSING: + message = f"Goodbye {name}" - if response_type == ResponseType.Q: + elif response_type == ResponseType.Q: question = formulate_question(response_data['data']) - return f"{name}, {question}?" + message = f"{name}, {question}?" + elif response_type == ResponseType.A: activity = formulate_advice(response_data['data'][1]) - return f"How about the activity '{activity}', {name}?" - - -def check_responses(): - global reasoner_response, sentence_data - if reasoner_response and sentence_data: - current_app.logger.info("Got both sentence and reasoning, sending response...") - message = generate_response(sentence_data, reasoner_response) - reasoner_response = None - sentence_data = None - payload = {"message": message} - current_app.logger.debug(f"sending response message: {payload}") - front_end_address = os.environ.get("FRONTEND_ADDRESS", None) - if front_end_address: - requests.post(f"http://{front_end_address}/response", json=payload) + message = f"How about the activity '{activity}', {name}?" + + return message + + +def send_message(reasoner_response): + message = generate_response(reasoner_response) + payload = {"message": message} + current_app.logger.debug(f"sending response message: {payload}") + front_end_address = current_app.config.get("FRONTEND_ADDRESS", None) + if front_end_address: + requests.post(f"http://{front_end_address}/process", json=payload) + diff --git a/modules/response-generator-demo/compose.yml b/modules/response-generator-demo/compose.yml new file mode 100644 index 0000000..36a3428 --- /dev/null +++ b/modules/response-generator-demo/compose.yml @@ -0,0 +1,11 @@ +services: + response-generator-demo: + env_file: + - setup.env + - ./modules/response-generator-demo/config.env + expose: + - 5000 + build: ./modules/response-generator-demo/. + volumes: + - ./modules/response-generator-demo/app:/app + depends_on: [] \ No newline at end of file diff --git a/modules/response-generator-demo/config.env.default b/modules/response-generator-demo/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/response-generator/requirements.txt b/modules/response-generator-demo/requirements.txt similarity index 95% rename from response-generator/requirements.txt rename to modules/response-generator-demo/requirements.txt index 0cabc86..8de5081 100644 --- a/response-generator/requirements.txt +++ b/modules/response-generator-demo/requirements.txt @@ -1,3 +1,3 @@ -requests==2.31.0 -Flask==3.0.3 +requests==2.31.0 +Flask==3.0.3 strenum==0.4.15 \ No newline at end of file diff --git a/modules/response-generator-gemini/Dockerfile b/modules/response-generator-gemini/Dockerfile new file mode 100644 index 0000000..60e98a0 --- /dev/null +++ b/modules/response-generator-gemini/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +COPY requirements.txt / +RUN pip3 install -r /requirements.txt + +# Copy over source +COPY app /app + +# Run the server +CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] + diff --git a/modules/response-generator-gemini/README.md b/modules/response-generator-gemini/README.md new file mode 100644 index 0000000..4b0d6b1 --- /dev/null +++ b/modules/response-generator-gemini/README.md @@ -0,0 +1,45 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Gemini Response Generator | +| **Type** | Response Generator | +| **Core** | Yes | +| **Access URL** | N/A | + +## Description +This Response Generator uses Google's `genai` module to query Gemini for generating a response, given an appropriately built up context and the latest message that the user sent. + +## Usage +Instructions: +1. Make sure to have obtained [an API key for Gemini](https://ai.google.dev/gemini-api/docs/api-key), and set the `GEMINI_API_KEY` environment variable in the module's `config.env` to it +2. Configure `core-modules.yaml` to use this module as the response generator. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Reasoner` +```JSON + { + "sentence_data": { + "patient_name": , // The name of the user currently chatting. + "sentence": , // The sentence that the user submitted. + "timestamp": // The time at which the user submitted the sentence (ISO format). + }, + "type": , // Whether the reasoner decided to give an answer (A) or to request more information (Q). + "data": // A dict containing the output of the reasoner. + } +``` +### Output to `Front End` +```JSON + { + "message": // The generated message. + } +``` +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + +## Internal Dependencies +None. + +## Required Resources +- Internet connection for using Google Gemini \ No newline at end of file diff --git a/modules/response-generator-gemini/app/__init__.py b/modules/response-generator-gemini/app/__init__.py new file mode 100644 index 0000000..27a6eaf --- /dev/null +++ b/modules/response-generator-gemini/app/__init__.py @@ -0,0 +1,42 @@ +from flask import Flask +from logging.handlers import HTTPHandler +from logging import Filter +from app.util.llm_extension import LLMExtension +import os + + + +# NOTE: This approach will load the model for every instance of the application. +llm = LLMExtension() + +class ServiceNameFilter(Filter): + def filter(self, record): + record.service_name = "Response Generator" + return True + +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None + +def create_app(test=False): + flask_app = Flask(__name__) + + logger_address = core_module_address("LOGGER_MODULE") + if logger_address and not test: + http_handler = HTTPHandler(logger_address, "/log", method="POST") + flask_app.logger.addFilter(ServiceNameFilter()) + flask_app.logger.addHandler(http_handler) + + frontend_address = core_module_address("FRONTEND_MODULE") + if frontend_address: + flask_app.config["FRONTEND_ADDRESS"] = frontend_address + + from app.routes import bp + flask_app.register_blueprint(bp) + + flask_app.config['GEMINI_API_KEY'] = os.environ['GEMINI_API_KEY'] + llm.init_app(flask_app) + + return flask_app diff --git a/modules/response-generator-gemini/app/routes.py b/modules/response-generator-gemini/app/routes.py new file mode 100644 index 0000000..b35a390 --- /dev/null +++ b/modules/response-generator-gemini/app/routes.py @@ -0,0 +1,22 @@ +from flask import Blueprint, current_app, request +import app.util + + +bp = Blueprint('main', __name__) + + +@bp.route('/') +def hello(): + return 'Hello, I am the response generator module!' + + +@bp.route('/process', methods=['POST']) +def submit_reasoner_response(): + data = request.json + current_app.logger.info(f"Received data from reasoner: {data}") + reasoner_response = data + + app.util.send_message(reasoner_response) + + return 'OK' + diff --git a/modules/response-generator-gemini/app/tests/conftest.py b/modules/response-generator-gemini/app/tests/conftest.py new file mode 100644 index 0000000..b0e941c --- /dev/null +++ b/modules/response-generator-gemini/app/tests/conftest.py @@ -0,0 +1,46 @@ +import pytest +from app import create_app +from unittest.mock import Mock, patch +from types import SimpleNamespace + + +class AnyStringWith(str): + def __eq__(self, other): + return self in other + + +@pytest.fixture() +def util(): + with patch('app.util') as util: + yield util + + +@pytest.fixture() +def application(): + yield create_app(test=True) + + +@pytest.fixture() +def client(application): + tc = application.test_client() + # For detecting errors and disabling logging in general + setattr(tc.application, "logger", Mock(tc.application.logger)) + return tc + + +@pytest.fixture() +def sentence_data(): + sd = SimpleNamespace() + patient_name = "Tim" + sd.greet = {"sentence": "Hi", "patient_name": patient_name} + sd.other = {"sentence": "Something else", "patient_name": patient_name} + return sd + + +@pytest.fixture() +def reasoner_response(sentence_data): + rr = SimpleNamespace() + rr.greet = {"data": None, "type": "Q", "sentence_data": sentence_data.greet} + rr.question = {"data": {"data": "prioritizedOver"}, "type": "Q", "sentence_data": sentence_data.other} + rr.advice = {"data": {"data": [None, "some activity"]}, "type": "A", "sentence_data": sentence_data.other} + return rr diff --git a/modules/response-generator-gemini/app/tests/test_app.py b/modules/response-generator-gemini/app/tests/test_app.py new file mode 100644 index 0000000..6c8257f --- /dev/null +++ b/modules/response-generator-gemini/app/tests/test_app.py @@ -0,0 +1,14 @@ +from unittest.mock import patch + + +def test_hello(client): + response = client.get('/') + assert b'Hello' in response.data + + +def test_submit_reasoner_response(client, reasoner_response): + with patch('app.util') as util: + client.post(f"/process", json=reasoner_response.question) + util.send_message.assert_called_once() + + diff --git a/modules/response-generator-gemini/app/tests/test_util.py b/modules/response-generator-gemini/app/tests/test_util.py new file mode 100644 index 0000000..60bedac --- /dev/null +++ b/modules/response-generator-gemini/app/tests/test_util.py @@ -0,0 +1,58 @@ +from unittest.mock import Mock, patch +import app.util as util + +def test_check_responses_both_set(application, reasoner_response): + with application.app_context(), patch('app.util.generate_response'): + # util.generate_response = Mock() + # util.sentence_data = sentence_data.greet + # util.reasoner_response = reasoner_response.question + # util.check_responses() + util.send_message(reasoner_response) + + util.generate_response.assert_called_once() + + # # Secondly it should reset the values of both sentence_data and reasoner_response + # assert util.sentence_data is None + # assert util.reasoner_response is None + + +# NOTE: Need an API key to test response generation with, not viable for now, and also not very useful. + +# # A greeting is sent back upon greeting, no question or advice formulated +# def test_generate_response_greeting(application, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# res = util.generate_response(reasoner_response.greet) + +# util.formulate_question.assert_not_called() +# util.formulate_advice.assert_not_called() +# assert "hi" in res.lower() + + +# # A question is formulated if the reasoner comes up with a question. +# def test_generate_response_question(application, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# res = util.generate_response(reasoner_response.question) + +# util.formulate_question.assert_called_once() +# util.formulate_advice.assert_not_called() +# assert "?" in res.lower() + + +# # Advice is formulated if the reasoner comes up with advice. +# def test_generate_response_advice(application, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# res = util.generate_response(reasoner_response.advice) + +# util.formulate_question.assert_not_called() +# util.formulate_advice.assert_called_once() +# assert "activity" in res.lower() + + +# # Missing patient_name should result in "Unknown patient" being used as name. +# def test_generate_response_no_patient(application, sentence_data, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# del sentence_data.greet["patient_name"] +# res = util.generate_response(reasoner_response.greet) +# assert "Unknown Patient".lower() in res.lower() + + diff --git a/modules/response-generator-gemini/app/util/__init__.py b/modules/response-generator-gemini/app/util/__init__.py new file mode 100644 index 0000000..ba79437 --- /dev/null +++ b/modules/response-generator-gemini/app/util/__init__.py @@ -0,0 +1,95 @@ +from flask import current_app +from enum import auto +from strenum import StrEnum +from app.util.gemini import generate +import requests + +GREETINGS = ( + "hi", + "hello", + "yo", + "hey" +) + +CLOSING = ( + "bye", + "thanks", + "thank you", + "goodbye" +) + +class ResponseType(StrEnum): + Q = auto() + A = auto() + + +def formulate_question(query: str) -> str: + """ + Formulates a natural language question based on which facts are missing from the DB. + """ + if 'prioritizedOver' in query: + return "that depends on what you find important. What do you prioritize" + elif 'hasPhysicalActivityHabit' in query: + return "what physical activities do you regularly do" + raise ValueError(f"Cannot formulate question for query {query}") + +# ============================================================================= +# def formulate_advice(activity: str) -> str: +# prefix = "http://www.semanticweb.org/aledpro/ontologies/2024/2/userKG#" +# activity = activity.replace(prefix, "") +# +# activity = activity.replace("_", " ") +# return activity +# ============================================================================= + + +def formulate_advice(activity: str) -> str: + prefix = "http://www.semanticweb.org/aledpro/ontologies/2024/2/userKG#" + activity = activity.replace(prefix, "") + + # Split activity on underscore and take the last part if it starts with "activity" + parts = activity.split("_") + if parts[0] == "activity": + activity = "_".join(parts[1:]) + + activity = activity.replace("_", " ") + return activity + + +def generate_response(reasoner_response): + sentence_data = reasoner_response['sentence_data'] + try: + name = sentence_data['patient_name'] + except KeyError: + name = "Unknown patient" + current_app.logger.debug(f"reasoner_response: {reasoner_response}") + response_type = ResponseType(reasoner_response["type"]) + response_data = reasoner_response["data"] + + message = "I don't understand, could you try rephrasing it?" + + if sentence_data['sentence'].lower() in GREETINGS: + message = f"Hi, {name}" + + elif sentence_data['sentence'].lower() in CLOSING: + message = f"Goodbye {name}" + + elif response_type == ResponseType.Q: + if 'prioritizedOver' in response_data['data']: + message = generate(f"The user talking to you is {name}. {name} is a diabetes patient. You currently know too little about {name} to help them. In particular, you would like to get to know what {name}'s general values are and how they prioritize them, so you can recommend activities to {name} that involve their values.", sentence_data['sentence']) + + elif response_type == ResponseType.A: + activity = formulate_advice(response_data['data'][1]) + message = f"How about the activity '{activity}', {name}?" + + return message + + +def send_message(reasoner_response): + message = generate_response(reasoner_response) + payload = {"message": message} + current_app.logger.debug(f"sending response message: {payload}") + front_end_address = current_app.config.get("FRONTEND_ADDRESS", None) + if front_end_address: + requests.post(f"http://{front_end_address}/process", json=payload) + diff --git a/modules/response-generator-gemini/app/util/gemini.py b/modules/response-generator-gemini/app/util/gemini.py new file mode 100644 index 0000000..844fe34 --- /dev/null +++ b/modules/response-generator-gemini/app/util/gemini.py @@ -0,0 +1,19 @@ +import app + + +def generate(context, message): + prompt=f""" + You act the role of a medical chat bot, that is able to link facts about the patient and about medical science in order to give advice. You do not need to do this linking yourself as this will be given to you if available. I will give you a context, and a message typed by the user that is talking to you, both prefaced by a corresponding header, and you will attempt to generate an appropriate response to the user. Your replies should be succinct, to the point, and not stray too far from the context. + + Context: + {context} + + Message: + {message} + """ + + response = app.llm.client.models.generate_content(model="gemini-2.0-flash", contents=[prompt]) + + print("Output: ", response.text) + + return response.text diff --git a/modules/response-generator-gemini/app/util/llm_extension.py b/modules/response-generator-gemini/app/util/llm_extension.py new file mode 100644 index 0000000..6fb545d --- /dev/null +++ b/modules/response-generator-gemini/app/util/llm_extension.py @@ -0,0 +1,24 @@ +from flask import g +from google import genai + + +class LLMExtension: + client: genai.Client | None = None + + + def __init__(self, app=None): + if app is not None: + self.init_app(app) + + + def init_model(self, app): + gemini_api_key = app.config.get("GEMINI_API_KEY", None) + client = genai.Client(api_key=gemini_api_key) + return client + + + def init_app(self, app): + self.client = self.init_model(app) + app.extensions = getattr(app, "extensions", {}) + app.extensions["client"] = self.client + # app.before_request(...) diff --git a/modules/response-generator-gemini/compose.yml b/modules/response-generator-gemini/compose.yml new file mode 100644 index 0000000..f37001b --- /dev/null +++ b/modules/response-generator-gemini/compose.yml @@ -0,0 +1,11 @@ +services: + response-generator-gemini: + env_file: + - setup.env + - ./modules/response-generator-gemini/config.env + expose: + - 5000 + build: ./modules/response-generator-gemini/. + volumes: + - ./modules/response-generator-gemini/app:/app + depends_on: [] \ No newline at end of file diff --git a/modules/response-generator-gemini/config.env.default b/modules/response-generator-gemini/config.env.default new file mode 100644 index 0000000..c72c8aa --- /dev/null +++ b/modules/response-generator-gemini/config.env.default @@ -0,0 +1 @@ +GEMINI_API_KEY= diff --git a/modules/response-generator-gemini/requirements.txt b/modules/response-generator-gemini/requirements.txt new file mode 100644 index 0000000..72ae0ed --- /dev/null +++ b/modules/response-generator-gemini/requirements.txt @@ -0,0 +1,4 @@ +requests==2.32.3 +Flask==3.0.3 +strenum==0.4.15 +google-genai==1.5.0 \ No newline at end of file diff --git a/modules/response-generator-llm-local/Dockerfile b/modules/response-generator-llm-local/Dockerfile new file mode 100644 index 0000000..e8a3a1b --- /dev/null +++ b/modules/response-generator-llm-local/Dockerfile @@ -0,0 +1,43 @@ +FROM condaforge/miniforge3:latest + +ENV DEBIAN_FRONTEND=noninteractive + + +# Pre-warm the model, by downloading it into cache. Pinning transformers/pytorch to be sure... +# TODO: Would be nice to find a way to pre-warm without any dependencies at all, e.g. by downloading with the default pip and then moving it to the mamba env cache. +COPY environment.yml / +RUN mamba env create --quiet --file /environment.yml +RUN mamba run --live-stream -n llm pip3 install transformers==4.50.3 torch==2.6.0+cpu --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple +ARG MODEL_NAME=medalpaca/medalpaca-7b +ENV MODEL_NAME=$MODEL_NAME +COPY pre-warm.py / +RUN mamba run --live-stream -n llm python3 -m pre-warm.py $MODEL_NAME + + +# Install appropriate CUDA version +ARG CUDA_MAJOR +ARG CUDA_MINOR +RUN apt-get update && apt-get install -y build-essential libxml2 +RUN wget https://raw.githubusercontent.com/TimDettmers/bitsandbytes/main/install_cuda.sh +RUN bash install_cuda.sh $CUDA_MAJOR$CUDA_MINOR /cuda 1 +RUN ln -s /cuda/cuda-$CUDA_MAJOR.$CUDA_MINOR/targets/x86_64-linux/lib/libcudart.so /opt/conda/lib/libcudart.so +ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt/conda/lib/ +RUN echo 'export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt/conda/lib/' >> ~/.bashrc + + +# Install the environment and requirements +COPY requirements.txt / +RUN mamba run --live-stream -n llm pip3 uninstall -y transformers torch +RUN mamba run --live-stream -n llm pip3 install -r /requirements.txt +RUN mamba run --live-stream -n llm pip3 install torch --index-url https://download.pytorch.org/whl/cu$CUDA_MAJOR$CUDA_MINOR +RUN mamba install -y -n llm -c conda-forge libstdcxx-ng=13 +RUN mamba install -y -n llm -c conda-forge gcc=13 +RUN apt-get clean && rm -rf /var/lib/apt/lists/* +RUN mamba clean --all -y + + +# Copy over source +COPY app /app + +# Run the server +CMD [ "mamba", "run", "--live-stream", "-n", "llm", "flask", "--app", "app", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/modules/response-generator-llm-local/README.md b/modules/response-generator-llm-local/README.md new file mode 100644 index 0000000..00eb36d --- /dev/null +++ b/modules/response-generator-llm-local/README.md @@ -0,0 +1,57 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Medalpaca Response Generator CUDA | +| **Type** | Response Generator | +| **Core** | Yes | +| **Access URL** | N/A | + +## Description +This Response Generator uses the `transformers` library in combination with PyTorch in order to locally run and query an LLM based on `medalpaca-7b` for response generation. By default, a GPU with CUDA is expected to be present, however you may configure the `CUDA_VISIBLE_DEVICES` environment variable in the `compose.yml` file to an empty string `""` instead of `0`, which will cause PyTorch to fall back on using only the CPU. Naturally, this is very slow (~1-5 minutes per response). For now GPU-support in docker is only confirmed to be working on Windows in WSL2. + +Note that the implementation is very incomplete as of yet. This is just a proof-of-concept of running and querying an LLM intended for medical purposes locally. The response generator will attempt to "properly" respond to greetings and to any variation of the phrase "What do you recommend?"", where the LLM will be used to formulate a reponse. + +**WARNING:** Starting the module for the first time will build the image that the module uses, which downloads all pre-requisites and pre-downloads + caches the model, which may take up a very large amount of time (up to an hour) and storage (~40GB). + +## Usage +Instructions: +1. Open `compose.yml` in this folder, and configure your CUDA version by adjusting the `CUDA_MAJOR`.`CUDA_MINOR` values. You can discover your CUDA version and whether it is functioning in Docker at all by running the following command: `sudo docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi`. +2. In the same file, also configure the LLM to use by changing the value of `MODEL_NAME`, by default this is `medalpaca/medalpaca-7b`. Changing this will trigger a redownload of the model during build, which can take quite long. +3. Configure `core-modules.yaml` to use this module as the response generator. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Reasoner` +```JSON + { + "sentence_data": { + "patient_name": , // The name of the user currently chatting. + "sentence": , // The sentence that the user submitted. + "timestamp": // The time at which the user submitted the sentence (ISO format). + }, + "type": , // Whether the reasoner decided to give an answer (A) or to request more information (Q). + "data": // A dict containing the output of the reasoner. + } +``` +### Output to `Front End` +```JSON + { + "message": // The generated message. + } +``` +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + +## Internal Dependencies +None. + +## Required Resources (for medalpaca/medalpaca-7b) +For CPU only: +- At least 32GB RAM +- At least 30GB of free disk space + +For GPU usage: +- GPU + driver with CUDA support +- Latest Docker (compose) version +- At least 8GB of VRAM diff --git a/modules/response-generator-llm-local/app/__init__.py b/modules/response-generator-llm-local/app/__init__.py new file mode 100644 index 0000000..3dd193f --- /dev/null +++ b/modules/response-generator-llm-local/app/__init__.py @@ -0,0 +1,46 @@ +from flask import Flask +from logging.handlers import HTTPHandler +from logging import Filter +from app.util.llm_extension import LLMExtension +import os + + +# NOTE: This approach will load the model for every instance of the application. +# Hence, this is only suitable for development purposes where scaling is not relevant. +llm: LLMExtension = LLMExtension() + +class ServiceNameFilter(Filter): + def filter(self, record): + record.service_name = "Response Generator" + return True + + +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None + + +def create_app(test=False): + flask_app = Flask(__name__) + + logger_address = core_module_address("LOGGER_MODULE") + if logger_address and not test: + http_handler = HTTPHandler(logger_address, "/log", method="POST") + flask_app.logger.addFilter(ServiceNameFilter()) + flask_app.logger.addHandler(http_handler) + + frontend_address = core_module_address("FRONTEND_MODULE") + if frontend_address: + flask_app.config["FRONTEND_ADDRESS"] = frontend_address + + from app.routes import bp + flask_app.register_blueprint(bp) + + # Do not load the model for testing/CI + if not test: + llm.init_app(flask_app, os.environ['MODEL_NAME']) + + + return flask_app diff --git a/modules/response-generator-llm-local/app/routes.py b/modules/response-generator-llm-local/app/routes.py new file mode 100644 index 0000000..8415d80 --- /dev/null +++ b/modules/response-generator-llm-local/app/routes.py @@ -0,0 +1,20 @@ +from flask import Blueprint, current_app, request +import app.util + +bp = Blueprint('main', __name__) + +@bp.route('/') +def hello(): + return 'Hello, I am the response generator module!' + + +@bp.route('/process', methods=['POST']) +def submit_reasoner_response(): + data = request.json + current_app.logger.info(f"Received data from reasoner: {data}") + reasoner_response = data + + app.util.send_message(reasoner_response) + + return 'OK' + diff --git a/modules/response-generator-llm-local/app/tests/conftest.py b/modules/response-generator-llm-local/app/tests/conftest.py new file mode 100644 index 0000000..b0e941c --- /dev/null +++ b/modules/response-generator-llm-local/app/tests/conftest.py @@ -0,0 +1,46 @@ +import pytest +from app import create_app +from unittest.mock import Mock, patch +from types import SimpleNamespace + + +class AnyStringWith(str): + def __eq__(self, other): + return self in other + + +@pytest.fixture() +def util(): + with patch('app.util') as util: + yield util + + +@pytest.fixture() +def application(): + yield create_app(test=True) + + +@pytest.fixture() +def client(application): + tc = application.test_client() + # For detecting errors and disabling logging in general + setattr(tc.application, "logger", Mock(tc.application.logger)) + return tc + + +@pytest.fixture() +def sentence_data(): + sd = SimpleNamespace() + patient_name = "Tim" + sd.greet = {"sentence": "Hi", "patient_name": patient_name} + sd.other = {"sentence": "Something else", "patient_name": patient_name} + return sd + + +@pytest.fixture() +def reasoner_response(sentence_data): + rr = SimpleNamespace() + rr.greet = {"data": None, "type": "Q", "sentence_data": sentence_data.greet} + rr.question = {"data": {"data": "prioritizedOver"}, "type": "Q", "sentence_data": sentence_data.other} + rr.advice = {"data": {"data": [None, "some activity"]}, "type": "A", "sentence_data": sentence_data.other} + return rr diff --git a/modules/response-generator-llm-local/app/tests/test_app.py b/modules/response-generator-llm-local/app/tests/test_app.py new file mode 100644 index 0000000..6c8257f --- /dev/null +++ b/modules/response-generator-llm-local/app/tests/test_app.py @@ -0,0 +1,14 @@ +from unittest.mock import patch + + +def test_hello(client): + response = client.get('/') + assert b'Hello' in response.data + + +def test_submit_reasoner_response(client, reasoner_response): + with patch('app.util') as util: + client.post(f"/process", json=reasoner_response.question) + util.send_message.assert_called_once() + + diff --git a/modules/response-generator-llm-local/app/tests/test_util.py b/modules/response-generator-llm-local/app/tests/test_util.py new file mode 100644 index 0000000..8389322 --- /dev/null +++ b/modules/response-generator-llm-local/app/tests/test_util.py @@ -0,0 +1,58 @@ +from unittest.mock import Mock, patch +import app.util as util + +def test_check_responses_both_set(application, reasoner_response): + with application.app_context(), patch('app.util.generate_response'): + # util.generate_response = Mock() + # util.sentence_data = sentence_data.greet + # util.reasoner_response = reasoner_response.question + # util.check_responses() + util.send_message(reasoner_response) + + util.generate_response.assert_called_once() + + # # Secondly it should reset the values of both sentence_data and reasoner_response + # assert util.sentence_data is None + # assert util.reasoner_response is None + + +# NOTE: Need a more efficient way of running tests for local LLMs + +# A greeting is sent back upon greeting, no question or advice formulated +# def test_generate_response_greeting(application, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# res = util.generate_response(reasoner_response.greet) + +# util.formulate_question.assert_not_called() +# util.formulate_advice.assert_not_called() +# assert "hi" in res.lower() + + +# # A question is formulated if the reasoner comes up with a question. +# def test_generate_response_question(application, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# res = util.generate_response(reasoner_response.question) + +# util.formulate_question.assert_called_once() +# util.formulate_advice.assert_not_called() +# assert "?" in res.lower() + + +# # Advice is formulated if the reasoner comes up with advice. +# def test_generate_response_advice(application, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# res = util.generate_response(reasoner_response.advice) + +# util.formulate_question.assert_not_called() +# util.formulate_advice.assert_called_once() +# assert "activity" in res.lower() + + +# # Missing patient_name should result in "Unknown patient" being used as name. +# def test_generate_response_no_patient(application, sentence_data, reasoner_response): +# with application.app_context(), patch('app.util.formulate_advice'), patch('app.util.formulate_question'): +# del sentence_data.greet["patient_name"] +# res = util.generate_response(reasoner_response.greet) +# assert "Unknown Patient".lower() in res.lower() + + diff --git a/modules/response-generator-llm-local/app/util/__init__.py b/modules/response-generator-llm-local/app/util/__init__.py new file mode 100644 index 0000000..3152e65 --- /dev/null +++ b/modules/response-generator-llm-local/app/util/__init__.py @@ -0,0 +1,88 @@ +from logging import currentframe +from flask import current_app +from enum import auto +from strenum import StrEnum +from app.util.medalpaca import generate +import os +import requests + +GREETINGS = ("hi", "hello", "yo", "hey") + +CLOSING = ("bye", "thanks", "thank you", "goodbye") + + +class ResponseType(StrEnum): + Q = auto() + A = auto() + + +def formulate_question(query: str) -> str: + """ + Formulates a natural language question based on which facts are missing from the DB. + """ + if "prioritizedOver" in query: + return "that depends on what you find important. What do you prioritize" + elif "hasPhysicalActivityHabit" in query: + return "what physical activities do you regularly do" + raise ValueError(f"Cannot formulate question for query {query}") + + +# ============================================================================= +# def formulate_advice(activity: str) -> str: +# prefix = "http://www.semanticweb.org/aledpro/ontologies/2024/2/userKG#" +# activity = activity.replace(prefix, "") +# +# activity = activity.replace("_", " ") +# return activity +# ============================================================================= + + +def formulate_advice(activity: str) -> str: + prefix = "http://www.semanticweb.org/aledpro/ontologies/2024/2/userKG#" + activity = activity.replace(prefix, "") + + # Split activity on underscore and take the last part if it starts with "activity" + parts = activity.split("_") + if parts[0] == "activity": + activity = "_".join(parts[1:]) + + activity = activity.replace("_", " ") + return activity + + +def generate_response(reasoner_response): + sentence_data = reasoner_response["sentence_data"] + try: + name = sentence_data["patient_name"] + except KeyError: + name = "Unknown patient" + current_app.logger.debug(f"reasoner_response: {reasoner_response}") + response_type = ResponseType(reasoner_response["type"]) + response_data = reasoner_response["data"] + + message = "I don't understand, could you try rephrasing it?" + + if sentence_data["sentence"].lower() in GREETINGS: + message = f"Hi, {name}" + + elif sentence_data["sentence"].lower() in CLOSING: + message = f"Goodbye {name}" + + elif response_type == ResponseType.Q: + if "prioritizedOver" in response_data["data"]: + message = f"{name}, {generate(f'{name} is a patient. {name} has diabetes. {name} is asking the question in this prompt, given after this context. {name} wants to find out what you would recommend based on his values and priorities in life. You are a medical chatbot, that tries to find out information about {name} in order to answer their questions. In this case, you have too little information, because you first need to know what {name} finds important and what their values are, and how {name} prioritizes these. Ask {name} what they find important in their life, what they care about, or what their values are, and how they prioritize these.', sentence_data['sentence'])}" + + elif response_type == ResponseType.A: + activity = formulate_advice(response_data["data"][1]) + message = f"How about the activity '{activity}', {name}?" + + return message + + +def send_message(reasoner_response): + message = generate_response(reasoner_response) + payload = {"message": message} + current_app.logger.debug(f"sending response message: {payload}") + front_end_address = current_app.config.get("FRONTEND_ADDRESS", None) + if front_end_address: + requests.post(f"http://{front_end_address}/process", json=payload) diff --git a/modules/response-generator-llm-local/app/util/llm_extension.py b/modules/response-generator-llm-local/app/util/llm_extension.py new file mode 100644 index 0000000..ba176ae --- /dev/null +++ b/modules/response-generator-llm-local/app/util/llm_extension.py @@ -0,0 +1,30 @@ +from flask import g +from transformers import AutoTokenizer, pipeline, Pipeline, BitsAndBytesConfig +import torch + + +class LLMExtension: + pipe: Pipeline | None = None + + + def __init__(self, app=None, model_name=None): + if app is not None and model_name is not None: + self.init_app(app, model_name) + + + def init_model(self, model_name): + bnb_config = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_use_double_quant=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch.bfloat16 + ) + pipe = pipeline("text-generation", model=model_name, tokenizer=model_name, max_new_tokens=500, model_kwargs={"quantization_config": bnb_config, "torch_dtype": torch.float16, "low_cpu_mem_usage": True}) + return pipe + + + def init_app(self, app, model_name): + self.pipe = self.init_model(model_name) + app.extensions = getattr(app, "extensions", {}) + app.extensions["pipe"] = self.pipe + # app.before_request(...) diff --git a/modules/response-generator-llm-local/app/util/medalpaca.py b/modules/response-generator-llm-local/app/util/medalpaca.py new file mode 100644 index 0000000..5016ef0 --- /dev/null +++ b/modules/response-generator-llm-local/app/util/medalpaca.py @@ -0,0 +1,10 @@ +import app + + +def generate(context, question): + out = app.llm.pipe(f"Context: {context}\n\nQuestion: {question}\n\nAnswer: ")[0]['generated_text'] + + out_split = ('.' + out).split("Answer:")[-1] + print("Output: ", out) + + return out_split \ No newline at end of file diff --git a/modules/response-generator-llm-local/compose.yml b/modules/response-generator-llm-local/compose.yml new file mode 100644 index 0000000..bbc20ec --- /dev/null +++ b/modules/response-generator-llm-local/compose.yml @@ -0,0 +1,25 @@ +services: + response-generator-llm-local: + env_file: + - setup.env + - ./modules/response-generator-llm-local/config.env + environment: + - CUDA_VISIBLE_DEVICES=0 # Use an empty string here to force just CPU usage. + expose: + - 5000 + build: + context: ./modules/response-generator-llm-local/. + args: + CUDA_MAJOR: 12 + CUDA_MINOR: 4 + MODEL_NAME: medalpaca/medalpaca-7b + volumes: + - ./modules/response-generator-llm-local/app:/app + depends_on: [] + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] \ No newline at end of file diff --git a/modules/response-generator-llm-local/config.env.default b/modules/response-generator-llm-local/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/modules/response-generator-llm-local/environment.yml b/modules/response-generator-llm-local/environment.yml new file mode 100644 index 0000000..0906e60 --- /dev/null +++ b/modules/response-generator-llm-local/environment.yml @@ -0,0 +1,7 @@ +name: llm +channels: + - conda-forge + - defaults +dependencies: + - python=3.10 + - pip diff --git a/modules/response-generator-llm-local/pre-warm.py b/modules/response-generator-llm-local/pre-warm.py new file mode 100644 index 0000000..ff1c688 --- /dev/null +++ b/modules/response-generator-llm-local/pre-warm.py @@ -0,0 +1,12 @@ +import transformers +import sys + +def deco(func): + def _(*args, **kwargs): + func(*args, **kwargs) + print("Exiting after downloading models!") + exit(0) + return _ + +transformers.modeling_utils._get_resolved_checkpoint_files = deco(transformers.modeling_utils._get_resolved_checkpoint_files) +model = transformers.AutoModel.from_pretrained(sys.argv[1]) \ No newline at end of file diff --git a/modules/response-generator-llm-local/requirements.txt b/modules/response-generator-llm-local/requirements.txt new file mode 100644 index 0000000..ba306e0 --- /dev/null +++ b/modules/response-generator-llm-local/requirements.txt @@ -0,0 +1,10 @@ +requests==2.32.3 +Flask==3.0.3 +strenum==0.4.15 +bitsandbytes +accelerate +transformers +protobuf +tiktoken +blobfile +sentencepiece \ No newline at end of file diff --git a/modules/text-to-triples-llm/Dockerfile b/modules/text-to-triples-llm/Dockerfile new file mode 100644 index 0000000..69ae03f --- /dev/null +++ b/modules/text-to-triples-llm/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10-slim + +# Install python deps +COPY requirements.txt / +RUN pip3 install -r /requirements.txt +RUN apt-get update +RUN apt-get install -y curl + +RUN curl --create-dirs -LO --output-dir / https://huggingface.co/StergiosNt/spo_labeling_bert/resolve/main/best_model.pth + +# Copy over source +COPY app /app + +# Run the server +CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] diff --git a/modules/text-to-triples-llm/INFO.md b/modules/text-to-triples-llm/INFO.md new file mode 100644 index 0000000..0a85e8b --- /dev/null +++ b/modules/text-to-triples-llm/INFO.md @@ -0,0 +1,26 @@ +# Conversational Triple Extraction in Diabetes Healthcare Management Using Synthetic Data + +## Overview + +This document describes the procedure for using a fine-tuned BERT model to extract S-P-O (Subject-Predicate-Object) triples from conversational sentences related to Diabetes management. + +## Dependencies + +- Before running the code, ensure you have Python 3.12.3 installed on your system as it is required for compatibility with the libraries used in this project. + +- Make sure you have the required Python libraries installed. You can install them using the following command: + +```bash +pip install torch transformers +``` + +## Script Overview + +- ```t2t_bert.py```: This script utilizes a fine-tuned BERT model to extract Subject, Predicate, and Object (S-P-O) triples from conversational sentences. It loads the fine-tuned BERT model and uses a tokenizer to process input sentences. The script includes functions to predict S-P-O labels at a token level and assemble these tokens into coherent triples, handling cases where some components may be implied. If no explicit components are identified, it returns an empty triple structure. + +## How to Use + +1) Before running the scripts, ensure all required libraries and dependencies are installed in your Python environment. +2) To access the fine-tuned BERT models, visit the following link: https://huggingface.co/StergiosNt/spo_labeling_bert. For a model fine-tuned across all conversational sentences, download **best_model.pth**. If you prefer a model specifically fine-tuned on sentences with S-P-O labels, excluding those with tokens exclusively labeled as 'other', please download **best_model_spo.pth**. +3) Open the ```t2t_bert.py``` file and update the path to where you have stored the downloaded fine-tuned BERT model. +4) Run the ```t2t_bert.py``` file in your Python environment (Visual Studio Code is recommended). \ No newline at end of file diff --git a/modules/text-to-triples-llm/README.md b/modules/text-to-triples-llm/README.md new file mode 100644 index 0000000..9267995 --- /dev/null +++ b/modules/text-to-triples-llm/README.md @@ -0,0 +1,55 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | LLM Triple Extractor | +| **Type** | Triple Extractor | +| **Core** | Yes | +| **Access URL** | N/A | + +## Description +This triple extractor uses a fine-tuned BERT model to extract triples from sentences. + +## Usage +Instructions: +1. Configure `core-modules.yaml` to use this module as the triple extractor. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Front-End` +```JSON +{ + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) +} +``` + +### Output to `Reasoner` +```JSON +{ + "sentence_data": { + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) + }, + "triples": [ + { + "subject":, + "object": , + "predicate": + }, + ... + ] +} +``` + +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + + +## Internal Dependencies +None. + +## Required Resources +None. diff --git a/modules/text-to-triples-llm/app/__init__.py b/modules/text-to-triples-llm/app/__init__.py new file mode 100644 index 0000000..ae3c8e0 --- /dev/null +++ b/modules/text-to-triples-llm/app/__init__.py @@ -0,0 +1,47 @@ +from flask import Flask +from logging.handlers import HTTPHandler +from logging import Filter +from app.util.model_extension import ModelExtension +import os + + + +# NOTE: This approach will load the model for every instance of the application. +model = ModelExtension() + +class ServiceNameFilter(Filter): + def filter(self, record): + record.service_name = "Text 2 Triple" + return True + + +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None + + +def create_app(test=False): + flask_app = Flask(__name__) + + logger_address = os.environ.get("LOGGER_ADDRESS", None) + + if logger_address and not test: + http_handler = HTTPHandler(logger_address, "/log", method="POST") + flask_app.logger.addFilter(ServiceNameFilter()) + flask_app.logger.addHandler(http_handler) + + reasoner_address = core_module_address('REASONER_MODULE') + if reasoner_address: + flask_app.config['REASONER_ADDRESS'] = reasoner_address + + # Do not load the model for testing/CI + if not test: + model.init_app(flask_app) + + + from app.routes import bp + flask_app.register_blueprint(bp) + + return flask_app diff --git a/modules/text-to-triples-llm/app/routes.py b/modules/text-to-triples-llm/app/routes.py new file mode 100644 index 0000000..6d4d945 --- /dev/null +++ b/modules/text-to-triples-llm/app/routes.py @@ -0,0 +1,20 @@ +from flask import Blueprint, current_app, request +import app.util + +bp = Blueprint('main', __name__) + + +@bp.route('/') +def hello(): + return "Hello, I am the text to triples module!" + + +@bp.route('/process', methods=['POST']) +def process(): + sentence_data = request.json + sentence = sentence_data['sentence'] + patient_name = sentence_data['patient_name'] + timestamp =sentence_data['timestamp'] + current_app.logger.debug(f"Patient {patient_name} wrote {sentence} at {timestamp}") + app.util.send_triples(sentence_data) + return 'OK' diff --git a/modules/text-to-triples-llm/app/tests/conftest.py b/modules/text-to-triples-llm/app/tests/conftest.py new file mode 100644 index 0000000..f3e98e3 --- /dev/null +++ b/modules/text-to-triples-llm/app/tests/conftest.py @@ -0,0 +1,58 @@ +import pytest +from app import create_app +from unittest.mock import Mock, patch +# from types import SimpleNamespace + + +class AnyStringWith(str): + def __eq__(self, other): + return self in other + + +def create_triple(subj, pred, obj): + return {"subject": subj, "object": obj, "predicate": pred} + + +@pytest.fixture() +def reasoner_address(): + return "dummy" + + +@pytest.fixture() +def application(monkeypatch, reasoner_address): + monkeypatch.setenv("REASONER_MODULE", "TEST_MOD_1") + monkeypatch.setenv("TEST_MOD_1", reasoner_address) + with patch('app.model') as m: + yield create_app(test=True) + + +@pytest.fixture() +def client(application): + tc = application.test_client() + # For detecting errors and disabling logging in general + setattr(tc.application, "logger", Mock(tc.application.logger)) + return tc + + +@pytest.fixture() +def sample_sentence(): + return "Some cool sentence." + + +@pytest.fixture() +def sample_name(): + return 'John' + + +@pytest.fixture() +def sample_timestamp(): + return '...' + + +@pytest.fixture() +def sample_sentence_data(sample_sentence, sample_name, sample_timestamp): + return { + 'patient_name': sample_name, + 'sentence': sample_sentence, + 'timestamp': sample_timestamp + } diff --git a/text-to-triples/app/tests/test_app.py b/modules/text-to-triples-llm/app/tests/test_app.py similarity index 54% rename from text-to-triples/app/tests/test_app.py rename to modules/text-to-triples-llm/app/tests/test_app.py index a4b7d07..0a4905c 100644 --- a/text-to-triples/app/tests/test_app.py +++ b/modules/text-to-triples-llm/app/tests/test_app.py @@ -6,11 +6,8 @@ def test_hello(client): assert b'Hello' in response.data -def test_new_sentence(client, sample_name, sample_sentence): +def test_new_sentence(client, sample_sentence_data): with patch('app.util.send_triples') as st: - res = client.post('/new-sentence', json={ - 'sentence': sample_sentence, - 'patient_name': sample_name - }) + res = client.post('/process', json=sample_sentence_data) st.assert_called_once() assert res.status_code == 200 diff --git a/modules/text-to-triples-llm/app/tests/test_util.py b/modules/text-to-triples-llm/app/tests/test_util.py new file mode 100644 index 0000000..3fbeded --- /dev/null +++ b/modules/text-to-triples-llm/app/tests/test_util.py @@ -0,0 +1,15 @@ +from unittest.mock import patch, ANY +from app.util import send_triples +from app.tests.conftest import AnyStringWith, create_triple + + +def test_send_triples(application, sample_sentence_data, reasoner_address): + with application.app_context(), \ + patch('app.util.process_input_output') as et, \ + patch('app.util.requests') as r: + + send_triples(sample_sentence_data) + + et.assert_called_once_with(sample_sentence_data) + r.post.assert_called_once_with(AnyStringWith(reasoner_address), json=ANY) + diff --git a/modules/text-to-triples-llm/app/util/__init__.py b/modules/text-to-triples-llm/app/util/__init__.py new file mode 100644 index 0000000..44c03f8 --- /dev/null +++ b/modules/text-to-triples-llm/app/util/__init__.py @@ -0,0 +1,15 @@ +import requests +import os + +from flask import current_app +from app.util.t2t_bert import process_input_output +from typing import Dict, Any + + +def send_triples(data: Dict[str, str]): + payload = process_input_output(data) + payload['sentence_data'] = data + current_app.logger.debug(f"payload: {payload}") + reasoner_address = current_app.config.get('REASONER_ADDRESS', None) + if reasoner_address: + requests.post(f"http://{reasoner_address}/process", json=payload) \ No newline at end of file diff --git a/modules/text-to-triples-llm/app/util/model_extension.py b/modules/text-to-triples-llm/app/util/model_extension.py new file mode 100644 index 0000000..b6660f5 --- /dev/null +++ b/modules/text-to-triples-llm/app/util/model_extension.py @@ -0,0 +1,32 @@ +from flask import g +import torch + + + +class ModelExtension: + model = None + + + def __init__(self, app=None): + if app is not None: + self.init_app(app) + + + def init_model(self): + model_path = r'/best_model.pth' + model = torch.load(model_path) + + # Switch the model to evaluation mode + model.eval() + return model + + + def get_model(self): + return self.model + + + def init_app(self, app): + self.model = self.init_model() + app.extensions = getattr(app, "extensions", {}) + app.extensions["model"] = self.model + # app.before_request(...) diff --git a/modules/text-to-triples-llm/app/util/t2t_bert.py b/modules/text-to-triples-llm/app/util/t2t_bert.py new file mode 100644 index 0000000..ca6c7a2 --- /dev/null +++ b/modules/text-to-triples-llm/app/util/t2t_bert.py @@ -0,0 +1,101 @@ +import torch +from transformers import BertTokenizerFast +import json +import app + + + +# Initialize the tokenizer for processing inputs +tokenizer = BertTokenizerFast.from_pretrained('bert-base-uncased') + +# Define the label map according to the training labels +label_map = { + 'LABEL_0': 'Subject', + 'LABEL_1': 'Predicate', + 'LABEL_2': 'Object', + 'LABEL_3': 'other' +} + +def predict_and_form_triples(input_data, model, tokenizer, label_map): + """ + Extract and form S-P-O triples (Subject, Predicate, Object) from a sentence. + This function tokenizes the sentence, uses a model to predict SPO labels for each token, and aggregates + these tokens into coherent phrases. It handles subwords by merging them with preceding tokens and forms + triples even if some components are implied. If all components are missing, it returns an empty triple. + + Args: + input_data (dict): Contains the sentence to be processed. + model (torch.nn.Module): Pre-trained model for S-P-O prediction. + tokenizer (BertTokenizerFast): Tokenizer for processing the input sentence. + label_map (dict): Maps label IDs to their descriptive labels (e.g., Subject, Predicate, Object, other). + + Returns: + dict: Contains formed triples or an empty structure if no explicit S-P-O components are found. + """ + + sentence = input_data['sentence'] + inputs = tokenizer(sentence, return_tensors="pt", truncation=True, padding=True) + + with torch.no_grad(): + outputs = model(**inputs) + logits = outputs.logits + predictions = torch.argmax(logits, dim=-1)[0] + tokens = tokenizer.convert_ids_to_tokens(inputs['input_ids'][0]) + + subjects, predicates, objects = [], [], [] + aggregated_subjects, aggregated_predicates, aggregated_objects = '', '', '' + for token, pred in zip(tokens, predictions): + label = label_map[model.config.id2label[pred.item()]] + + # Handle subwords and aggregate them into the previous token if necessary + if token.startswith("##"): + if label == 'Subject' and subjects: + subjects[-1] += token[2:] # Remove '##' and concatenate + elif label == 'Predicate' and predicates: + predicates[-1] += token[2:] + elif label == 'Object' and objects: + objects[-1] += token[2:] + else: + if label == 'Subject': + subjects.append(token) + elif label == 'Predicate': + predicates.append(token) + elif label == 'Object': + objects.append(token) + + # Join tokens to form the phrases for subjects, predicates, and objects + aggregated_subjects = " ".join(subjects) + aggregated_predicates = " ".join(predicates) + aggregated_objects = " ".join(objects) + + triples = [] + # Form a triple only if there's at least one non-implied component + if subjects or predicates or objects: + triples.append({ + "subject": aggregated_subjects, + "predicate": aggregated_predicates, + "object": aggregated_objects + }) + # If no components are available (all implied), then return an empty triple structure + else: + triples.append({ + "subject": "", + "predicate": "", + "object": "" + }) + + return triples + + +def process_input_output(input_data): + """ + Processes input dict to extract S-P-O triples using the 'predict_and_form_triples' function, + applying the S-P-O prediction and aggregation logic, returning a dict with triples. + Args: + input_data: A dict containing the input data. + + Returns: + triples: A dict with a list of S-P-O triples extracted from the input. + """ + triples = predict_and_form_triples(input_data, app.model.get_model(), tokenizer, label_map) + return {"triples": triples} diff --git a/modules/text-to-triples-llm/compose.yml b/modules/text-to-triples-llm/compose.yml new file mode 100644 index 0000000..857730a --- /dev/null +++ b/modules/text-to-triples-llm/compose.yml @@ -0,0 +1,11 @@ +services: + text-to-triples-llm: + env_file: + - setup.env + - ./modules/text-to-triples-llm/config.env + expose: + - 5000 + build: ./modules/text-to-triples-llm/. + volumes: + - ./modules/text-to-triples-llm/app:/app + depends_on: [] \ No newline at end of file diff --git a/modules/text-to-triples-llm/config.env.default b/modules/text-to-triples-llm/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/modules/text-to-triples-llm/requirements.txt b/modules/text-to-triples-llm/requirements.txt new file mode 100644 index 0000000..3623971 --- /dev/null +++ b/modules/text-to-triples-llm/requirements.txt @@ -0,0 +1,4 @@ +requests==2.31.0 +Flask==3.0.3 +torch==2.5.1 +transformers==4.47.1 \ No newline at end of file diff --git a/response-generator/Dockerfile b/modules/text-to-triples-rule-based/Dockerfile similarity index 94% rename from response-generator/Dockerfile rename to modules/text-to-triples-rule-based/Dockerfile index ce7451d..683cac8 100644 --- a/response-generator/Dockerfile +++ b/modules/text-to-triples-rule-based/Dockerfile @@ -1,12 +1,11 @@ -FROM python:3.10-slim - -# Install python deps -COPY requirements.txt / -RUN pip3 install -r /requirements.txt - -# Copy over source -COPY app /app - -# Run the server -CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] - +FROM python:3.10-slim + +# Install python deps +COPY requirements.txt / +RUN pip3 install -r /requirements.txt + +# Copy over source +COPY app /app + +# Run the server +CMD [ "flask", "--app", "app", "--debug", "run", "--host", "0.0.0.0"] diff --git a/modules/text-to-triples-rule-based/README.md b/modules/text-to-triples-rule-based/README.md new file mode 100644 index 0000000..5baf1b4 --- /dev/null +++ b/modules/text-to-triples-rule-based/README.md @@ -0,0 +1,55 @@ +# This is a CHIP Module +| Properties | | +| ------------- | ------------- | +| **Name** | Rule-Based Triple Extractor | +| **Type** | Triple Extractor | +| **Core** | Yes | +| **Access URL** | N/A | + +## Description +A rule-based triple extractor module, using nltk to disect a sentence and extract triples from it. It is compatible with the demo knowledge, in that it transforms the extracted predicates into terms that the knowledge database understands. + +## Usage +Instructions: +1. Configure `core-modules.yaml` to use this module as the triple extractor. + +## Input/Output +Communication between the core modules occurs by sending a POST request to the `/process` route with an appropriate body, as detailed below. + +### Input from `Front-End` +```JSON +{ + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) +} +``` + +### Output to `Reasoner` +```JSON +{ + "sentence_data": { + "patient_name": , // The name of the user currently chatting + "sentence": , // The sentence that the user submitted + "timestamp": // The time at which the user submitted the sentence (ISO format) + }, + "triples": [ + { + "subject":, + "object": , + "predicate": + }, + ... + ] +} +``` + +## API (routes, descriptions, models) +- [GET] `/`: default 'hello' route, to check whether the module is alive and kicking. + + +## Internal Dependencies +None. + +## Required Resources +None. diff --git a/text-to-triples/app/__init__.py b/modules/text-to-triples-rule-based/app/__init__.py similarity index 63% rename from text-to-triples/app/__init__.py rename to modules/text-to-triples-rule-based/app/__init__.py index 905a343..d947c66 100644 --- a/text-to-triples/app/__init__.py +++ b/modules/text-to-triples-rule-based/app/__init__.py @@ -9,17 +9,25 @@ def filter(self, record): record.service_name = "Text 2 Triple" return True +def core_module_address(core_module): + try: + return os.environ[os.environ[core_module]] + except KeyError: + return None def create_app(test=False): flask_app = Flask(__name__) - logger_address = os.environ.get("LOGGER_ADDRESS", None) - + logger_address = core_module_address("LOGGER_MODULE") if logger_address and not test: http_handler = HTTPHandler(logger_address, "/log", method="POST") flask_app.logger.addFilter(ServiceNameFilter()) flask_app.logger.addHandler(http_handler) + reasoner_address = core_module_address('REASONER_MODULE') + if reasoner_address: + flask_app.config['REASONER_ADDRESS'] = reasoner_address + from app.routes import bp flask_app.register_blueprint(bp) diff --git a/modules/text-to-triples-rule-based/app/routes.py b/modules/text-to-triples-rule-based/app/routes.py new file mode 100644 index 0000000..9952772 --- /dev/null +++ b/modules/text-to-triples-rule-based/app/routes.py @@ -0,0 +1,21 @@ +from flask import Blueprint, current_app, request +import app.util + + +bp = Blueprint('main', __name__) + + +@bp.route('/') +def hello(): + return 'Hello, I am the text to triples module!' + + +@bp.route('/process', methods=['POST']) +def process(): + sentence_data = request.json + sentence = sentence_data["sentence"] + patient_name = sentence_data['patient_name'] + timestamp = sentence_data["timestamp"] + current_app.logger.debug(f"Patient {patient_name} wrote {sentence} at {timestamp}") + app.util.send_triples(sentence_data) + return 'OK' diff --git a/text-to-triples/app/tests/conftest.py b/modules/text-to-triples-rule-based/app/tests/conftest.py similarity index 58% rename from text-to-triples/app/tests/conftest.py rename to modules/text-to-triples-rule-based/app/tests/conftest.py index c018d07..03edcf7 100644 --- a/text-to-triples/app/tests/conftest.py +++ b/modules/text-to-triples-rule-based/app/tests/conftest.py @@ -14,7 +14,14 @@ def create_triple(subj, pred, obj): @pytest.fixture() -def application(): +def reasoner_address(): + return "dummy" + + +@pytest.fixture() +def application(monkeypatch, reasoner_address): + monkeypatch.setenv("REASONER_MODULE", "TEST_MOD_1") + monkeypatch.setenv("TEST_MOD_1", reasoner_address) yield create_app(test=True) @@ -26,14 +33,29 @@ def client(application): return tc +@pytest.fixture() +def sample_sentence(): + return "Some cool sentence." + + @pytest.fixture() def sample_name(): return 'John' @pytest.fixture() -def sample_sentence(): - return 'Some cool sentence.' +def sample_timestamp(): + return '...' + + +@pytest.fixture() +def sample_sentence_data(sample_sentence, sample_name, sample_timestamp): + return { + 'patient_name': sample_name, + 'sentence': sample_sentence, + 'timestamp': sample_timestamp + } + @pytest.fixture() diff --git a/modules/text-to-triples-rule-based/app/tests/test_app.py b/modules/text-to-triples-rule-based/app/tests/test_app.py new file mode 100644 index 0000000..0a4905c --- /dev/null +++ b/modules/text-to-triples-rule-based/app/tests/test_app.py @@ -0,0 +1,13 @@ +from unittest.mock import patch + + +def test_hello(client): + response = client.get('/') + assert b'Hello' in response.data + + +def test_new_sentence(client, sample_sentence_data): + with patch('app.util.send_triples') as st: + res = client.post('/process', json=sample_sentence_data) + st.assert_called_once() + assert res.status_code == 200 diff --git a/text-to-triples/app/tests/test_util.py b/modules/text-to-triples-rule-based/app/tests/test_util.py similarity index 65% rename from text-to-triples/app/tests/test_util.py rename to modules/text-to-triples-rule-based/app/tests/test_util.py index 2276ee7..e6b0fb5 100644 --- a/text-to-triples/app/tests/test_util.py +++ b/modules/text-to-triples-rule-based/app/tests/test_util.py @@ -3,44 +3,42 @@ from app.tests.conftest import AnyStringWith, create_triple -def test_send_triples(application, monkeypatch, sample_name, sample_sentence): - dummy_address = 'dummy' - monkeypatch.setenv("REASONING_ADDRESS", dummy_address) +def test_send_triples(application, sample_sentence_data, reasoner_address): with application.app_context(), \ patch('app.util.extract_triples') as et, \ patch('app.util.requests') as r: - send_triples(sample_name, sample_sentence) + send_triples(sample_sentence_data) - et.assert_called_once_with(sample_name, sample_sentence) - r.post.assert_called_once_with(AnyStringWith(dummy_address), json=ANY) + et.assert_called_once_with(sample_sentence_data) + r.post.assert_called_once_with(AnyStringWith(reasoner_address), json=ANY) -def test_extract_triples_no_tokens(application, sample_name, sample_sentence): +def test_extract_triples_no_tokens(application, sample_sentence_data, sample_sentence): with application.app_context(), patch('app.util.postprocess_triple') as pt, patch('app.util.nltk') as nltk: nltk.pos_tag.return_value = [] - ret = extract_triples(sample_name, sample_sentence) + ret = extract_triples(sample_sentence_data) nltk.word_tokenize.assert_called_once_with(sample_sentence) pt.assert_not_called() assert 'triples' in ret -def test_extract_triples_no_predicate(application, sample_name, sample_sentence, sample_tokens): +def test_extract_triples_no_predicate(application, sample_sentence_data, sample_sentence, sample_tokens): with application.app_context(), patch('app.util.postprocess_triple') as pt, patch('app.util.nltk') as nltk: nltk.pos_tag.return_value = sample_tokens[1:] - ret = extract_triples(sample_name, sample_sentence) + ret = extract_triples(sample_sentence_data) nltk.word_tokenize.assert_called_once_with(sample_sentence) pt.assert_not_called() assert 'triples' in ret -def test_extract_triples(application, sample_name, sample_sentence, sample_tokens): +def test_extract_triples(application, sample_sentence_data, sample_name, sample_sentence, sample_tokens): with application.app_context(), patch('app.util.postprocess_triple') as pt, patch('app.util.nltk') as nltk: nltk.pos_tag.return_value = sample_tokens - ret = extract_triples(sample_name, sample_sentence) + ret = extract_triples(sample_sentence_data) nltk.word_tokenize.assert_called_once_with(sample_sentence) pt.assert_called_once_with(ANY, sample_name) assert 'triples' in ret diff --git a/text-to-triples/app/util.py b/modules/text-to-triples-rule-based/app/util/__init__.py similarity index 69% rename from text-to-triples/app/util.py rename to modules/text-to-triples-rule-based/app/util/__init__.py index c3cab29..df9e6b9 100644 --- a/text-to-triples/app/util.py +++ b/modules/text-to-triples-rule-based/app/util/__init__.py @@ -27,11 +27,11 @@ def postprocess_triple(triple, userID): 'object': object_, } -# NOTE: Deprecated, as this way of extracting tuples does not work properly: -# - Assumes a sentence structure where the patient talks in third person; instead, the subject should always be the patient if the patient uses "I". -# - Even then, a simple sentence such as "I like eating with my mother" is not captured properly, as it'll become 'sub:I pred:like obj:mother' -def extract_triples(patient_name, sentence): + +def extract_triples(sentence_data): triples = [] + patient_name = sentence_data['patient_name'] + sentence = sentence_data['sentence'] tokens = nltk.word_tokenize(sentence) tagged_tokens = nltk.pos_tag(tokens) @@ -57,13 +57,13 @@ def extract_triples(patient_name, sentence): return {"triples": triples} -def send_triples(patient_name, sentence): +def send_triples(sentence_data): nltk.download('punkt') nltk.download('averaged_perceptron_tagger') - payload = extract_triples(patient_name, sentence) - payload["patient_name"] = patient_name + payload = extract_triples(sentence_data) + payload['sentence_data'] = sentence_data current_app.logger.debug(f"payload: {payload}") - reasoning_address = os.environ.get('REASONING_ADDRESS', None) - if reasoning_address: - requests.post(f"http://{reasoning_address}/store-knowledge", json=payload) \ No newline at end of file + reasoner_address = current_app.config.get('REASONER_ADDRESS', None) + if reasoner_address: + requests.post(f"http://{reasoner_address}/process", json=payload) \ No newline at end of file diff --git a/modules/text-to-triples-rule-based/compose.yml b/modules/text-to-triples-rule-based/compose.yml new file mode 100644 index 0000000..d712c26 --- /dev/null +++ b/modules/text-to-triples-rule-based/compose.yml @@ -0,0 +1,11 @@ +services: + text-to-triples-rule-based: + env_file: + - setup.env + - ./modules/text-to-triples-rule-based/config.env + expose: + - 5000 + build: ./modules/text-to-triples-rule-based/. + volumes: + - ./modules/text-to-triples-rule-based/app:/app + depends_on: [] \ No newline at end of file diff --git a/modules/text-to-triples-rule-based/config.env.default b/modules/text-to-triples-rule-based/config.env.default new file mode 100644 index 0000000..e69de29 diff --git a/text-to-triples/requirements.txt b/modules/text-to-triples-rule-based/requirements.txt similarity index 95% rename from text-to-triples/requirements.txt rename to modules/text-to-triples-rule-based/requirements.txt index bd43753..20a5683 100644 --- a/text-to-triples/requirements.txt +++ b/modules/text-to-triples-rule-based/requirements.txt @@ -1,3 +1,3 @@ -requests==2.31.0 -Flask==3.0.3 +requests==2.31.0 +Flask==3.0.3 nltk==3.8.1 \ No newline at end of file diff --git a/quasar/backend/app/db.py b/quasar/backend/app/db.py deleted file mode 100644 index 94172ed..0000000 --- a/quasar/backend/app/db.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import current_app, g - -# NOTE: Redis DB connection for potentially caching session/job ID info -def get_db_connection(): - """ - Returns the database connection. - """ - if 'db' not in g: - address = current_app.config['REDIS_ADDRESS'] - # g.db = SPARQLWrapper(url) - return g.db - - -def close_db(e=None): - g.pop('db', None) diff --git a/reasoning/app/routes.py b/reasoning/app/routes.py deleted file mode 100644 index 2e0967a..0000000 --- a/reasoning/app/routes.py +++ /dev/null @@ -1,55 +0,0 @@ -from flask import Blueprint, current_app, request, jsonify -import requests -import app.util -import os - -bp = Blueprint('main', __name__) - - -@bp.route('/') -def hello(): - return 'Hello, I am the reasoning module!' - - -@bp.route('/query-knowledge', methods=['POST']) -def query_knowledge(): - return 'OK' - - -@bp.route('/store-knowledge', methods=['POST']) -def store_knowledge(): - # Get JSON data from the POST request - current_app.logger.info(f"Triples received: {request.json}") - json_data = request.json - triples = json_data['triples'] - result = "Empty triple set received", 200 - if len(triples) > 0: - current_app.logger.debug(f"triples: {triples}") - triple = triples[0] - # Convert JSON triple to RDF data - rdf_data = app.util.json_triple_to_rdf(triple) - current_app.logger.debug(f"rdf_data: {rdf_data}") - - # Upload RDF data to GraphDB - response = app.util.upload_rdf_data(rdf_data) - if response.status_code == 204: - result = jsonify({"message": "Data uploaded successfully!"}), 200 - else: - result = jsonify({"error": f"Failed to upload data: {response.status_code}, {response.text}"}), response.status_code - - # IF DONE, START REASONING (should query knowledge base somehow) - reason_and_notify_response_generator() - - return result - - -# Note that we first check if we can give advice, and if that is "None", -# then we try to formulate a question instead. -@bp.route('/reason') -def reason_and_notify_response_generator(): - response_generator_address = os.environ.get("RESPONSE_GENERATOR_ADDRESS", None) - payload = app.util.reason() - if response_generator_address: - requests.post(f"http://{response_generator_address}/submit-reasoner-response", json=payload) - - return 'OK', 200 diff --git a/reasoning/app/tests/conftest.py b/reasoning/app/tests/conftest.py deleted file mode 100644 index f5074ab..0000000 --- a/reasoning/app/tests/conftest.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from app import create_app -from unittest.mock import Mock, MagicMock, patch -from types import SimpleNamespace - - -class AnyStringWith(str): - def __eq__(self, other): - return self in other - - -@pytest.fixture() -def application(monkeypatch): - monkeypatch.setenv("KNOWLEDGE_ADDRESS", "dummy") - app = create_app(test=True) - # For detecting errors and disabling logging in general - setattr(app, "logger", Mock(app.logger)) - yield app - - -@pytest.fixture() -def client(application): - return application.test_client() - - -@pytest.fixture() -def util(): - with patch('app.util') as util: - yield util - - -@pytest.fixture() -def reason_question(): - with patch('app.util.reason_question') as reason_question: - yield reason_question - - -@pytest.fixture() -def reason_advice(): - with patch('app.util.reason_advice') as reason_advice: - yield reason_advice - - -@pytest.fixture() -def get_db_connection(): - with patch('app.db.get_db_connection') as get_db_connection: - conn = MagicMock() - get_db_connection.return_value = conn - yield get_db_connection, conn - - -@pytest.fixture() -def triples(): - tr = SimpleNamespace() - tr.empty = {'triples': []} - tr.one = {'triples': [{"subject": "foo", "predicate": "bar", "object": "baz"}]} - tr.many = {'triples': 3*[{"subject": "foo", "predicate": "bar", "object": "baz"}]} - return tr - - -@pytest.fixture() -def test_name(): - return 'FooBarBaz' diff --git a/reasoning/app/tests/test_app.py b/reasoning/app/tests/test_app.py deleted file mode 100644 index a4bce16..0000000 --- a/reasoning/app/tests/test_app.py +++ /dev/null @@ -1,45 +0,0 @@ -from unittest.mock import Mock, patch, ANY -from app.tests.conftest import AnyStringWith - - -def test_hello(client): - response = client.get('/') - assert b'Hello' in response.data - - -def test_store_knowledge_empty(client, util, triples): - res = client.post(f"/store-knowledge", json=triples.empty) - - util.reason.assert_called_once() - assert b"empty" in res.data.lower() - assert res.status_code == 200 - - -def test_store_knowledge_success(client, util, triples): - knowledge_res = Mock() - knowledge_res.status_code = 204 - util.upload_rdf_data.return_value = knowledge_res - - res = client.post(f"/store-knowledge", json=triples.one) - - util.reason.assert_called_once() - util.json_triple_to_rdf.assert_called_once() - assert res.status_code == 200 - - -def test_store_knowledge_inference_failed(client, util, triples): - res = client.post(f"/store-knowledge", json=triples.one) - - util.reason.assert_called_once() - util.json_triple_to_rdf.assert_called_once() - assert res.status_code == 500 - - -def test_reason_and_notify_response_generator(client, util, monkeypatch): - with patch('app.routes.requests') as r: - dummy_url = 'dummy' - monkeypatch.setenv('RESPONSE_GENERATOR_ADDRESS', dummy_url) - res = client.get(f"/reason") - util.reason.assert_called_once() - r.post.assert_called_once_with(AnyStringWith(dummy_url), json=ANY) - assert res.status_code == 200 diff --git a/reasoning/app/tests/test_reason_advice.py b/reasoning/app/tests/test_reason_advice.py deleted file mode 100644 index 2a8f57c..0000000 --- a/reasoning/app/tests/test_reason_advice.py +++ /dev/null @@ -1,28 +0,0 @@ -from unittest.mock import ANY, patch -from flask import g -from app.reason_advice import reason_advice, recommended_activities_sorted, rule_based_advice -from SPARQLWrapper import JSON -from app.tests.conftest import AnyStringWith - - -def test_reason_advice(application, get_db_connection, test_name): - with application.app_context(): - ret = reason_advice(test_name) - assert 'data' in ret - - -def test_recommended_activities_sorted(application, get_db_connection, test_name): - with application.app_context(): - _, conn = get_db_connection - - recommended_activities_sorted(test_name) - - conn.setQuery.assert_called_once() - conn.setReturnFormat.assert_called_once_with(JSON) - conn.addParameter.assert_called_once_with(ANY, AnyStringWith('json')) - - -def test_rule_based_advice(application, test_name): - with application.app_context(), patch('app.reason_advice.recommended_activities_sorted') as rec: - rule_based_advice(test_name) - rec.assert_called_once_with(test_name) diff --git a/reasoning/app/tests/test_reason_question.py b/reasoning/app/tests/test_reason_question.py deleted file mode 100644 index 52d40d6..0000000 --- a/reasoning/app/tests/test_reason_question.py +++ /dev/null @@ -1,97 +0,0 @@ -from unittest.mock import ANY, patch, MagicMock -from flask import g -from app.reason_question import get_missing_facts, get_required_facts, query_for_presence, reason_question, rule_based_question -from SPARQLWrapper import JSON -from app.tests.conftest import AnyStringWith - - -def test_reason_question(application, get_db_connection, test_name): - with application.app_context(): - ret = reason_question(test_name) - assert 'data' in ret - - -def test_rule_based_question_empty(application, test_name): - with application.app_context(), \ - patch('app.reason_question.get_required_facts') as req, \ - patch('app.reason_question.get_missing_facts') as mis, \ - patch('app.reason_question.sort_missing_facts') as srt: - - srt.return_value = [] - - mf = rule_based_question(test_name) - - req.assert_called_once() - mis.assert_called_once() - srt.assert_called_once() - - assert mf is None - - -def test_rule_based_question_non_empty(application, test_name): - with application.app_context(), \ - patch('app.reason_question.get_required_facts') as req, \ - patch('app.reason_question.get_missing_facts') as mis, \ - patch('app.reason_question.sort_missing_facts') as srt: - - mock = MagicMock() - srt.return_value = [mock] - - mf = rule_based_question(test_name) - - req.assert_called_once() - mis.assert_called_once() - srt.assert_called_once() - - assert mf is mock - - -def test_query_for_presence(application, get_db_connection): - with application.app_context(): - _, conn = get_db_connection - fact = 'test' - query_ret = MagicMock() - conn.query.return_value = query_ret - - query_for_presence(fact) - - conn.setQuery.assert_called_once_with(AnyStringWith(fact)) - conn.setReturnFormat.assert_called_once_with(JSON) - conn.addParameter.assert_called_once_with(ANY, AnyStringWith('json')) - conn.query.assert_called_once() - query_ret.convert.assert_called() - - -# All the queries should operate on the given user's KG -def test_get_required_facts(application, test_name): - with application.app_context(): - ret = get_required_facts(test_name) - for query in ret: - assert f'userKG:{test_name}' in query - - -def test_get_missing_facts_empty(application): - with application.app_context(): - ret = get_missing_facts([]) - assert ret == [] - - -def test_get_missing_facts_missing(application): - with application.app_context(), patch('app.reason_question.query_for_presence') as qfp: - fact = 'test' - qfp_ret = False - qfp.return_value = qfp_ret - ret = get_missing_facts([fact]) - qfp.assert_called_with(fact) - assert fact in ret - - -def test_get_missing_facts_present(application): - with application.app_context(), patch('app.reason_question.query_for_presence') as qfp: - fact = 'test' - qfp_ret = True - qfp.return_value = qfp_ret - ret = get_missing_facts([fact]) - qfp.assert_called_with(fact) - assert fact not in ret - assert len(ret) == 0 diff --git a/response-generator/app/routes.py b/response-generator/app/routes.py deleted file mode 100644 index 0a560fe..0000000 --- a/response-generator/app/routes.py +++ /dev/null @@ -1,31 +0,0 @@ -from flask import Blueprint, current_app, request -import app.util - -bp = Blueprint('main', __name__) - - -@bp.route('/subject-sentence', methods=['POST']) -def submit_sentence(): - data = request.json - current_app.logger.info(f"Received sentence: {data}") - app.util.sentence_data = data - - app.util.check_responses() - - return 'OK' - - -@bp.route('/submit-reasoner-response', methods=['POST']) -def submit_reasoner_response(): - data = request.json - current_app.logger.info(f"Received data from reasoner: {data}") - app.util.reasoner_response = data - - app.util.check_responses() - - return 'OK' - - -@bp.route('/') -def hello(): - return 'Hello, I am the response generator module!' diff --git a/response-generator/app/tests/test_app.py b/response-generator/app/tests/test_app.py deleted file mode 100644 index 9045c8f..0000000 --- a/response-generator/app/tests/test_app.py +++ /dev/null @@ -1,26 +0,0 @@ -from unittest.mock import patch - - -def test_hello(client): - response = client.get('/') - assert b'Hello' in response.data - - -def test_subject_sentence(client, sentence_data): - with patch('app.util') as util: - client.post(f"/subject-sentence", json=sentence_data.greet) - - assert type(util.sentence_data) is dict - assert type(util.reasoner_response) is not dict - util.check_responses.assert_called_once() - - -def test_submit_reasoner_response(client, reasoner_response): - with patch('app.util') as util: - client.post(f"/submit-reasoner-response", json=reasoner_response.question) - - assert type(util.sentence_data) is not dict - assert type(util.reasoner_response) is dict - util.check_responses.assert_called_once() - - diff --git a/response-generator/app/tests/test_util.py b/response-generator/app/tests/test_util.py deleted file mode 100644 index 925f275..0000000 --- a/response-generator/app/tests/test_util.py +++ /dev/null @@ -1,91 +0,0 @@ -from unittest.mock import Mock - - -# Confirm that check_responses doesn't do anything by itself -def test_check_responses_none(application, util): - with application.app_context(): - util.generate_response = Mock() - util.check_responses() - util.generate_response.assert_not_called() - - -# Confirm that check_responses doesn't do anything with just sentence data -def test_check_responses_sentence_data(application, util, sentence_data): - with application.app_context(): - util.generate_response = Mock() - util.sentence_data = sentence_data.greet - util.check_responses() - util.generate_response.assert_not_called() - - -# Confirm that check_responses doesn't do anything with just a reasoner response -def test_check_responses_reasoner_response(application, util, reasoner_response): - with application.app_context(): - util.generate_response = Mock() - util.reasoner_response = reasoner_response.question - util.check_responses() - util.generate_response.assert_not_called() - - -# Confirm that something happens when both are set -def test_check_responses_both_set(application, util, sentence_data, reasoner_response): - with application.app_context(): - util.generate_response = Mock() - util.sentence_data = sentence_data.greet - util.reasoner_response = reasoner_response.question - util.check_responses() - - # First, it should generate a response - util.generate_response.assert_called_once() - - # Secondly it should reset the values of both sentence_data and reasoner_response - assert util.sentence_data is None - assert util.reasoner_response is None - - -# A greeting is sent back upon greeting, no question or advice formulated -def test_generate_response_greeting(application, util, sentence_data, reasoner_response): - with application.app_context(): - util.formulate_advice = Mock() - util.formulate_question = Mock() - res = util.generate_response(sentence_data.greet, reasoner_response.greet) - - util.formulate_question.assert_not_called() - util.formulate_advice.assert_not_called() - assert "hi" in res.lower() - - -# A question is formulated if the reasoner comes up with a question. -def test_generate_response_question(application, util, sentence_data, reasoner_response): - with application.app_context(): - util.formulate_advice = Mock() - util.formulate_question = Mock() - res = util.generate_response(sentence_data.other, reasoner_response.question) - - util.formulate_question.assert_called_once() - util.formulate_advice.assert_not_called() - assert "?" in res.lower() - - -# Advice is formulated if the reasoner comes up with advice. -def test_generate_response_advice(application, util, sentence_data, reasoner_response): - with application.app_context(): - util.formulate_advice = Mock() - util.formulate_question = Mock() - res = util.generate_response(sentence_data.other, reasoner_response.advice) - - util.formulate_question.assert_not_called() - util.formulate_advice.assert_called_once() - assert "activity" in res.lower() - - -# Missing patient_name should result in "Unknown patient" being used as name. -def test_generate_response_no_patient(application, util, sentence_data, reasoner_response): - with application.app_context(): - util.formulate_advice = Mock() - util.formulate_question = Mock() - del sentence_data.greet["patient_name"] - res = util.generate_response(sentence_data.greet, reasoner_response.greet) - assert "Unknown Patient".lower() in res.lower() - - diff --git a/restart.sh b/restart.sh deleted file mode 100755 index 389c021..0000000 --- a/restart.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -docker-compose down -v && \ -docker-compose build && \ -docker-compose up diff --git a/text-to-triples/app/routes.py b/text-to-triples/app/routes.py deleted file mode 100644 index 87bb41c..0000000 --- a/text-to-triples/app/routes.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import Blueprint, current_app, request -import app.util - - -bp = Blueprint('main', __name__) - - -@bp.route('/') -def hello(): - return 'Hello, I am the text to triples module!' - - -@bp.route('/new-sentence', methods=['POST']) -def new_sentence(): - data = request.json - sentence = data["sentence"] - patient_name = data['patient_name'] - current_app.logger.debug(f"Patient {patient_name} wrote {sentence}") - app.util.send_triples(patient_name, sentence) - return 'OK'