├── .devcontainer
└── devcontainer.json
├── .env.template
├── .github
├── CODE_OF_CONDUCT.md
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── azure-dev.yml
│ └── docker-image.yml
├── .gitignore
├── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── tasks.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── api_documentation.md
├── azure.yaml
├── demo
├── .DS_Store
├── default-dataset
│ ├── Invoice Sample.pdf
│ ├── eval_data.jsonl
│ ├── evaluation_schema.json
│ ├── ground_truth.json
│ ├── ground_truth_with_evaluators.json
│ ├── output_schema.json
│ ├── output_schema_empty.json
│ └── system_prompt.txt
└── medical-dataset
│ ├── eyes_surgery_pre_1_4.pdf
│ ├── output_schema.json
│ └── system_prompt.txt
├── docs
└── ArchitectureOverview.png
├── frontend
├── .deployment
├── .dockerignore
├── .env.temp
├── .gitignore
├── Dockerfile
├── app.py
├── backend_client.py
├── concurrency_management.py
├── concurrency_settings.py
├── document_chat.py
├── explore_data.py
├── instructions.py
├── process_files.py
├── requirements.txt
├── settings.py
└── static
│ └── logo.png
├── infra
├── abbreviations.json
├── main-containerapp.bicep
├── main-containerapp.parameters.json
├── main.bicep
└── main.parameters.json
├── notebooks
├── .env.temp
├── README.md
├── evaluator.ipynb
├── output.json
├── outputs
│ ├── output_07_31.15.32.50.json
│ ├── output_07_31.15.32.50_randomized-1.json
│ ├── output_07_31.15.32.50_randomized-2.json
│ ├── output_07_31.15.32.50_randomized-3.json
│ └── output_08_07.15.33.41.json
└── requirements.txt
├── sample-invoice.pdf
└── src
├── __init__.py
├── containerapp
├── Dockerfile
├── REFACTORING_SUMMARY.md
├── ai_ocr
│ ├── azure
│ │ ├── config.py
│ │ ├── doc_intelligence.py
│ │ ├── images.py
│ │ └── openai_ops.py
│ ├── chains.py
│ ├── model.py
│ ├── process.py
│ └── timeout.py
├── api_routes.py
├── blob_processing.py
├── datasets
│ └── default-dataset
│ │ └── demo.docx
├── dependencies.py
├── evaluators
│ ├── __init__.py
│ ├── cosine_similarity_string_evaluator.py
│ ├── custom_string_evaluator.py
│ ├── field_evaluator_base.py
│ ├── fuzz_string_evaluator.py
│ ├── json_evaluator.py
│ └── tests
│ │ ├── __init__.py
│ │ ├── test_custom_string_evaluator.py
│ │ └── test_json_evaluator.py
├── example-datasets
│ ├── default-dataset
│ │ ├── output_schema.json
│ │ └── system_prompt.txt
│ └── medical-dataset
│ │ ├── output_schema.json
│ │ └── system_prompt.txt
├── logic_app_manager.py
├── main.py
├── main_local.py
├── models.py
└── requirements.txt
└── evaluators
├── __init__.py
├── cosine_similarity_string_evaluator.py
├── custom_string_evaluator.py
├── field_evaluator_base.py
├── fuzz_string_evaluator.py
├── json_evaluator.py
└── tests
├── __init__.py
├── test_custom_string_evaluator.py
└── test_json_evaluator.py
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "image": "mcr.microsoft.com/devcontainers/python:3",
3 | "features": {
4 | "ghcr.io/devcontainers/features/azure-cli:1": {},
5 | "ghcr.io/azure/azure-dev/azd:0": {},
6 | "ghcr.io/devcontainers/features/github-cli:1": {},
7 | "ghcr.io/devcontainers/features/node:1": {},
8 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}
9 | },
10 | "customizations": {
11 | "vscode": {
12 | "extensions": [
13 | "GitHub.remotehub",
14 | "GitHub.copilot",
15 | "GitHub.copilot-chat",
16 | "github.vscode-pull-request-github",
17 | "ms-vscode.vscode-node-azure-pack",
18 | "ms-toolsai.jupyter",
19 | "ms-azuretools.azure-dev",
20 | "ms-azuretools.vscode-bicep",
21 | "ms-vscode.powershell",
22 | "ms-vscode-remote.vscode-remote-extensionpack",
23 | "tomoki1207.pdf",
24 | "redhat.vscode-yaml",
25 | "formulahendry.azure-storage-explorer",
26 | "ms-azuretools.vscode-docker",
27 | "ms-azuretools.vscode-azureresourcegroups",
28 | "ms-azuretools.vscode-azurestorage",
29 | "ms-azuretools.vscode-azure-github-copilot",
30 | "ms-vscode-remote.remote-containers",
31 | "ms-python.black-formatter",
32 | "ms-azuretools.vscode-azurefunctions"
33 | ]
34 | }
35 | },
36 | // Add your own post creation commands here
37 | // Add the Python packages that you use to requirements.txt
38 | "postCreateCommand": "sudo apt update && pip install --upgrade pip && pip install -r frontend/requirements.txt"
39 | }
40 |
41 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | # Environment variables for ARGUS Container App deployment
2 | # Copy this file to .env and fill in your values
3 |
4 | # Azure Subscription and Resource Group
5 | AZURE_SUBSCRIPTION_ID=your-subscription-id-here
6 | AZURE_RESOURCE_GROUP_NAME=rg-argus-containerapp
7 | AZURE_LOCATION=eastus2
8 |
9 | # Azure Environment (for azd)
10 | AZURE_ENV_NAME=argus-dev
11 | AZURE_PRINCIPAL_ID=your-user-principal-id
12 |
13 | # Azure Container App Configuration
14 | AZURE_CONTAINER_APP_NAME=ca-argus
15 |
16 | # Azure OpenAI Configuration
17 | AZURE_OPENAI_ENDPOINT=https://your-openai-account.openai.azure.com/
18 | AZURE_OPENAI_KEY=your-openai-api-key
19 | AZURE_OPENAI_MODEL_DEPLOYMENT_NAME=gpt-4
20 |
21 | # To get your Principal ID, run:
22 | # az ad signed-in-user show --query id --output tsv
23 |
24 | # To get your Subscription ID, run:
25 | # az account show --query id --output tsv
26 |
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Microsoft Open Source Code of Conduct
2 |
3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
4 |
5 | Resources:
6 |
7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
4 | > Please provide us with the following information:
5 | > ---------------------------------------------------------------
6 |
7 | ### This issue is for a: (mark with an `x`)
8 | ```
9 | - [ ] bug report -> please search issues before submitting
10 | - [ ] feature request
11 | - [ ] documentation issue or request
12 | - [ ] regression (a behavior that used to work and stopped in a new release)
13 | ```
14 |
15 | ### Minimal steps to reproduce
16 | >
17 |
18 | ### Any log messages given by the failure
19 | >
20 |
21 | ### Expected/desired behavior
22 | >
23 |
24 | ### OS and Version?
25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?)
26 |
27 | ### Versions
28 | >
29 |
30 | ### Mention any other details that might be useful
31 |
32 | > ---------------------------------------------------------------
33 | > Thanks! We'll be in touch soon.
34 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Purpose
2 |
3 | * ...
4 |
5 | ## Does this introduce a breaking change?
6 |
7 | ```
8 | [ ] Yes
9 | [ ] No
10 | ```
11 |
12 | ## Pull Request Type
13 | What kind of change does this Pull Request introduce?
14 |
15 |
16 | ```
17 | [ ] Bugfix
18 | [ ] Feature
19 | [ ] Code style update (formatting, local variables)
20 | [ ] Refactoring (no functional changes, no api changes)
21 | [ ] Documentation content changes
22 | [ ] Other... Please describe:
23 | ```
24 |
25 | ## How to Test
26 | * Get the code
27 |
28 | ```
29 | git clone [repo-address]
30 | cd [repo-name]
31 | git checkout [branch-name]
32 | npm install
33 | ```
34 |
35 | * Test the code
36 |
37 | ```
38 | ```
39 |
40 | ## What to Check
41 | Verify that the following are valid
42 | * ...
43 |
44 | ## Other Information
45 |
--------------------------------------------------------------------------------
/.github/workflows/azure-dev.yml:
--------------------------------------------------------------------------------
1 | on:
2 | workflow_dispatch:
3 | # Uncomment the below line to run the workflow on commit
4 | # push:
5 | # # Run when commits are pushed to mainline branch
6 | # # Set this to the mainline branch you are using
7 | # branches:
8 | # - main
9 |
10 | # Set this permission if you are using a Federated Credential.
11 | permissions:
12 | id-token: write
13 | contents: read
14 |
15 | jobs:
16 | build:
17 | runs-on: ubuntu-latest
18 | # azd build-in variables.
19 | # This variables are always set by `azd pipeline config`
20 | # You can set them as global env (apply to all steps) or you can add them to individual steps' environment
21 | env:
22 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
23 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
24 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
25 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
26 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
27 | ## Define the additional variables or secrets that are required globally (provision and deploy)
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 |
32 | # using the install-azd action
33 | - name: Install azd
34 | uses: Azure/setup-azd@v2.1.0
35 |
36 | # azd set up Federated Credential by default. You can remove this step if you are using Client Credentials
37 | - name: Log in with Azure (Federated Credentials)
38 | if: ${{ env.AZURE_CLIENT_ID != '' }}
39 | run: |
40 | azd auth login `
41 | --client-id "$Env:AZURE_CLIENT_ID" `
42 | --federated-credential-provider "github" `
43 | --tenant-id "$Env:AZURE_TENANT_ID"
44 | shell: pwsh
45 |
46 | - name: Provision Infrastructure
47 | run: azd provision --no-prompt
48 | env:
49 | AZURE_RESOURCE_GROUP: ${{ vars.AZURE_RESOURCE_GROUP }}
50 | # uncomment this if you are using infrastructure parameters
51 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }}
52 | ## Define the additional variables or secrets that are required only for provision
53 |
54 | - name: Deploy Application
55 | run: azd deploy --no-prompt
56 | # env:
57 | ## Define the additional variables or secrets that are required only for deploy
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 |
11 | docker-build:
12 | runs-on: ubuntu-latest
13 | steps:
14 |
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Docker Login
19 | uses: docker/login-action@v3
20 | with:
21 | registry: argus.azurecr.io
22 | username: argus
23 | password: ${{ secrets.DOCKER_PASSWORD }}
24 |
25 | - name: Set up Docker Buildx
26 | uses: docker/setup-buildx-action@v3
27 |
28 | - name: Get current date
29 | id: date
30 | run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
31 |
32 | - name: Build Docker Image and optionally push
33 | uses: docker/build-push-action@v6
34 | with:
35 | context: .
36 | file: docker/backend.Dockerfile
37 | push: true
38 | cache-from: type=registry,ref=argus.azurecr.io/argus-backend:latest
39 | tags: |
40 | argus.azurecr.io/argus-backend:latest
41 | argus.azurecr.io/argus-backend:${{ steps.date.outputs.date }}_${{ github.run_number }}
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | .hypothesis/
51 | .pytest_cache/
52 |
53 | # Translations
54 | *.mo
55 | *.pot
56 |
57 | # Django stuff:
58 | *.log
59 | local_settings.py
60 | db.sqlite3
61 |
62 | # Flask stuff:
63 | instance/
64 | .webassets-cache
65 |
66 | # Scrapy stuff:
67 | .scrapy
68 |
69 | # Sphinx documentation
70 | docs/_build/
71 |
72 | # PyBuilder
73 | target/
74 |
75 | # Jupyter Notebook
76 | .ipynb_checkpoints
77 |
78 | # IPython
79 | profile_default/
80 | ipython_config.py
81 |
82 | # pyenv
83 | .python-version
84 |
85 | # pipenv
86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not
89 | # install all needed dependencies.
90 | #Pipfile.lock
91 |
92 | # celery beat schedule file
93 | celerybeat-schedule
94 |
95 | # SageMath parsed files
96 | *.sage.py
97 |
98 | # Environments
99 | .env
100 | .venv
101 | .vscode/
102 | .azure/
103 |
104 | env/
105 | venv/
106 | ENV/
107 | env.bak/
108 | venv.bak/
109 |
110 | # Spyder project settings
111 | .spyderproject
112 | .spyproject
113 |
114 | # Rope project settings
115 | .ropeproject
116 |
117 | # mkdocs documentation
118 | /site
119 |
120 | # mypy
121 | .mypy_cache/
122 | .dmypy.json
123 | dmypy.json
124 |
125 | # Pyre type checker
126 | .pyre/
127 |
128 | # Azure Functions artifacts
129 | bin
130 | obj
131 | appsettings.json
132 | local.settings.json
133 |
134 | # Azurite artifacts
135 | __blobstorage__
136 | __queuestorage__
137 | __azurite_db*__.json
138 | .python_packages
139 | # Azure deployment artifacts
140 | .azure/
141 | .env
142 | .env.local
143 |
144 | # Test outputs
145 | *.log
146 | test-output/
147 |
148 | # IDE
149 | .vscode/
150 | .idea/
151 |
152 | # Mac
153 | .DS_Store
154 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "ms-azuretools.vscode-azurefunctions",
4 | "ms-python.python"
5 | ]
6 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Attach to Python Functions",
6 | "type": "python",
7 | "request": "attach",
8 | "port": 9091,
9 | "preLaunchTask": "func: host start"
10 | }
11 | ]
12 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "azureFunctions.deploySubpath": "src/functionapp",
3 | "azureFunctions.scmDoBuildDuringDeployment": true,
4 | "azureFunctions.pythonVenv": "${workspaceFolder}/venv",
5 | "azureFunctions.projectLanguage": "Python",
6 | "azureFunctions.projectRuntime": "~4",
7 | "debug.internalConsoleOptions": "neverOpen",
8 | "azureFunctions.projectLanguageModel": 2,
9 | "azureFunctions.projectSubpath": "src",
10 | "python.testing.unittestArgs": [
11 | "-v",
12 | "-s",
13 | "./src",
14 | "-p",
15 | "test_*.py"
16 | ],
17 | "python.testing.pytestEnabled": false,
18 | "python.testing.unittestEnabled": true
19 | }
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "func",
6 | "label": "func: host start",
7 | "command": "host start",
8 | "problemMatcher": "$func-python-watch",
9 | "isBackground": true,
10 | "dependsOn": "pip install (functions)",
11 | "options": {
12 | "cwd": "${workspaceFolder}/src/functionapp"
13 | }
14 | },
15 | {
16 | "label": "pip install (functions)",
17 | "type": "shell",
18 | "osx": {
19 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt"
20 | },
21 | "windows": {
22 | "command": "${config:azureFunctions.pythonVenv}/Scripts/python -m pip install -r requirements.txt"
23 | },
24 | "linux": {
25 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt"
26 | },
27 | "problemMatcher": [],
28 | "options": {
29 | "cwd": "${workspaceFolder}/src/functionapp"
30 | }
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to [project-title]
2 |
3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a
4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
6 |
7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide
8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
9 | provided by the bot. You will only need to do this once across all repos using our CLA.
10 |
11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
14 |
15 | - [Code of Conduct](#coc)
16 | - [Issues and Bugs](#issue)
17 | - [Feature Requests](#feature)
18 | - [Submission Guidelines](#submit)
19 |
20 | ## Code of Conduct
21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
22 |
23 | ## Found an Issue?
24 | If you find a bug in the source code or a mistake in the documentation, you can help us by
25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can
26 | [submit a Pull Request](#submit-pr) with a fix.
27 |
28 | ## Want a Feature?
29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub
30 | Repository. If you would like to *implement* a new feature, please submit an issue with
31 | a proposal for your work first, to be sure that we can use it.
32 |
33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).
34 |
35 | ## Submission Guidelines
36 |
37 | ### Submitting an Issue
38 | Before you submit an issue, search the archive, maybe your question was already answered.
39 |
40 | If your issue appears to be a bug, and hasn't been reported, open a new issue.
41 | Help us to maximize the effort we can spend fixing issues and adding new
42 | features, by not reporting duplicate issues. Providing the following information will increase the
43 | chances of your issue being dealt with quickly:
44 |
45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps
46 | * **Version** - what version is affected (e.g. 0.1.2)
47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you
48 | * **Browsers and Operating System** - is this a problem with all browsers?
49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps
50 | * **Related Issues** - has a similar issue been reported before?
51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be
52 | causing the problem (line of code or commit)
53 |
54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new].
55 |
56 | ### Submitting a Pull Request (PR)
57 | Before you submit your Pull Request (PR) consider the following guidelines:
58 |
59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR
60 | that relates to your submission. You don't want to duplicate effort.
61 |
62 | * Make your changes in a new git fork:
63 |
64 | * Commit your changes using a descriptive commit message
65 | * Push your fork to GitHub:
66 | * In GitHub, create a pull request
67 | * If we suggest changes then:
68 | * Make the required updates.
69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request):
70 |
71 | ```shell
72 | git rebase master -i
73 | git push -f
74 | ```
75 |
76 | That's it! Thank you for your contribution!
77 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Azure Samples
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/azure.yaml:
--------------------------------------------------------------------------------
1 | name: argus
2 | metadata:
3 | template: containerapp-python@latest
4 | infra:
5 | provider: bicep
6 | path: infra
7 | services:
8 | backend:
9 | project: src/containerapp
10 | language: python
11 | host: containerapp
12 | frontend:
13 | project: frontend
14 | language: python
15 | host: containerapp
16 |
--------------------------------------------------------------------------------
/demo/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/demo/.DS_Store
--------------------------------------------------------------------------------
/demo/default-dataset/Invoice Sample.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/demo/default-dataset/Invoice Sample.pdf
--------------------------------------------------------------------------------
/demo/default-dataset/eval_data.jsonl:
--------------------------------------------------------------------------------
1 | {"ground_truth": {"Customer Name": "Happiest Valley Farms", "Invoice Number": "1234", "Date": "November 30, 2022", "Billing info": {"Customer": "Henry Ross", "Customer ID": "8675309", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Payment Due": "December 30, 2022", "Salesperson": "Luca Richter", "Payment Terms": "Cash or check", "Shipping info": {"Recipient": "Henry Ross", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Delivery Date": "December 7, 2022", "Shipping Method": "Ground", "Shipping Terms": "Returns not accepted", "Table": {"Items": [{"Qty": 10, "Item#": 123, "Description": "Baby chicks", "Unit price": 5.0, "Discount": "10%", "Line total": 45.0}, {"Qty": 2, "Item#": 444, "Description": "Heat lamps", "Discount": "", "Unit price": 24.0, "Line total": 48.0}, {"Qty": 6, "Item#": 120, "Description": "Chicken roosts", "Discount": "", "Unit price": 30.0, "Line total": 180.0}], "Total Discount": 5.0, "Subtotal": 278.0, "Sales Tax": 13.9, "Total": 286.9}, "Footer": {"Customer Name": "Happiest Valley Farms", "Address": "456 Anyroad, Anywhere", "Website": "interstingsite.com", "Phone number": "(123)987-6543", "Fax number": "(123)987-6542", "Email": "happiest@example.com"}}, "actual": {"Customer Name": "Henry Ross", "Invoice Number": "1234", "Date": "November 30, 2022", "Billing info": {"Customer": "Henry Ross", "Customer ID": "8675309", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Payment Due": "December 30, 2022", "Salesperson": "Luca Richter", "Payment Terms": "Cash or check", "Shipping info": {"Recipient": "Henry Ross", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Delivery Date": "December 7, 2022", "Shipping Method": "Ground", "Shipping Terms": "Returns not accepted", "Table": {"Items": [{"Qty": "10", "Item#": "123", "Description": "Baby chicks", "Unit price": "5.00", "Discount": "10%", "Line total": "45.00"}, {"Qty": "2", "Item#": "444", "Description": "Heat lamps", "Unit price": "24.00", "Discount": "", "Line total": "48.00"}, {"Qty": "6", "Item#": "120", "Description": "Chicken roosts", "Unit price": "30.00", "Discount": "", "Line total": "180.00"}], "Total Discount": "5.00", "Subtotal": "278.00", "Sales Tax": "13.90", "Total": "286.90"}, "Footer": {"Customer Name": "Happiest Valley Farms", "Address": "456 Anyroad, Anywhere", "Website": "interestingsite.com", "Phone number": "(123) 987-6543", "Fax number": "(123) 987-6542", "Email": "happiest@example.com"}}, "eval_schema": {"Customer Name": {"CustomStringEvaluator": {"IGNORE_DOTS": "True"}}, "Invoice Number": {"CustomStringEvaluator": {"IGNORE_NUMBER_SIGN": "True"}, "Date": {}, "Billing info": {"Customer": {}, "Customer ID": {}, "Address": {"CustomStringEvaluator": {"IGNORE_COMMAS": "True"}}, "Phone": {"CustomStringEvaluator": {"IGNORE_DASHES": "True", "IGNORE_PARENTHETHES": "True"}}}, "Payment Due": {}, "Salesperson": {}, "Payment Terms": {}, "Shipping info": {"Recipient": {}, "Address": {}, "Phone": {"CustomStringEvaluator": {"IGNORE_DASHES": "True", "IGNORE_PARENTHETHES": "True"}}}, "Delivery Date": {"CustomStringEvaluator": {"IGNORE_COMMAS": "True"}}, "Shipping Method": {}, "Shipping Terms": {}, "Table": {"Items": [{"Qty": {}, "Item#": {}, "Description": {}, "Unit price": {}, "Discount": {"CustomStringEvaluator": {"IGNORE_PERCENTAGE_SIGN": "True"}}, "Line total": {}}, {"Qty": {}, "Item#": {}, "Description": {}, "Unit price": {}, "Discount": {"CustomStringEvaluator": {"IGNORE_PERCENTAGE_SIGN": "True"}}, "Line total": {}}, {"Qty": {}, "Item#": {}, "Description": {}, "Unit price": {}, "Discount": {"CustomStringEvaluator": {"IGNORE_PERCENTAGE_SIGN": "True"}}, "Line total": {}}], "Total Discount": {}, "Subtotal": {}, "Sales Tax": {}, "Total": {}}, "Footer": {"Customer Name": {}, "Address": {}, "Website": {}, "Phone number": {}, "Fax number": {}, "Email": {}}}}}
2 |
--------------------------------------------------------------------------------
/demo/default-dataset/evaluation_schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "Customer Name": {
3 | "CustomStringEvaluator": {
4 | "IGNORE_DOTS": "True"
5 | }
6 | },
7 | "Invoice Number": {
8 | "CustomStringEvaluator": {
9 | "IGNORE_NUMBER_SIGN": "True"
10 | },
11 | "Date": {},
12 | "Billing info": {
13 | "Customer": {},
14 | "Customer ID": {},
15 | "Address": {
16 | "CustomStringEvaluator": {
17 | "IGNORE_COMMAS": "True"
18 | }
19 | },
20 | "Phone": {
21 | "CustomStringEvaluator": {
22 | "IGNORE_DASHES": "True",
23 | "IGNORE_PARENTHETHES": "True"
24 | }
25 | }
26 | },
27 | "Payment Due": {},
28 | "Salesperson": {},
29 | "Payment Terms": {},
30 | "Shipping info": {
31 | "Recipient": {},
32 | "Address": {},
33 | "Phone": {
34 | "CustomStringEvaluator": {
35 | "IGNORE_DASHES": "True",
36 | "IGNORE_PARENTHETHES": "True"
37 | }
38 | }
39 | },
40 | "Delivery Date": {
41 | "CustomStringEvaluator": {
42 | "IGNORE_COMMAS": "True"
43 | }
44 | },
45 | "Shipping Method": {},
46 | "Shipping Terms": {},
47 | "Table": {
48 | "Items": [
49 | {
50 | "Qty": {},
51 | "Item#": {},
52 | "Description": {},
53 | "Unit price": {},
54 | "Discount": {
55 | "CustomStringEvaluator": {
56 | "IGNORE_PERCENTAGE_SIGN": "True"
57 | }
58 | },
59 | "Line total": {}
60 | },
61 | {
62 | "Qty": {},
63 | "Item#": {},
64 | "Description": {},
65 | "Unit price": {},
66 | "Discount": {
67 | "CustomStringEvaluator": {
68 | "IGNORE_PERCENTAGE_SIGN": "True"
69 | }
70 | },
71 | "Line total": {}
72 | },
73 | {
74 | "Qty": {},
75 | "Item#": {},
76 | "Description": {},
77 | "Unit price": {},
78 | "Discount": {
79 | "CustomStringEvaluator": {
80 | "IGNORE_PERCENTAGE_SIGN": "True"
81 | }
82 | },
83 | "Line total": {}
84 | }
85 | ],
86 | "Total Discount": {},
87 | "Subtotal": {},
88 | "Sales Tax": {},
89 | "Total": {}
90 | },
91 | "Footer": {
92 | "Customer Name": {},
93 | "Address": {},
94 | "Website": {},
95 | "Phone number": {},
96 | "Fax number": {},
97 | "Email": {}
98 | }
99 | }
100 | }
--------------------------------------------------------------------------------
/demo/default-dataset/ground_truth.json:
--------------------------------------------------------------------------------
1 | {
2 | "Customer Name": "Happiest Valley Farms",
3 | "Invoice Number": "1234",
4 | "Date": "November 30, 2022",
5 | "Billing info": {
6 | "Customer": "Henry Ross",
7 | "Customer ID": "8675309",
8 | "Address": "123 Avenue A, Metropolis",
9 | "Phone": "(123) 456-7890"
10 | },
11 | "Payment Due": "December 30, 2022",
12 | "Salesperson": "Luca Richter",
13 | "Payment Terms": "Cash or check",
14 | "Shipping info": {
15 | "Recipient": "Henry Ross",
16 | "Address": "123 Avenue A, Metropolis",
17 | "Phone": "(123) 456-7890"
18 | },
19 | "Delivery Date": "December 7, 2022",
20 | "Shipping Method": "Ground",
21 | "Shipping Terms": "Returns not accepted",
22 | "Table": {
23 | "Items": [
24 | {
25 | "Qty": 10,
26 | "Item#": 123,
27 | "Description": "Baby chicks",
28 | "Unit price": 5.00,
29 | "Discount": "10%",
30 | "Line total": 45.00
31 | },
32 | {
33 | "Qty": 2,
34 | "Item#": 444,
35 | "Description": "Heat lamps",
36 | "Discount": "",
37 | "Unit price": 24.00,
38 | "Line total": 48.00
39 | },
40 | {
41 | "Qty": 6,
42 | "Item#": 120,
43 | "Description": "Chicken roosts",
44 | "Discount": "",
45 | "Unit price": 30.00,
46 | "Line total": 180.00
47 | }
48 | ],
49 | "Total Discount": 5.00,
50 | "Subtotal": 278.00,
51 | "Sales Tax": 13.90,
52 | "Total": 286.90
53 | },
54 | "Footer": {
55 | "Customer Name": "Happiest Valley Farms",
56 | "Address": "456 Anyroad, Anywhere",
57 | "Website": "interstingsite.com",
58 | "Phone number": "(123)987-6543",
59 | "Fax number": "(123)987-6542",
60 | "Email": "happiest@example.com"
61 | }
62 | }
--------------------------------------------------------------------------------
/demo/default-dataset/ground_truth_with_evaluators.json:
--------------------------------------------------------------------------------
1 | {
2 | "Customer Name": {
3 | "value": "Happiest Valley Farms",
4 | "evaluators": {
5 | "MatchEvaluator": {
6 | "IGNORE_DOTS": "True"
7 | }
8 | }
9 | },
10 | "Invoice Number": {
11 | "value": "1234",
12 | "evaluators": {
13 | "MatchEvaluator": {
14 | "IGNORE_NUMBER_SIGN": "True"
15 | }
16 | }
17 | },
18 | "Date": {
19 | "value": "November 30, 2022"
20 | },
21 | "Billing info": {
22 | "Customer": {
23 | "value": "Henry Ross"
24 | },
25 | "Customer ID": {
26 | "value": "8675309"
27 | },
28 | "Address": {
29 | "value": "123 Avenue A, Metropolis",
30 | "evaluators": {
31 | "MatchEvaluator": {
32 | "IGNORE_COMAS": "True"
33 | }
34 | }
35 | },
36 | "Phone": {
37 | "value": "(123) 456-7890",
38 | "evaluators": {
39 | "MatchEvaluator": {
40 | "IGNORE_DASHES": "True",
41 | "IGNORE_PARENTHETHIS": "True"
42 | }
43 | }
44 | }
45 | },
46 | "Payment Due": {
47 | "value": "December 30, 2022"
48 | },
49 | "Salesperson": {
50 | "value": "Luca Richter"
51 | },
52 | "Payment Terms": {
53 | "value": "Cash or check"
54 | },
55 | "Shipping info": {
56 | "Recipient": {
57 | "value": "Henry Ross"
58 | },
59 | "Address": {
60 | "value": "123 Avenue A, Metropolis"
61 | },
62 | "Phone": {
63 | "value": "(123) 456-7890",
64 | "evaluators": {
65 | "MatchEvaluator": {
66 | "IGNORE_DASHES": "True",
67 | "IGNORE_PARENTHETHIS": "True"
68 | }
69 | }
70 | }
71 | },
72 | "Delivery Date": {
73 | "value": "December 7, 2022",
74 | "evaluators": {
75 | "MatchEvaluator": {
76 | "IGNORE_COMAS": "True"
77 | }
78 | }
79 | },
80 | "Shipping Method": {
81 | "value": "Ground"
82 | },
83 | "Shipping Terms": {
84 | "value": "Returns not accepted"
85 | },
86 | "Table": {
87 | "Items": [
88 | {
89 | "Qty": {
90 | "value": 10
91 | },
92 | "Item#": {
93 | "value": 123
94 | },
95 | "Description": {
96 | "value": "Baby chicks"
97 | },
98 | "Unit price": {
99 | "value": 5.00
100 | },
101 | "Discount": {
102 | "value": "10%",
103 | "evaluators": {
104 | "MatchEvaluator": {
105 | "IGNORE_PERCENTAGE_SIGN": "True"
106 | }
107 | }
108 | },
109 | "Line total": {
110 | "value": 45.00
111 | }
112 | },
113 | {
114 | "Qty": {
115 | "value": 2
116 | },
117 | "Item#": {
118 | "value": 444
119 | },
120 | "Description": {
121 | "value": "Heat lamps"
122 | },
123 | "Unit price": {
124 | "value": 24.00
125 | },
126 | "Discount": {
127 | "value": "",
128 | "evaluators": {
129 | "MatchEvaluator": {
130 | "IGNORE_PERCENTAGE_SIGN": "True"
131 | }
132 | }
133 | },
134 | "Line total": {
135 | "value": 48.00
136 | }
137 | },
138 | {
139 | "Qty": {
140 | "value": 6
141 | },
142 | "Item#": {
143 | "value": 120
144 | },
145 | "Description": {
146 | "value": "Chicken roosts"
147 | },
148 | "Unit price": {
149 | "value": 30.00
150 | },
151 | "Discount": {
152 | "value": "",
153 | "evaluators": {
154 | "MatchEvaluator": {
155 | "IGNORE_PERCENTAGE_SIGN": "True"
156 | }
157 | }
158 | },
159 | "Line total": {
160 | "value": 180.00
161 | }
162 | }
163 | ],
164 | "Total Discount": {
165 | "value": 5.00
166 | },
167 | "Subtotal": {
168 | "value": 278.00
169 | },
170 | "Sales Tax": {
171 | "value": 13.90
172 | },
173 | "Total": {
174 | "value": 286.90
175 | }
176 | },
177 | "Footer": {
178 | "Customer Name": {
179 | "value": "Happiest Valley Farms"
180 | },
181 | "Address": {
182 | "value": "456 Anyroad, Anywhere"
183 | },
184 | "Website": {
185 | "value": "interstingsite.com"
186 | },
187 | "Phone number": {
188 | "value": "(123)987-6543"
189 | },
190 | "Fax number": {
191 | "value": "(123)987-6542"
192 | },
193 | "Email": {
194 | "value": "happiest@example.com"
195 | }
196 | }
197 | }
--------------------------------------------------------------------------------
/demo/default-dataset/output_schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "Customer Name": "",
3 | "Invoice Number": "",
4 | "Date": "",
5 | "Billing info": {
6 | "Customer": "",
7 | "Customer ID": "",
8 | "Address": "",
9 | "Phone": ""
10 | },
11 | "Payment Due": "",
12 | "Salesperson": "",
13 | "Payment Terms": "",
14 | "Shipping info": {
15 | "Recipient": "",
16 | "Address": "",
17 | "Phone": ""
18 | },
19 | "Delivery Date": "",
20 | "Shipping Method": "",
21 | "Shipping Terms": "",
22 | "Table": {
23 | "Items": [
24 | {
25 | "Qty": "",
26 | "Item#": "",
27 | "Description": "",
28 | "Unit price": "",
29 | "Discount": "",
30 | "Line total": ""
31 | }
32 | ],
33 | "Total Discount": "",
34 | "Subtotal": "",
35 | "Sales Tax": "",
36 | "Total": ""
37 | },
38 | "Footer": {
39 | "Customer Name": "",
40 | "Address": "",
41 | "Website": "",
42 | "Phone number": "",
43 | "Fax number": "",
44 | "Email": ""
45 | }
46 | }
--------------------------------------------------------------------------------
/demo/default-dataset/output_schema_empty.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/demo/default-dataset/system_prompt.txt:
--------------------------------------------------------------------------------
1 | Extract all data from the document in a comprehensive and structured manner.
2 |
3 | Focus on:
4 | - Key identifiers (invoice numbers, reference numbers, IDs)
5 | - Financial information (amounts, totals, currency, taxes)
6 | - Parties involved (vendors, customers, suppliers, recipients)
7 | - Dates and timelines (invoice dates, due dates, service periods)
8 | - Line items and details (products, services, quantities, prices)
9 | - Contact information (addresses, phone numbers, emails)
10 | - Any other relevant structured data visible in the document
11 |
12 | When both text and images are available, use the text as the primary source and cross-reference with images for accuracy. When only images are available, extract all visible information directly from the visual content.
--------------------------------------------------------------------------------
/demo/medical-dataset/eyes_surgery_pre_1_4.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/demo/medical-dataset/eyes_surgery_pre_1_4.pdf
--------------------------------------------------------------------------------
/demo/medical-dataset/output_schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "id" : "medical_report",
3 | "categorization" : "",
4 | "title": "Medical Report",
5 | "type": "object",
6 | "properties": {
7 | "doctor": {
8 | "type": "object",
9 | "properties": {
10 | "specialty": { "type": "string" },
11 | "name": { "type": "string" },
12 | "clinic": { "type": "string" },
13 | "phone": { "type": "string" },
14 | "fax": { "type": "string" }
15 | }
16 | },
17 | "patient": {
18 | "type": "object",
19 | "properties": {
20 | "name": { "type": "string" }
21 | }
22 | },
23 | "post_surgery_follow_up": {
24 | "type": "array",
25 | "items": {
26 | "type": "object",
27 | "properties": {
28 | "period": { "type": "string" },
29 | "date": { "type": "string", "format": "date" },
30 | "ODv": { "type": "string" },
31 | "ODT": { "type": "string" },
32 | "OSv": { "type": "string" },
33 | "OST": { "type": "string" },
34 | "therapy": { "type": "string" }
35 | }
36 | }
37 | },
38 | "pre_surgery_evaluation": {
39 | "type": "object",
40 | "properties": {
41 | "anamnesis_data": { "type": "string" },
42 | "night_glare": { "type": "string" },
43 | "contact_lens_tolerance": { "type": "string" },
44 | "medications": { "type": "string" },
45 | "ocular_dryness": { "type": "string" },
46 | "collagen_disorders": { "type": "string" },
47 | "diabetes": { "type": "string" },
48 | "autorefractometry": {
49 | "type": "object",
50 | "properties": {
51 | "OD": { "type": "string" },
52 | "OS": { "type": "string" }
53 | }
54 | },
55 | "visual_acuity": {
56 | "type": "object",
57 | "properties": {
58 | "OD": { "type": "string" },
59 | "OS": { "type": "string" }
60 | }
61 | },
62 | "corneal_map": { "type": "string" },
63 | "schirmer_tear_test": { "type": "string" },
64 | "pupilometry": { "type": "string" },
65 | "pachymetry": {
66 | "type": "object",
67 | "properties": {
68 | "OD": { "type": "string" },
69 | "OS": { "type": "string" }
70 | }
71 | },
72 | "cornea": { "type": "string" },
73 | "crystalline_lens": { "type": "string" },
74 | "fundus": { "type": "string" },
75 | "tonometry": { "type": "string" },
76 | "eyelid_conjunctiva_anomalies": { "type": "string" }
77 | }
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/demo/medical-dataset/system_prompt.txt:
--------------------------------------------------------------------------------
1 | Extract information about patients, medical conditions, treatments, analysis or appointments/visits they made at hospitals, doctors or laboratories, payments of invoices or purchases of medicaments.
2 | On the field 'categorization' choose one of these: 1) 'invoice' 2) 'medical_report' based on your classification.
3 | If you cannot determine that the content belongs to one of these categories then apply a classification 'N/A'.
--------------------------------------------------------------------------------
/docs/ArchitectureOverview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/docs/ArchitectureOverview.png
--------------------------------------------------------------------------------
/frontend/.deployment:
--------------------------------------------------------------------------------
1 | [config]
2 | SCM_DO_BUILD_DURING_DEPLOYMENT=true
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *.pyc
3 | *.pyo
4 | *.pyd
5 | .Python
6 | env/
7 | venv/
8 | .env
9 | .venv
10 | pip-log.txt
11 | pip-delete-this-directory.txt
12 | .tox
13 | .coverage
14 | .coverage.*
15 | .cache
16 | nosetests.xml
17 | coverage.xml
18 | *.cover
19 | *.log
20 | .git
21 | .mypy_cache
22 | .pytest_cache
23 | .hypothesis
24 | .DS_Store
25 | *.swp
26 | *.swo
27 | *~
28 |
--------------------------------------------------------------------------------
/frontend/.env.temp:
--------------------------------------------------------------------------------
1 | BLOB_ACCOUNT_URL=""
2 | CONTAINER_NAME="datasets"
3 | COSMOS_URL=""
4 | COSMOS_DB_NAME="doc-extracts"
5 | COSMOS_DOCUMENTS_CONTAINER_NAME="documents"
6 | COSMOS_CONFIG_CONTAINER_NAME="configuration"
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | .hypothesis/
51 | .pytest_cache/
52 |
53 | # Translations
54 | *.mo
55 | *.pot
56 |
57 | # Django stuff:
58 | *.log
59 | local_settings.py
60 | db.sqlite3
61 |
62 | # Flask stuff:
63 | instance/
64 | .webassets-cache
65 |
66 | # Scrapy stuff:
67 | .scrapy
68 |
69 | # Sphinx documentation
70 | docs/_build/
71 |
72 | # PyBuilder
73 | target/
74 |
75 | # Jupyter Notebook
76 | .ipynb_checkpoints
77 |
78 | # IPython
79 | profile_default/
80 | ipython_config.py
81 |
82 | # pyenv
83 | .python-version
84 |
85 | # pipenv
86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not
89 | # install all needed dependencies.
90 | #Pipfile.lock
91 |
92 | # celery beat schedule file
93 | celerybeat-schedule
94 |
95 | # SageMath parsed files
96 | *.sage.py
97 |
98 | # Environments
99 | .env
100 | .venv
101 | env/
102 | venv/
103 | ENV/
104 | env.bak/
105 | venv.bak/
106 |
107 | # Spyder project settings
108 | .spyderproject
109 | .spyproject
110 |
111 | # Rope project settings
112 | .ropeproject
113 |
114 | # mkdocs documentation
115 | /site
116 |
117 | # mypy
118 | .mypy_cache/
119 | .dmypy.json
120 | dmypy.json
121 |
122 | # Pyre type checker
123 | .pyre/
124 |
125 | # Azure Functions artifacts
126 | bin
127 | obj
128 | appsettings.json
129 | local.settings.json
130 |
131 | # Azurite artifacts
132 | __blobstorage__
133 | __queuestorage__
134 | __azurite_db*__.json
135 | .python_packages
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use Python 3.11 slim image
2 | FROM python:3.11-slim
3 |
4 | # Set working directory
5 | WORKDIR /app
6 |
7 | # Install system dependencies
8 | RUN apt-get update && apt-get install -y \
9 | curl \
10 | && rm -rf /var/lib/apt/lists/*
11 |
12 | # Copy requirements first for better caching
13 | COPY requirements.txt .
14 |
15 | # Install Python dependencies
16 | RUN pip install --no-cache-dir -r requirements.txt
17 |
18 | # Copy application code
19 | COPY . .
20 |
21 | # Create a non-root user
22 | RUN useradd --create-home --shell /bin/bash appuser && chown -R appuser:appuser /app
23 | USER appuser
24 |
25 | # Expose the port that Streamlit runs on
26 | EXPOSE 8501
27 |
28 | # Health check
29 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
30 | CMD curl -f http://localhost:8501/_stcore/health || exit 1
31 |
32 | # Run Streamlit
33 | CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableWebsocketCompression=false"]
34 |
--------------------------------------------------------------------------------
/frontend/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | import streamlit as st
3 | from dotenv import load_dotenv
4 |
5 | from process_files import process_files_tab
6 | from explore_data import explore_data_tab
7 | from instructions import instructions_tab
8 | from settings import settings_tab
9 |
10 | ## IMPORTANT: Instructions on how to run the Streamlit app locally can be found in the README.md file.
11 |
12 |
13 | # Load environment variables
14 | load_dotenv()
15 |
16 | # Initialize the session state variables if they are not already set
17 | def initialize_session_state():
18 | env_vars = {
19 | 'system_prompt': "SYSTEM_PROMPT",
20 | 'schema': "OUTPUT_SCHEMA",
21 | 'blob_conn_str': "BLOB_CONN_STR",
22 | 'blob_url' : "BLOB_ACCOUNT_URL",
23 | 'container_name': "CONTAINER_NAME",
24 | 'cosmos_url': "COSMOS_URL",
25 | 'cosmos_db_name': "COSMOS_DB_NAME",
26 | 'cosmos_documents_container_name': "COSMOS_DOCUMENTS_CONTAINER_NAME",
27 | 'cosmos_config_container_name': "COSMOS_CONFIG_CONTAINER_NAME",
28 | 'backend_url': "BACKEND_URL"
29 | }
30 | for var, env in env_vars.items():
31 | if var not in st.session_state:
32 | st.session_state[var] = os.getenv(env)
33 |
34 | # Initialize the session state variables
35 | initialize_session_state()
36 |
37 | # Set the page layout to wide
38 | st.set_page_config(
39 | page_title="ARGUS - Document Intelligence Platform",
40 | page_icon="🧠",
41 | layout="wide"
42 | )
43 |
44 | # Header
45 | st.header("🧠 ARGUS: Automated Retrieval and GPT Understanding System")
46 |
47 | # Tabs navigation
48 | tabs = st.tabs(["🧠 Process Files", "🔎 Explore Data", "⚙️ Settings", "📋 Instructions"])
49 |
50 | # Render the tabs
51 | with tabs[0]:
52 | process_files_tab()
53 | with tabs[1]:
54 | explore_data_tab()
55 | with tabs[2]:
56 | settings_tab()
57 | with tabs[3]:
58 | instructions_tab()
59 |
--------------------------------------------------------------------------------
/frontend/backend_client.py:
--------------------------------------------------------------------------------
1 | import os
2 | import requests
3 | import streamlit as st
4 | from typing import Optional, List, Dict, Any
5 |
6 |
7 | class BackendClient:
8 | """Client for communicating with the ARGUS backend API"""
9 |
10 | def __init__(self, backend_url: Optional[str] = None):
11 | self.backend_url = backend_url or os.getenv('BACKEND_URL', 'http://localhost:8000')
12 | self.session = requests.Session()
13 |
14 | def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
15 | """Make a request to the backend API"""
16 | url = f"{self.backend_url}/api{endpoint}"
17 | try:
18 | response = self.session.request(method, url, **kwargs)
19 | response.raise_for_status()
20 | return response
21 | except requests.exceptions.RequestException as e:
22 | st.error(f"Error communicating with backend: {e}")
23 | raise
24 |
25 | def upload_file(self, file_content: bytes, filename: str, dataset_name: str) -> Dict[str, Any]:
26 | """Upload a file to the specified dataset"""
27 | files = {
28 | 'file': (filename, file_content, 'application/octet-stream')
29 | }
30 | data = {
31 | 'dataset_name': dataset_name
32 | }
33 | response = self._make_request('POST', '/upload', files=files, data=data)
34 | return response.json()
35 |
36 | def get_configuration(self) -> Dict[str, Any]:
37 | """Get the current configuration from the backend"""
38 | response = self._make_request('GET', '/configuration')
39 | return response.json()
40 |
41 | def update_configuration(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
42 | """Update the configuration via the backend"""
43 | response = self._make_request('POST', '/configuration', json=config_data)
44 | return response.json()
45 |
46 | def get_datasets(self) -> List[str]:
47 | """Get list of available datasets"""
48 | response = self._make_request('GET', '/datasets')
49 | return response.json()
50 |
51 | def get_dataset_files(self, dataset_name: str) -> List[Dict[str, Any]]:
52 | """Get files in a specific dataset"""
53 | response = self._make_request('GET', f'/datasets/{dataset_name}/files')
54 | return response.json()
55 |
56 | def get_documents(self, dataset_name: Optional[str] = None) -> List[Dict[str, Any]]:
57 | """Get processed documents, optionally filtered by dataset"""
58 | params = {'dataset': dataset_name} if dataset_name else {}
59 | response = self._make_request('GET', '/documents', params=params)
60 | data = response.json()
61 |
62 | # Handle both old format (direct array) and new format (with wrapper)
63 | if isinstance(data, dict) and 'documents' in data:
64 | return data['documents']
65 | elif isinstance(data, list):
66 | return data
67 | else:
68 | return []
69 |
70 | def get_document_details(self, document_id: str) -> Optional[Dict[str, Any]]:
71 | """Get details for a specific document"""
72 | try:
73 | response = self._make_request('GET', f'/documents/{document_id}')
74 | return response.json()
75 | except requests.exceptions.RequestException:
76 | return None
77 |
78 | def health_check(self) -> Dict[str, Any]:
79 | """Check if the backend is healthy"""
80 | # Try the health endpoint without /api prefix first (for local development)
81 | try:
82 | url = f"{self.backend_url}/health"
83 | response = self.session.get(url)
84 | response.raise_for_status()
85 | return response.json()
86 | except:
87 | # Fallback to /api/health for production backend
88 | response = self._make_request('GET', '/health')
89 | return response.json()
90 |
91 | def delete_document(self, document_id: str) -> Optional[requests.Response]:
92 | """Delete a document by ID"""
93 | try:
94 | response = self._make_request('DELETE', f'/documents/{document_id}')
95 | return response
96 | except requests.exceptions.RequestException as e:
97 | st.error(f"Failed to delete document: {e}")
98 | return None
99 |
100 | def reprocess_document(self, document_id: str) -> Optional[requests.Response]:
101 | """Reprocess a document by ID"""
102 | try:
103 | response = self._make_request('POST', f'/documents/{document_id}/reprocess')
104 | return response
105 | except requests.exceptions.RequestException as e:
106 | st.error(f"Failed to reprocess document: {e}")
107 | return None
108 |
109 | def chat_with_document(self, document_id: str, message: str, chat_history: list = None) -> Dict[str, Any]:
110 | """Send a chat message about a specific document"""
111 | data = {
112 | 'document_id': document_id,
113 | 'message': message,
114 | 'chat_history': chat_history or []
115 | }
116 | response = self._make_request('POST', '/chat', json=data)
117 | return response.json()
118 |
119 |
120 | # Global backend client instance
121 | backend_client = BackendClient()
122 |
--------------------------------------------------------------------------------
/frontend/concurrency_management.py:
--------------------------------------------------------------------------------
1 | """
2 | Logic App Concurrency Management Interface
3 |
4 | This module provides a Streamlit interface for managing Logic App concurrency settings.
5 | It allows users to view current concurrency settings and update the maximum number of
6 | concurrent runs for the Logic App workflow.
7 | """
8 |
9 | import streamlit as st
10 | import requests
11 | import json
12 | import os
13 | from datetime import datetime
14 | import logging
15 |
16 | # Configure logging
17 | logging.basicConfig(level=logging.INFO)
18 | logger = logging.getLogger(__name__)
19 |
20 | def get_backend_url():
21 | """Get the backend API URL from environment or use default"""
22 | return os.getenv('BACKEND_API_URL', 'http://localhost:8000')
23 |
24 | def render_concurrency_management():
25 | """Render the Logic App concurrency management interface"""
26 | st.header("🔧 Logic App Concurrency Management")
27 | st.markdown("Manage the concurrency settings for your Logic App workflow to control how many instances can run simultaneously.")
28 |
29 | backend_url = get_backend_url()
30 |
31 | # Create two columns for better layout
32 | col1, col2 = st.columns([2, 1])
33 |
34 | with col1:
35 | st.subheader("Current Settings")
36 |
37 | # Add refresh button
38 | if st.button("🔄 Refresh Settings", key="refresh_concurrency"):
39 | st.rerun()
40 |
41 | # Fetch current concurrency settings
42 | try:
43 | with st.spinner("Loading current concurrency settings..."):
44 | response = requests.get(f"{backend_url}/api/concurrency", timeout=10)
45 |
46 | if response.status_code == 200:
47 | settings = response.json()
48 |
49 | if settings.get("enabled", False):
50 | # Display current settings in a nice format
51 | st.success("✅ Logic App Manager is active")
52 |
53 | # Create metrics display
54 | metric_col1, metric_col2, metric_col3 = st.columns(3)
55 |
56 | with metric_col1:
57 | st.metric(
58 | label="Current Max Runs",
59 | value=settings.get("current_max_runs", "Unknown")
60 | )
61 |
62 | with metric_col2:
63 | st.metric(
64 | label="Workflow State",
65 | value=settings.get("workflow_state", "Unknown")
66 | )
67 |
68 | with metric_col3:
69 | if settings.get("last_modified"):
70 | try:
71 | last_modified = datetime.fromisoformat(
72 | settings["last_modified"].replace("Z", "+00:00")
73 | )
74 | st.metric(
75 | label="Last Modified",
76 | value=last_modified.strftime("%Y-%m-%d %H:%M")
77 | )
78 | except:
79 | st.metric(
80 | label="Last Modified",
81 | value="Unknown"
82 | )
83 |
84 | # Display Logic App details
85 | with st.expander("Logic App Details"):
86 | st.write(f"**Logic App Name:** {settings.get('logic_app_name', 'Unknown')}")
87 | st.write(f"**Resource Group:** {settings.get('resource_group', 'Unknown')}")
88 |
89 | # Store current settings in session state for updates
90 | st.session_state.current_max_runs = settings.get("current_max_runs", 5)
91 | st.session_state.logic_app_active = True
92 |
93 | else:
94 | st.error(f"❌ Logic App Manager is not configured: {settings.get('error', 'Unknown error')}")
95 | st.session_state.logic_app_active = False
96 |
97 | elif response.status_code == 503:
98 | st.error("❌ Logic App Manager is not available. Check configuration.")
99 | st.session_state.logic_app_active = False
100 | else:
101 | st.error(f"❌ Failed to fetch settings: HTTP {response.status_code}")
102 | st.session_state.logic_app_active = False
103 |
104 | except requests.exceptions.RequestException as e:
105 | st.error(f"❌ Connection error: {str(e)}")
106 | st.session_state.logic_app_active = False
107 | except Exception as e:
108 | st.error(f"❌ Error loading settings: {str(e)}")
109 | st.session_state.logic_app_active = False
110 |
111 | with col2:
112 | st.subheader("Update Settings")
113 |
114 | # Only show update form if Logic App is active
115 | if st.session_state.get("logic_app_active", False):
116 | current_max_runs = st.session_state.get("current_max_runs", 5)
117 |
118 | # Input for new max runs
119 | new_max_runs = st.number_input(
120 | "New Max Concurrent Runs",
121 | min_value=1,
122 | max_value=100,
123 | value=current_max_runs,
124 | step=1,
125 | help="Set the maximum number of Logic App instances that can run concurrently (1-100)"
126 | )
127 |
128 | # Show the impact of the change
129 | if new_max_runs != current_max_runs:
130 | if new_max_runs > current_max_runs:
131 | st.info(f"ℹ️ This will increase concurrency from {current_max_runs} to {new_max_runs}")
132 | else:
133 | st.warning(f"⚠️ This will decrease concurrency from {current_max_runs} to {new_max_runs}")
134 |
135 | # Update button
136 | if st.button("💾 Update Concurrency", key="update_concurrency"):
137 | if new_max_runs == current_max_runs:
138 | st.info("ℹ️ No changes to apply.")
139 | else:
140 | # Show confirmation for significant changes
141 | proceed = True
142 | if abs(new_max_runs - current_max_runs) > 5:
143 | st.warning("⚠️ This is a significant change in concurrency settings.")
144 | proceed = st.checkbox("I understand the impact of this change", key="confirm_update")
145 |
146 | if proceed:
147 | try:
148 | with st.spinner(f"Updating max concurrent runs to {new_max_runs}..."):
149 | update_payload = {"max_runs": new_max_runs}
150 | response = requests.put(
151 | f"{backend_url}/api/concurrency",
152 | json=update_payload,
153 | timeout=30
154 | )
155 |
156 | if response.status_code == 200:
157 | result = response.json()
158 | st.success(f"✅ Successfully updated max concurrent runs to {new_max_runs}!")
159 | st.session_state.current_max_runs = new_max_runs
160 |
161 | # Show update details
162 | with st.expander("Update Details"):
163 | st.json(result)
164 |
165 | # Auto-refresh after successful update
166 | st.rerun()
167 | else:
168 | error_detail = response.json().get("detail", "Unknown error")
169 | st.error(f"❌ Failed to update settings: {error_detail}")
170 |
171 | except requests.exceptions.RequestException as e:
172 | st.error(f"❌ Connection error: {str(e)}")
173 | except Exception as e:
174 | st.error(f"❌ Error updating settings: {str(e)}")
175 | else:
176 | st.info("ℹ️ Configure Logic App Manager to enable updates.")
177 |
178 | # Information section
179 | st.markdown("---")
180 | st.subheader("ℹ️ About Concurrency Management")
181 |
182 | with st.expander("Understanding Concurrency Settings"):
183 | st.markdown("""
184 | **What is Logic App Concurrency?**
185 |
186 | Logic App concurrency controls how many instances of your workflow can run simultaneously:
187 |
188 | - **Low Concurrency (1-5)**: Better for resource-intensive operations, prevents overwhelming downstream services
189 | - **Medium Concurrency (6-20)**: Balanced approach for most scenarios
190 | - **High Concurrency (21-100)**: Suitable for lightweight operations with high throughput requirements
191 |
192 | **Considerations:**
193 | - Higher concurrency can improve throughput but may increase resource usage
194 | - Consider the capacity of downstream services (APIs, databases)
195 | - Monitor performance and adjust based on actual usage patterns
196 |
197 | **Environment Variables Required:**
198 | - `AZURE_SUBSCRIPTION_ID`: Your Azure subscription ID
199 | - `AZURE_RESOURCE_GROUP_NAME`: Resource group containing the Logic App
200 | - `LOGIC_APP_NAME`: Name of the Logic App workflow
201 | """)
202 |
203 | # Performance monitoring section
204 | with st.expander("Performance Monitoring Tips"):
205 | st.markdown("""
206 | **Monitoring Your Logic App Performance:**
207 |
208 | 1. **Azure Portal**: Check Logic App metrics and run history
209 | 2. **Application Insights**: Monitor performance and errors
210 | 3. **Resource Usage**: Watch CPU, memory, and execution time
211 | 4. **Downstream Impact**: Monitor connected services for performance issues
212 |
213 | **Best Practices:**
214 | - Start with lower concurrency and gradually increase
215 | - Test thoroughly in non-production environments
216 | - Set up alerts for high error rates or performance degradation
217 | - Review and adjust settings based on actual usage patterns
218 | """)
219 |
220 | # Main render function for the tab
221 | def render():
222 | """Main render function called by the Streamlit app"""
223 | render_concurrency_management()
224 |
225 | if __name__ == "__main__":
226 | # For testing the module standalone
227 | render()
228 |
--------------------------------------------------------------------------------
/frontend/concurrency_settings.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 | import requests
3 | import json
4 | from datetime import datetime
5 |
6 | def concurrency_settings_tab():
7 | """Simplified tab for managing Logic App concurrency settings"""
8 |
9 | st.markdown("## 🚀 Concurrency Settings")
10 | st.markdown("Configure how many files can be processed in parallel by the Logic App.")
11 |
12 | # Get backend URL from session state or environment
13 | backend_url = st.session_state.get('backend_url', 'http://localhost:8000')
14 |
15 | # Auto-load current settings
16 | current_settings = load_current_settings(backend_url)
17 |
18 | if current_settings and current_settings.get('enabled', False):
19 | # Get current value to prepopulate the input
20 | current_max_runs = current_settings.get('current_max_runs', 5)
21 |
22 | # Status indicator
23 | st.success("✅ Logic App Manager is enabled")
24 |
25 | # Simplified update form - centered layout
26 | st.markdown("### Set Maximum Concurrent Runs")
27 |
28 | with st.form("update_concurrency_form"):
29 | new_max_runs = st.number_input(
30 | f"Current setting: {current_max_runs} concurrent runs",
31 | min_value=1,
32 | max_value=100,
33 | value=current_max_runs, # Prepopulate with current value
34 | step=1,
35 | help="Number of files that can be processed simultaneously"
36 | )
37 |
38 | # Show impact guidance
39 | if new_max_runs <= 5:
40 | st.info("💡 Lower values: More controlled processing, lower resource usage")
41 | elif new_max_runs <= 20:
42 | st.info("💡 Medium values: Balanced approach for most scenarios")
43 | else:
44 | st.warning("💡 Higher values: Faster processing, requires sufficient Azure resources")
45 |
46 | submit_button = st.form_submit_button("Update Concurrency", type="primary")
47 |
48 | if submit_button:
49 | if new_max_runs == current_max_runs:
50 | st.info("ℹ️ No changes needed - value is already set to " + str(new_max_runs))
51 | else:
52 | success = update_concurrency_setting(backend_url, new_max_runs)
53 | if success:
54 | st.success(f"✅ Successfully updated to {new_max_runs} concurrent runs!")
55 | st.rerun() # Refresh to show new values
56 | else:
57 | st.error("❌ Failed to update settings. Please try again.")
58 |
59 | else:
60 | # Show error state
61 | st.error("❌ Logic App Manager is not available")
62 | if current_settings and 'error' in current_settings:
63 | st.error(f"Error: {current_settings['error']}")
64 | st.info("Please check your configuration and ensure the backend service is running.")
65 |
66 | # Add diagnostics section for troubleshooting
67 | st.markdown("---")
68 | st.markdown("### 🔍 Diagnostics")
69 |
70 | if st.button("Run Diagnostics", type="secondary"):
71 | with st.spinner("Running diagnostics..."):
72 | try:
73 | diag_response = requests.get(f"{backend_url}/api/concurrency/diagnostics", timeout=10)
74 | if diag_response.status_code == 200:
75 | diagnostics = diag_response.json()
76 |
77 | st.markdown("**Diagnostic Results:**")
78 |
79 | # Environment Variables Check
80 | env_vars = diagnostics.get("environment_variables", {})
81 | st.markdown("**Environment Variables:**")
82 | for var, is_set in env_vars.items():
83 | status_icon = "✅" if is_set else "❌"
84 | value = diagnostics.get("environment_values", {}).get(var, "NOT_SET")
85 | st.markdown(f"{status_icon} `{var}`: {value}")
86 |
87 | # Logic App Manager Status
88 | st.markdown("**Logic App Manager Status:**")
89 | manager_init = diagnostics.get("logic_app_manager_initialized", False)
90 | st.markdown(f"{'✅' if manager_init else '❌'} Logic App Manager Initialized: {manager_init}")
91 |
92 | if manager_init:
93 | manager_enabled = diagnostics.get("logic_app_manager_enabled", False)
94 | st.markdown(f"{'✅' if manager_enabled else '❌'} Logic App Manager Enabled: {manager_enabled}")
95 |
96 | creds_available = diagnostics.get("azure_credentials_available", False)
97 | st.markdown(f"{'✅' if creds_available else '❌'} Azure Credentials Available: {creds_available}")
98 |
99 | # Show full diagnostic data
100 | with st.expander("Full Diagnostic Data"):
101 | st.json(diagnostics)
102 |
103 | else:
104 | st.error(f"Failed to get diagnostics: HTTP {diag_response.status_code}")
105 |
106 | except Exception as e:
107 | st.error(f"Error running diagnostics: {str(e)}")
108 |
109 | # Enhanced help section
110 | st.markdown("---")
111 | st.markdown("### 📖 About Concurrency Control")
112 |
113 | with st.expander("💡 How Concurrency Control Works", expanded=True):
114 | st.markdown("""
115 | **Concurrency control** limits how many files can be processed simultaneously. This ensures stable processing and prevents resource overload.
116 |
117 | **What happens when you upload multiple files:**
118 | 1. Each file triggers a separate Logic App workflow run
119 | 2. The concurrency setting limits how many can run at the same time
120 | 3. Excess files wait in a queue until a slot becomes available
121 | 4. This prevents resource overload and ensures stable processing
122 |
123 | **Choosing the right setting:**
124 | - **Conservative (1-5 runs)**: Best for large files or limited Azure resources
125 | - **Balanced (6-15 runs)**: Good for most use cases with mixed file sizes
126 | - **Aggressive (16+ runs)**: Best for small files and ample Azure resources
127 | """)
128 |
129 | with st.expander("⚙️ Technical Details"):
130 | st.markdown("""
131 | **How the system enforces concurrency:**
132 | - **Logic App Level**: Controls workflow trigger concurrency
133 | - **Backend Level**: Uses semaphore to limit parallel processing
134 | - **End-to-End Control**: Both layers respect the same concurrency limit
135 |
136 | **Impact of changes:**
137 | - Changes take effect immediately for new file uploads
138 | - Currently running workflows are not affected
139 | - Higher concurrency = higher resource usage and costs
140 | - Lower concurrency = more controlled processing, lower costs
141 | """)
142 |
143 | with st.expander("🔧 Monitoring & Troubleshooting"):
144 | st.markdown("""
145 | **If processing seems slow:**
146 | 1. Check your current concurrency setting above
147 | 2. Consider increasing it if you have sufficient Azure resources
148 | 3. Monitor your Azure costs as higher concurrency = higher resource usage
149 |
150 | **If you see errors:**
151 | - Ensure the backend has proper permissions to manage the Logic App
152 | - Check that all required environment variables are set
153 | - Verify the Logic App exists and is in the 'Enabled' state
154 |
155 | **Resource considerations:**
156 | - Higher concurrency requires more Azure AI Document Intelligence capacity
157 | - Monitor your Azure OpenAI token usage and rate limits
158 | - Consider Azure Cosmos DB throughput (RU/s) for high concurrency
159 | """)
160 |
161 |
162 | def load_current_settings(backend_url):
163 | """Load current concurrency settings from the backend"""
164 | try:
165 | with st.spinner("Loading current settings..."):
166 | response = requests.get(f"{backend_url}/api/concurrency", timeout=10)
167 | if response.status_code == 200:
168 | return response.json()
169 | else:
170 | # Enhanced error reporting for 503 errors
171 | if response.status_code == 503:
172 | try:
173 | error_detail = response.json().get('detail', response.text)
174 | st.error(f"Failed to load concurrency settings: HTTP 503")
175 | st.error(f"Details: {error_detail}")
176 |
177 | # Show diagnostic information
178 | with st.expander("🔍 Diagnostic Information", expanded=True):
179 | st.markdown("**Possible causes:**")
180 | st.markdown("1. **Missing Environment Variables**: Logic App Manager requires these environment variables:")
181 | st.code("""
182 | AZURE_SUBSCRIPTION_ID
183 | AZURE_RESOURCE_GROUP_NAME
184 | LOGIC_APP_NAME
185 | """)
186 | st.markdown("2. **Logic App Not Deployed**: The Logic App workflow may not exist in Azure")
187 | st.markdown("3. **Authentication Issues**: The container app may not have permissions to access the Logic App")
188 |
189 | st.markdown("**To diagnose further:**")
190 | st.markdown("- Check Azure Container App environment variables in the Azure Portal")
191 | st.markdown("- Verify the Logic App exists in your resource group")
192 | st.markdown("- Check container app logs for authentication errors")
193 |
194 | except:
195 | st.error(f"Failed to load settings: HTTP {response.status_code}")
196 | st.error(f"Response: {response.text}")
197 | else:
198 | st.error(f"Failed to load settings: HTTP {response.status_code}")
199 | return None
200 | except requests.exceptions.RequestException as e:
201 | st.error(f"Connection error: {str(e)}")
202 | return None
203 | except Exception as e:
204 | st.error(f"Error loading settings: {str(e)}")
205 | return None
206 |
207 |
208 | def update_concurrency_setting(backend_url, new_max_runs):
209 | """Update the concurrency setting"""
210 | try:
211 | with st.spinner(f"Updating to {new_max_runs} concurrent runs..."):
212 | payload = {"max_runs": new_max_runs}
213 | response = requests.put(
214 | f"{backend_url}/api/concurrency",
215 | json=payload,
216 | timeout=30,
217 | headers={"Content-Type": "application/json"}
218 | )
219 |
220 | if response.status_code == 200:
221 | return True
222 | else:
223 | try:
224 | error_data = response.json()
225 | error_detail = error_data.get('detail', response.text)
226 | except:
227 | error_detail = response.text
228 | st.error(f"Update failed: {error_detail}")
229 | return False
230 |
231 | except Exception as e:
232 | st.error(f"Error updating settings: {str(e)}")
233 | return False
234 |
--------------------------------------------------------------------------------
/frontend/document_chat.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 | import requests
3 | import json
4 | from typing import List, Dict, Any, Optional
5 |
6 |
7 | class DocumentChatComponent:
8 | """Chat component for interacting with document content"""
9 |
10 | def __init__(self, backend_url: str):
11 | self.backend_url = backend_url
12 |
13 | def initialize_chat_state(self, document_id: str):
14 | """Initialize chat state for a document"""
15 | chat_key = f"chat_history_{document_id}"
16 | if chat_key not in st.session_state:
17 | st.session_state[chat_key] = []
18 | return chat_key
19 |
20 | def send_message(self, document_id: str, message: str, document_context: str, chat_history: List[Dict]) -> Optional[Dict]:
21 | """Send a message to the chat API"""
22 | try:
23 | response = requests.post(
24 | f"{self.backend_url}/api/chat",
25 | json={
26 | "document_id": document_id,
27 | "message": message,
28 | "chat_history": chat_history
29 | },
30 | timeout=30
31 | )
32 |
33 | if response.status_code == 200:
34 | return response.json()
35 | else:
36 | st.error(f"Chat API error: {response.status_code} - {response.text}")
37 | return None
38 |
39 | except requests.exceptions.RequestException as e:
40 | st.error(f"Error communicating with chat API: {e}")
41 | return None
42 |
43 | def render_chat_interface(self, document_id: str, document_name: str, document_context: str = ""):
44 | """Render the chat interface"""
45 | st.markdown(f"### Chat with: {document_name}")
46 | st.markdown("Ask questions about this document and get insights based on the extracted data.")
47 |
48 | # Initialize chat state
49 | chat_key = self.initialize_chat_state(document_id)
50 |
51 | # Display chat history
52 | chat_container = st.container()
53 | with chat_container:
54 | if st.session_state[chat_key]:
55 | for i, chat_item in enumerate(st.session_state[chat_key]):
56 | role = chat_item.get('role', 'user')
57 | content = chat_item.get('content', '')
58 | with st.chat_message(role):
59 | st.write(content)
60 | else:
61 | st.info("Start a conversation! Ask questions about the document content, specific details, or request insights.")
62 |
63 | # Use st.chat_input for chat input
64 | user_message = st.chat_input("Ask a question about this document...")
65 |
66 | if user_message and user_message.strip():
67 | # Add user message to chat history
68 | st.session_state[chat_key].append({
69 | "role": "user",
70 | "content": user_message.strip()
71 | })
72 | # Show loading spinner
73 | with st.spinner("Thinking..."):
74 | response = self.send_message(
75 | document_id,
76 | user_message.strip(),
77 | document_context,
78 | st.session_state[chat_key]
79 | )
80 | if response:
81 | assistant_response = response.get('response', 'Sorry, I could not process your request.')
82 | st.session_state[chat_key].append({
83 | "role": "assistant",
84 | "content": assistant_response
85 | })
86 | if 'usage' in response:
87 | usage = response['usage']
88 | with st.expander("Token Usage", expanded=False):
89 | st.write(f"**Prompt Tokens:** {usage.get('prompt_tokens', 0)}")
90 | st.write(f"**Completion Tokens:** {usage.get('completion_tokens', 0)}")
91 | st.write(f"**Total Tokens:** {usage.get('total_tokens', 0)}")
92 | st.rerun()
93 |
94 | # Clear chat history button
95 | if st.session_state[chat_key]:
96 | st.markdown("---")
97 | if st.button("Clear Chat History", key=f"clear_chat_{document_id}"):
98 | st.session_state[chat_key] = []
99 | st.rerun()
100 |
101 |
102 | def render_document_chat_tab(document_id: str, document_name: str, backend_url: str, document_context: str = ""):
103 | """Standalone function to render chat tab content"""
104 | chat_component = DocumentChatComponent(backend_url)
105 | chat_component.render_chat_interface(document_id, document_name, document_context)
106 |
--------------------------------------------------------------------------------
/frontend/instructions.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 |
3 | def instructions_tab():
4 | st.markdown(""" ## How to Use the ARGUS System
5 |
6 | ### Introduction
7 | The ARGUS System is a comprehensive document processing platform that uses Azure AI services to extract structured data from PDF files. The system uses direct cloud service integration for fast and efficient processing.
8 |
9 | ### System Architecture
10 | - **Frontend**: Streamlit-based web interface for user interactions
11 | - **Azure Services**: Document Intelligence, OpenAI, Storage, and Cosmos DB for data processing and storage
12 | - **Direct Integration**: Frontend connects directly to Azure services for optimal performance
13 |
14 | ### Step-by-Step Instructions #### 1. Uploading Files
15 | 1. **Navigate to the "🧠 Process Files" tab**.
16 | 2. **Select a Dataset**:
17 | - Choose a dataset from the dropdown menu.
18 | - The selected dataset will load its corresponding model prompt and example schema.
19 | 3. **Configure the Dataset** (Optional):
20 | - Modify the model prompt or example schema if needed.
21 | - Click 'Save' to update the configuration.
22 | 4. **Upload Files**:
23 | - Use the file uploader to select PDF files for processing.
24 | - Click 'Submit' to upload the files directly to cloud storage.
25 | - The uploaded files are processed automatically using the selected dataset's configuration.
26 | 5. **What is a Dataset?**
27 | - A dataset defines how documents should be processed, including:
28 | - **Model Prompt**: Instructions for the AI model on how to extract data
29 | - **Example Schema**: The target data structure to be extracted
30 | - The example schema can be empty; in this case, the AI model will create a schema based on the document content.
31 |
32 | ---
33 |
34 | #### 2. Exploring Data
35 | 1. **Navigate to the "🔎 Explore Data" tab**.
36 | 2. **View Document Statistics**:
37 | - See overview metrics including total documents, processed count, errors, and datasets
38 | 3. **Filter and Search**:
39 | - Use the dataset filter to view documents from specific datasets
40 | - Browse the document list with processing status indicators
41 | 4. **Analyze Processing Status**:
42 | - View charts showing processing status distribution
43 | - See dataset distribution across your documents
44 | 5. **View Document Details**:
45 | - Select individual documents to view detailed information
46 | - Review extracted content and processing metadata
47 | 6. **Status Indicators**:
48 | - ✅ Successfully processed
49 | - ❌ Processing error
50 | - ➖ Still processing
51 |
52 | ---
53 |
54 | #### 3. Adding New Dataset
55 | 1. **Navigate to the "🧠 Process Files" tab**.
56 | 2. **Add New Dataset**:
57 | - Scroll down to the "Add New Dataset" section.
58 | - Enter a new dataset name, model prompt, and example schema.
59 | - Click 'Add New Dataset' to create the dataset.
60 | - The new dataset will be saved directly to the database and available for selection.
61 |
62 | ---
63 |
64 | #### 4. Additional Notes
65 |
66 | - **Reprocessing Failed Files**:
67 | - For files that have failed, you can trigger reprocessing from the "🔎 Explore Data" tab.
68 |
69 | - **Handling Long Documents**:
70 | - Extraction accuracy might take a hit on very long documents. In such cases, we recommend splitting the documents into smaller parts before uploading.
71 |
72 | ----
73 |
74 | ### Processing Pipeline
75 |
76 | 1. **File Upload and Storage**:
77 | - Uploaded files are sent to Azure Blob Storage.
78 | - Files are organized into folders based on the selected dataset.
79 |
80 | 2. **Triggering Processing**:
81 | - The upload of a file triggers an Azure Function to start the processing pipeline.
82 | - The pipeline involves Azure Document Intelligence OCR and a Vision-enabled version of GPT-4.
83 |
84 | 3. **Data Extraction**:
85 | - **Azure Document Intelligence OCR**: Extracts text and structure from the uploaded PDF.
86 | - **Vision-enabled GPT-4**: Processes the extracted text to generate structured data based on the provided system prompt and example schema.
87 |
88 | 4. **Data Storage**:
89 | - Extracted data is stored in CosmosDB along with metadata and processing logs.
90 | - The system maintains logs and audit trails for each processed file.
91 |
92 | 5. **Data Retrieval and Display**:
93 | - The "🔎 Explore Data" tab fetches data from CosmosDB.
94 | - Displays the processing status and details of each file.
95 | - Allows for reprocessing or deletion of files directly from the interface.
96 |
97 | 6. **Configuration Management**:
98 | - Dataset configurations, including model prompts and example schemas, are stored in CosmosDB.
99 | - Configurations can be updated through the interface and are used to guide the extraction process.
100 |
101 | ---
102 |
103 | ### Additional Information
104 | For more details and to view the source code, visit the [Github Repo](https://github.com/albertaga27/azure-doc-extraction-gbb-ai/tree/one-click-deployment).
105 |
106 | ---
107 |
108 | This guide provides a comprehensive overview of the ARGUS System, ensuring that users can effectively upload, process, and manage their documents with ease.
109 | """)
110 |
111 |
--------------------------------------------------------------------------------
/frontend/requirements.txt:
--------------------------------------------------------------------------------
1 | streamlit==1.40.2
2 | pandas==2.2.3
3 | plotly==5.24.1
4 | azure-storage-blob==12.24.0
5 | azure-cosmos==4.9.0
6 | python-dotenv==1.0.1
7 | azure-identity==1.19.0
8 | requests==2.32.3
9 | numpy==2.1.3
10 | tornado<=6.4.2
--------------------------------------------------------------------------------
/frontend/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/frontend/static/logo.png
--------------------------------------------------------------------------------
/infra/abbreviations.json:
--------------------------------------------------------------------------------
1 | {
2 | "analysisServicesServers": "as",
3 | "apiManagementService": "apim-",
4 | "appConfigurationStores": "appcs-",
5 | "appManagedEnvironments": "cae-",
6 | "appContainerApps": "ca-",
7 | "authorizationPolicyDefinitions": "policy-",
8 | "automationAutomationAccounts": "aa-",
9 | "blueprintBlueprints": "bp-",
10 | "blueprintBlueprintsArtifacts": "bpa-",
11 | "cacheRedis": "redis-",
12 | "cdnProfiles": "cdnp-",
13 | "cdnProfilesEndpoints": "cdne-",
14 | "cognitiveServicesAccounts": "cog-",
15 | "cognitiveServicesFormRecognizer": "cog-fr-",
16 | "cognitiveServicesTextAnalytics": "cog-ta-",
17 | "computeAvailabilitySets": "avail-",
18 | "computeCloudServices": "cld-",
19 | "computeDiskEncryptionSets": "des",
20 | "computeDisks": "disk",
21 | "computeDisksOs": "osdisk",
22 | "computeGalleries": "gal",
23 | "computeSnapshots": "snap-",
24 | "computeVirtualMachines": "vm",
25 | "computeVirtualMachineScaleSets": "vmss-",
26 | "containerInstanceContainerGroups": "ci",
27 | "containerRegistryRegistries": "cr",
28 | "containerServiceManagedClusters": "aks-",
29 | "databricksWorkspaces": "dbw-",
30 | "dataFactoryFactories": "adf-",
31 | "dataLakeAnalyticsAccounts": "dla",
32 | "dataLakeStoreAccounts": "dls",
33 | "dataMigrationServices": "dms-",
34 | "dBforMySQLServers": "mysql-",
35 | "dBforPostgreSQLServers": "psql-",
36 | "devicesIotHubs": "iot-",
37 | "devicesProvisioningServices": "provs-",
38 | "devicesProvisioningServicesCertificates": "pcert-",
39 | "documentDBDatabaseAccounts": "cosmos-",
40 | "eventGridDomains": "evgd-",
41 | "eventGridDomainsTopics": "evgt-",
42 | "eventGridEventSubscriptions": "evgs-",
43 | "eventHubNamespaces": "evhns-",
44 | "eventHubNamespacesEventHubs": "evh-",
45 | "hdInsightClustersHadoop": "hadoop-",
46 | "hdInsightClustersHbase": "hbase-",
47 | "hdInsightClustersKafka": "kafka-",
48 | "hdInsightClustersMl": "mls-",
49 | "hdInsightClustersSpark": "spark-",
50 | "hdInsightClustersStorm": "storm-",
51 | "hybridComputeMachines": "arcs-",
52 | "insightsActionGroups": "ag-",
53 | "insightsComponents": "appi-",
54 | "keyVaultVaults": "kv-",
55 | "kubernetesConnectedClusters": "arck",
56 | "kustoClusters": "dec",
57 | "kustoClustersDatabases": "dedb",
58 | "loadTesting": "lt-",
59 | "logicIntegrationAccounts": "ia-",
60 | "logicWorkflows": "logic-",
61 | "machineLearningServicesWorkspaces": "mlw-",
62 | "managedIdentityUserAssignedIdentities": "id-",
63 | "managementManagementGroups": "mg-",
64 | "migrateAssessmentProjects": "migr-",
65 | "networkApplicationGateways": "agw-",
66 | "networkApplicationSecurityGroups": "asg-",
67 | "networkAzureFirewalls": "afw-",
68 | "networkBastionHosts": "bas-",
69 | "networkConnections": "con-",
70 | "networkDnsZones": "dnsz-",
71 | "networkExpressRouteCircuits": "erc-",
72 | "networkFirewallPolicies": "afwp-",
73 | "networkFirewallPoliciesWebApplication": "waf",
74 | "networkFirewallPoliciesRuleGroups": "wafrg",
75 | "networkFrontDoors": "fd-",
76 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-",
77 | "networkLoadBalancersExternal": "lbe-",
78 | "networkLoadBalancersInternal": "lbi-",
79 | "networkLoadBalancersInboundNatRules": "rule-",
80 | "networkLocalNetworkGateways": "lgw-",
81 | "networkNatGateways": "ng-",
82 | "networkNetworkInterfaces": "nic-",
83 | "networkNetworkSecurityGroups": "nsg-",
84 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-",
85 | "networkNetworkWatchers": "nw-",
86 | "networkPrivateDnsZones": "pdnsz-",
87 | "networkPrivateLinkServices": "pl-",
88 | "networkPublicIPAddresses": "pip-",
89 | "networkPublicIPPrefixes": "ippre-",
90 | "networkRouteFilters": "rf-",
91 | "networkRouteTables": "rt-",
92 | "networkRouteTablesRoutes": "udr-",
93 | "networkTrafficManagerProfiles": "traf-",
94 | "networkVirtualNetworkGateways": "vgw-",
95 | "networkVirtualNetworks": "vnet-",
96 | "networkVirtualNetworksSubnets": "snet-",
97 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-",
98 | "networkVirtualWans": "vwan-",
99 | "networkVpnGateways": "vpng-",
100 | "networkVpnGatewaysVpnConnections": "vcn-",
101 | "networkVpnGatewaysVpnSites": "vst-",
102 | "notificationHubsNamespaces": "ntfns-",
103 | "notificationHubsNamespacesNotificationHubs": "ntf-",
104 | "operationalInsightsWorkspaces": "log-",
105 | "portalDashboards": "dash-",
106 | "powerBIDedicatedCapacities": "pbi-",
107 | "purviewAccounts": "pview-",
108 | "recoveryServicesVaults": "rsv-",
109 | "resourcesResourceGroups": "rg-",
110 | "searchSearchServices": "srch-",
111 | "serviceBusNamespaces": "sb-",
112 | "serviceBusNamespacesQueues": "sbq-",
113 | "serviceBusNamespacesTopics": "sbt-",
114 | "serviceEndPointPolicies": "se-",
115 | "serviceFabricClusters": "sf-",
116 | "signalRServiceSignalR": "sigr",
117 | "sqlManagedInstances": "sqlmi-",
118 | "sqlServers": "sql-",
119 | "sqlServersDataWarehouse": "sqldw-",
120 | "sqlServersDatabases": "sqldb-",
121 | "sqlServersDatabasesStretch": "sqlstrdb-",
122 | "storageStorageAccounts": "st",
123 | "storageStorageAccountsVm": "stvm",
124 | "storSimpleManagers": "ssimp",
125 | "streamAnalyticsCluster": "asa-",
126 | "synapseWorkspaces": "syn",
127 | "synapseWorkspacesAnalyticsWorkspaces": "synw",
128 | "synapseWorkspacesSqlPoolsDedicated": "syndp",
129 | "synapseWorkspacesSqlPoolsSpark": "synsp",
130 | "timeSeriesInsightsEnvironments": "tsi-",
131 | "webServerFarms": "plan-",
132 | "webSitesAppService": "app-",
133 | "webSitesAppServiceEnvironment": "ase-",
134 | "webSitesFunctions": "func-",
135 | "webStaticSites": "stapp-"
136 | }
--------------------------------------------------------------------------------
/infra/main-containerapp.parameters.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
3 | "contentVersion": "1.0.0.0",
4 | "parameters": {
5 | "location": {
6 | "value": "${AZURE_LOCATION}"
7 | },
8 | "environmentName": {
9 | "value": "${AZURE_ENV_NAME}"
10 | },
11 | "containerAppName": {
12 | "value": "${AZURE_CONTAINER_APP_NAME=ca-argus}"
13 | },
14 | "azurePrincipalId": {
15 | "value": "${AZURE_PRINCIPAL_ID}"
16 | },
17 | "azureOpenaiEndpoint": {
18 | "value": "${AZURE_OPENAI_ENDPOINT}"
19 | },
20 | "azureOpenaiKey": {
21 | "value": "${AZURE_OPENAI_KEY}"
22 | },
23 | "azureOpenaiModelDeploymentName": {
24 | "value": "${AZURE_OPENAI_MODEL_DEPLOYMENT_NAME}"
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/infra/main.parameters.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
3 | "contentVersion": "1.0.0.0",
4 | "parameters": {
5 | "location": {
6 | "value": "${AZURE_LOCATION}"
7 | },
8 | "environmentName": {
9 | "value": "${AZURE_ENV_NAME}"
10 | },
11 | "containerAppName": {
12 | "value": "${AZURE_CONTAINER_APP_NAME=ca-argus}"
13 | },
14 | "azurePrincipalId": {
15 | "value": "${AZURE_PRINCIPAL_ID}"
16 | },
17 | "azureOpenaiEndpoint": {
18 | "value": "${AZURE_OPENAI_ENDPOINT}"
19 | },
20 | "azureOpenaiKey": {
21 | "value": "${AZURE_OPENAI_KEY}"
22 | },
23 | "azureOpenaiModelDeploymentName": {
24 | "value": "${AZURE_OPENAI_MODEL_DEPLOYMENT_NAME}"
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/notebooks/.env.temp:
--------------------------------------------------------------------------------
1 | DOCUMENT_INTELLIGENCE_ENDPOINT=
2 | DOCUMENT_INTELLIGENCE_KEY=
3 | AZURE_OPENAI_KEY=
4 | AZURE_OPENAI_ENDPOINT=
5 | AZURE_OPENAI_MODEL_DEPLOYMENT_NAME=
--------------------------------------------------------------------------------
/notebooks/README.md:
--------------------------------------------------------------------------------
1 | # Evaluating ARGUS
2 | ###
3 |
4 | > This notebook illustrate how to double check a first run of the Solution against an expected output.
5 |
6 |
7 | ### Notebook instructions
8 |
9 | Create a .env file in the notebook folder with these keys:
10 |
11 | DOCUMENT_INTELLIGENCE_ENDPOINT=
12 | DOCUMENT_INTELLIGENCE_KEY=
13 | AZURE_OPENAI_KEY=
14 | AZURE_OPENAI_ENDPOINT=
15 | AZURE_OPENAI_MODEL_DEPLOYMENT_NAME=
16 |
17 | > Notes:
18 | > - The document-intelligence resource needs to use the markdown preview feature (limited regions: West EU and East US at the moment).
19 | > - The Azure OpenAI model needs to be vision capable i.e. GPT-4T-0125, 0409 or Omni
20 |
21 | Install requirements.txt provided.
22 |
23 |
24 | ### Notebook flow
25 |
26 | 1. Run ARGUS on an Invoice sample from the demo/default-dataset folder
27 | 2. Saves the output in json format
28 | 3. Run evaluation using LLM as a judge without ground truth data
29 | 4. Run evaluation using ground truth
30 |
31 | ### Evaluation using ground truth data
32 |
33 | This approach provides a way to evaluate actual JSON against ground truth data.
34 | The ground truth data suppose to be manually verified by the human and adhere to the schema provided to Argus solution.
35 | The end result is a combination of total summary (ratio) with detailed information of comparison for each field. The output is a JSON file stored in [outputs folder](./outputs).
36 | [Json evaluator](../src/evaluators/json_evaluator.py) can use different mechanisms of comparing string values. For now we provide configurable [custom string evaluator](src/evaluators/custom_string_evaluator.py) and [fuzzy match evaluator](src/evaluators/fuzz_string_evaluator.py). It can be expanded to support other string evaluation techniques that might include LLM calls in combination with ground truth.
37 | The ratio is calculated based on the total number of strings being matched between ground truth and actual divided by the total number of values being compared.
38 |
39 |
40 | #### Evaluation data
41 |
42 | The [prompt flow evaluation API](https://microsoft.github.io/promptflow/reference/python-library-reference/promptflow-evals/promptflow.evals.evaluate.html) is used for evaluating the ground truth against the actual data. The `evaluate` function accepts the evaluation data in the form of `jsonl` and contains the keys `ground_truth`, `actual`, and optionally `eval_schema`. The notebook compiles the ground truth, actual and evaluation schema data into the jsonl format using the `compile_jsonl` function.
43 |
44 | The notebook will create the actual data. To update the [ground truth](../demo/default-dataset/ground_truth.json) and evaluation [schema](../), modify the respective files directly.
45 |
46 |
47 | #### Evaluation schema
48 |
49 | The [evaluation schema](../demo/default-dataset/evaluation_schema.json) is optional and used by the `JsonEvaluator` to configure how to evaluate each field in the ground truth with the actual value. If a field is not present in the evaluation schema that is present in the ground truth, then the default evaluators will be used. By default, each field will get a `CustomStringEvaluator` and `FuzzyMatchEvaluator`. If no default configuration and no evaluation schema provided for `CustomStringEvalaution` the evaluator will use exact match for value comparisons ignoring the case.
50 |
51 | Each field evaluator must implement the following method with the same arguments:
52 |
53 | ```python
54 | def __call__(self, ground_truth: str, actual: str, config: dict = None) -> int:
55 | # implementation here
56 | ```
57 |
58 | Example of default configuration for `CustomStringEvaluator`. This configuration will be applied to all fields unless specified in evaluation schema for a particular field
59 |
60 |
61 | ```python
62 | evaluators = [
63 | CustomStringEvaluator({
64 | CustomStringEvaluator.Config.IGNORE_COMMAS: True
65 | })
66 | ]
67 | json_evaluator = JsonEvaluator(evaluators)
68 | ```
69 |
70 | ```python
71 | ground_truth = {
72 | "name": "Smith, Bob",
73 | "phone": {
74 | "home_phone_number": "(555) 555-5555",
75 | "work_phone_number": "(555) 123-1234"
76 | },
77 | "address": "1234 Fake Street, FakeCity",
78 | "is_employed": "True"
79 | }
80 | ```
81 |
82 | ```python
83 | # evaluation_schema.json
84 | # Each field will get CustomStringEvaluator evaluatation with commas ignored unless the configuration is provided. The evaluation schema will override the default values.
85 |
86 | evaluation_schema = {
87 | # name is not provided so the default will be used, commas ignored
88 | "phone": {
89 | "home_phone_number": { # specific config for this field
90 | "CustomStringEvaluator": {
91 | "IGNORE_IGNORE_PARENTHETHES": "True",
92 | "IGNORE_DASHES": "True"
93 | }
94 | },
95 | "work_phone_number": {} # default config will be used for CustomStringEvaluator
96 | },
97 | "address": {}, # default config will be used for CustomStringEvaluator
98 | "is_employed": {
99 | "CustomStringEvaluator": {
100 | "ADDITIONAL_MATCHES": ["yes", "yup", "true"], # additional values that will be marked correct if any of these match the actual value
101 | }
102 | }
103 | }
104 | ```
105 |
106 | ```python
107 | actual = {
108 | "name": "Smith Bob", # correct, commas are ignored by default config for all fields
109 | "phone": {
110 | "home_phone_number": "555 5555555", # correct, parentheses and dashes are ignored by evaluation shcema for this field
111 | "work_phone_number": "555 1231234," # incorrect, parentheses and dashes are NOT ignored for this field
112 | },
113 | "address": "1234 Fake Street, FakeCity", # correct, exact match
114 | "is_employed": "yes" # correct, has a matches in additonal matches
115 | }
116 | ```
117 |
118 | ```python
119 | result = json_evaluator(ground_truth, actual, evaluation_schema)
120 | # result:
121 | # {
122 | # 'CustomStringEvaluator.name': 1,
123 | # 'CustomStringEvaluator.phone.home_phone_number': 1,
124 | # 'CustomStringEvaluator.phone.work_phone_number': 0,
125 | # 'CustomStringEvaluator.address': 1,
126 | # 'CustomStringEvaluator.is_employed': 1,
127 | # 'CustomStringEvaluator.ratio': 0.8 # 4 correct fields / 5 total fields
128 | # }
129 | ```
--------------------------------------------------------------------------------
/notebooks/output.json:
--------------------------------------------------------------------------------
1 | {"Customer Name": "Henry Ross", "Invoice Number": "1234", "Date": "November 30, 2022", "Billing info": {"Customer": "Henry Ross", "Customer ID": "8675309", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Payment Due": "December 30, 2022", "Salesperson": "Luca Richter", "Payment Terms": "Cash or check", "Shipping info": {"Recipient": "Henry Ross", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Delivery Date": "December 7, 2022", "Shipping Method": "Ground", "Shipping Terms": "Returns not accepted", "Table": {"Items": [{"Qty": "10", "Item#": "123", "Description": "Baby chicks", "Unit price": "5.00", "Discount": "10%", "Line total": "45.00"}, {"Qty": "2", "Item#": "444", "Description": "Heat lamps", "Unit price": "24.00", "Discount": "", "Line total": "48.00"}, {"Qty": "6", "Item#": "120", "Description": "Chicken roosts", "Unit price": "30.00", "Discount": "", "Line total": "180.00"}], "Total Discount": "5.00", "Subtotal": "278.00", "Sales Tax": "13.90", "Total": "286.90"}, "Footer": {"Customer Name": "Happiest Valley Farms", "Address": "456 Anyroad, Anywhere", "Website": "interestingsite.com", "Phone number": "(123) 987-6543", "Fax number": "(123) 987-6542", "Email": "happiest@example.com"}}
--------------------------------------------------------------------------------
/notebooks/outputs/output_07_31.15.32.50.json:
--------------------------------------------------------------------------------
1 | {"rows": [{"inputs.ground_truth": {"Customer Name": "Happiest Valley Farms", "Invoice Number": "1234", "Date": "November 30, 2022", "Billing info": {"Customer": "Henry Ross", "Customer ID": "8675309", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Payment Due": "December 30, 2022", "Salesperson": "Luca Richter", "Payment Terms": "Cash or check", "Shipping info": {"Recipient": "Henry Ross", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Delivery Date": "December 7, 2022", "Shipping Method": "Ground", "Shipping Terms": "Returns not accepted", "Table": {"Items": [{"Qty": 10, "Item#": 123, "Description": "Baby chicks", "Unit price": 5.0, "Discount": "10%", "Line total": 45.0}, {"Qty": 2, "Item#": 444, "Description": "Heat lamps", "Discount": "", "Unit price": 24.0, "Line total": 48.0}, {"Qty": 6, "Item#": 120, "Description": "Chicken roosts", "Discount": "", "Unit price": 30.0, "Line total": 180.0}], "Total Discount": 5.0, "Subtotal": 278.0, "Sales Tax": 13.9, "Total": 286.9}, "Footer": {"Customer Name": "Happiest Valley Farms", "Address": "456 Anyroad, Anywhere", "Website": "interstingsite.com", "Phone number": "(123)987-6543", "Fax number": "(123)987-6542", "Email": "happiest@example.com"}}, "inputs.actual": {"Customer Name": "Henry Ross", "Invoice Number": "1234", "Date": "November 30, 2022", "Billing info": {"Customer": "Henry Ross", "Customer ID": "8675309", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Payment Due": "December 30, 2022", "Salesperson": "Luca Richter", "Payment Terms": "Cash or check", "Shipping info": {"Recipient": "Henry Ross", "Address": "123 Avenue A, Metropolis", "Phone": "(123) 456-7890"}, "Delivery Date": "December 7, 2022", "Shipping Method": "Ground", "Shipping Terms": "Returns not accepted", "Table": {"Items": [{"Qty": "10", "Item#": "123", "Description": "Baby chicks", "Unit price": "5.00", "Discount": "10%", "Line total": "45.00"}, {"Qty": "2", "Item#": "444", "Description": "Heat lamps", "Unit price": "24.00", "Discount": "", "Line total": "48.00"}, {"Qty": "6", "Item#": "120", "Description": "Chicken roosts", "Unit price": "30.00", "Discount": "", "Line total": "180.00"}], "Total Discount": "5.00", "Subtotal": "278.00", "Sales Tax": "13.90", "Total": "286.90"}, "Footer": {"Customer Name": "Happiest Valley Farms", "Address": "456 Anyroad, Anywhere", "Website": "interestingsite.com", "Phone number": "(123) 987-6543", "Fax number": "(123) 987-6542", "Email": "happiest@example.com"}}, "inputs.eval_schema": {"Customer Name": {"CustomStringEvaluator": {"IGNORE_DOTS": "True"}}, "Invoice Number": {"CustomStringEvaluator": {"IGNORE_NUMBER_SIGN": "True"}, "Date": {}, "Billing info": {"Customer": {}, "Customer ID": {}, "Address": {"CustomStringEvaluator": {"IGNORE_COMMAS": "True"}}, "Phone": {"CustomStringEvaluator": {"IGNORE_DASHES": "True", "IGNORE_PARENTHETHES": "True"}}}, "Payment Due": {}, "Salesperson": {}, "Payment Terms": {}, "Shipping info": {"Recipient": {}, "Address": {}, "Phone": {"CustomStringEvaluator": {"IGNORE_DASHES": "True", "IGNORE_PARENTHETHES": "True"}}}, "Delivery Date": {"CustomStringEvaluator": {"IGNORE_COMMAS": "True"}}, "Shipping Method": {}, "Shipping Terms": {}, "Table": {"Items": [{"Qty": {}, "Item#": {}, "Description": {}, "Unit price": {}, "Discount": {"CustomStringEvaluator": {"IGNORE_PERCENTAGE_SIGN": "True"}}, "Line total": {}}, {"Qty": {}, "Item#": {}, "Description": {}, "Unit price": {}, "Discount": {"CustomStringEvaluator": {"IGNORE_PERCENTAGE_SIGN": "True"}}, "Line total": {}}, {"Qty": {}, "Item#": {}, "Description": {}, "Unit price": {}, "Discount": {"CustomStringEvaluator": {"IGNORE_PERCENTAGE_SIGN": "True"}}, "Line total": {}}], "Total Discount": {}, "Subtotal": {}, "Sales Tax": {}, "Total": {}}, "Footer": {"Customer Name": {}, "Address": {}, "Website": {}, "Phone number": {}, "Fax number": {}, "Email": {}}}}, "outputs.json_evaluator.CustomStringEvaluator.Customer Name": 0, "outputs.json_evaluator.FuzzStringEvaluator.Customer Name": 0.33, "outputs.json_evaluator.CustomStringEvaluator.Invoice Number": 1, "outputs.json_evaluator.FuzzStringEvaluator.Invoice Number": 1, "outputs.json_evaluator.CustomStringEvaluator.Date": 1, "outputs.json_evaluator.FuzzStringEvaluator.Date": 1, "outputs.json_evaluator.CustomStringEvaluator.Billing info.Customer": 1, "outputs.json_evaluator.FuzzStringEvaluator.Billing info.Customer": 1, "outputs.json_evaluator.CustomStringEvaluator.Billing info.Customer ID": 1, "outputs.json_evaluator.FuzzStringEvaluator.Billing info.Customer ID": 1, "outputs.json_evaluator.CustomStringEvaluator.Billing info.Address": 1, "outputs.json_evaluator.FuzzStringEvaluator.Billing info.Address": 1, "outputs.json_evaluator.CustomStringEvaluator.Billing info.Phone": 1, "outputs.json_evaluator.FuzzStringEvaluator.Billing info.Phone": 1, "outputs.json_evaluator.CustomStringEvaluator.Payment Due": 1, "outputs.json_evaluator.FuzzStringEvaluator.Payment Due": 1, "outputs.json_evaluator.CustomStringEvaluator.Salesperson": 1, "outputs.json_evaluator.FuzzStringEvaluator.Salesperson": 1, "outputs.json_evaluator.CustomStringEvaluator.Payment Terms": 1, "outputs.json_evaluator.FuzzStringEvaluator.Payment Terms": 1, "outputs.json_evaluator.CustomStringEvaluator.Shipping info.Recipient": 1, "outputs.json_evaluator.FuzzStringEvaluator.Shipping info.Recipient": 1, "outputs.json_evaluator.CustomStringEvaluator.Shipping info.Address": 1, "outputs.json_evaluator.FuzzStringEvaluator.Shipping info.Address": 1, "outputs.json_evaluator.CustomStringEvaluator.Shipping info.Phone": 1, "outputs.json_evaluator.FuzzStringEvaluator.Shipping info.Phone": 1, "outputs.json_evaluator.CustomStringEvaluator.Delivery Date": 1, "outputs.json_evaluator.FuzzStringEvaluator.Delivery Date": 1, "outputs.json_evaluator.CustomStringEvaluator.Shipping Method": 1, "outputs.json_evaluator.FuzzStringEvaluator.Shipping Method": 1, "outputs.json_evaluator.CustomStringEvaluator.Shipping Terms": 1, "outputs.json_evaluator.FuzzStringEvaluator.Shipping Terms": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[0].Qty": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[0].Qty": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[0].Item#": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[0].Item#": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[0].Description": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[0].Description": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[0].Unit price": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[0].Unit price": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[0].Discount": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[0].Discount": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[0].Line total": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[0].Line total": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[1].Qty": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[1].Qty": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[1].Item#": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[1].Item#": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[1].Description": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[1].Description": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[1].Discount": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[1].Discount": 0, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[1].Unit price": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[1].Unit price": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[1].Line total": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[1].Line total": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[2].Qty": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[2].Qty": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[2].Item#": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[2].Item#": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[2].Description": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[2].Description": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[2].Discount": 1, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[2].Discount": 0, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[2].Unit price": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[2].Unit price": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Items[2].Line total": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Items[2].Line total": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Total Discount": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Total Discount": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Subtotal": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Subtotal": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Sales Tax": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Sales Tax": 1, "outputs.json_evaluator.CustomStringEvaluator.Table.Total": 0, "outputs.json_evaluator.FuzzStringEvaluator.Table.Total": 1, "outputs.json_evaluator.CustomStringEvaluator.Footer.Customer Name": 1, "outputs.json_evaluator.FuzzStringEvaluator.Footer.Customer Name": 1, "outputs.json_evaluator.CustomStringEvaluator.Footer.Address": 1, "outputs.json_evaluator.FuzzStringEvaluator.Footer.Address": 1, "outputs.json_evaluator.CustomStringEvaluator.Footer.Website": 0, "outputs.json_evaluator.FuzzStringEvaluator.Footer.Website": 1, "outputs.json_evaluator.CustomStringEvaluator.Footer.Phone number": 0, "outputs.json_evaluator.FuzzStringEvaluator.Footer.Phone number": 1, "outputs.json_evaluator.CustomStringEvaluator.Footer.Fax number": 0, "outputs.json_evaluator.FuzzStringEvaluator.Footer.Fax number": 1, "outputs.json_evaluator.CustomStringEvaluator.Footer.Email": 1, "outputs.json_evaluator.FuzzStringEvaluator.Footer.Email": 1, "outputs.json_evaluator.CustomStringEvaluator.ratio": 0.6818181818, "outputs.json_evaluator.FuzzStringEvaluator.ratio": 0.9393181818}], "metrics": {"json_evaluator.CustomStringEvaluator.Customer Name": 0.0, "json_evaluator.FuzzStringEvaluator.Customer Name": 0.33, "json_evaluator.CustomStringEvaluator.Invoice Number": 1.0, "json_evaluator.FuzzStringEvaluator.Invoice Number": 1.0, "json_evaluator.CustomStringEvaluator.Date": 1.0, "json_evaluator.FuzzStringEvaluator.Date": 1.0, "json_evaluator.CustomStringEvaluator.Billing info.Customer": 1.0, "json_evaluator.FuzzStringEvaluator.Billing info.Customer": 1.0, "json_evaluator.CustomStringEvaluator.Billing info.Customer ID": 1.0, "json_evaluator.FuzzStringEvaluator.Billing info.Customer ID": 1.0, "json_evaluator.CustomStringEvaluator.Billing info.Address": 1.0, "json_evaluator.FuzzStringEvaluator.Billing info.Address": 1.0, "json_evaluator.CustomStringEvaluator.Billing info.Phone": 1.0, "json_evaluator.FuzzStringEvaluator.Billing info.Phone": 1.0, "json_evaluator.CustomStringEvaluator.Payment Due": 1.0, "json_evaluator.FuzzStringEvaluator.Payment Due": 1.0, "json_evaluator.CustomStringEvaluator.Salesperson": 1.0, "json_evaluator.FuzzStringEvaluator.Salesperson": 1.0, "json_evaluator.CustomStringEvaluator.Payment Terms": 1.0, "json_evaluator.FuzzStringEvaluator.Payment Terms": 1.0, "json_evaluator.CustomStringEvaluator.Shipping info.Recipient": 1.0, "json_evaluator.FuzzStringEvaluator.Shipping info.Recipient": 1.0, "json_evaluator.CustomStringEvaluator.Shipping info.Address": 1.0, "json_evaluator.FuzzStringEvaluator.Shipping info.Address": 1.0, "json_evaluator.CustomStringEvaluator.Shipping info.Phone": 1.0, "json_evaluator.FuzzStringEvaluator.Shipping info.Phone": 1.0, "json_evaluator.CustomStringEvaluator.Delivery Date": 1.0, "json_evaluator.FuzzStringEvaluator.Delivery Date": 1.0, "json_evaluator.CustomStringEvaluator.Shipping Method": 1.0, "json_evaluator.FuzzStringEvaluator.Shipping Method": 1.0, "json_evaluator.CustomStringEvaluator.Shipping Terms": 1.0, "json_evaluator.FuzzStringEvaluator.Shipping Terms": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[0].Qty": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[0].Qty": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[0].Item#": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[0].Item#": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[0].Description": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[0].Description": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[0].Unit price": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Items[0].Unit price": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[0].Discount": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[0].Discount": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[0].Line total": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Items[0].Line total": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[1].Qty": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[1].Qty": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[1].Item#": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[1].Item#": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[1].Description": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[1].Description": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[1].Discount": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[1].Discount": 0.0, "json_evaluator.CustomStringEvaluator.Table.Items[1].Unit price": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Items[1].Unit price": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[1].Line total": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Items[1].Line total": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[2].Qty": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[2].Qty": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[2].Item#": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[2].Item#": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[2].Description": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[2].Description": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[2].Discount": 1.0, "json_evaluator.FuzzStringEvaluator.Table.Items[2].Discount": 0.0, "json_evaluator.CustomStringEvaluator.Table.Items[2].Unit price": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Items[2].Unit price": 1.0, "json_evaluator.CustomStringEvaluator.Table.Items[2].Line total": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Items[2].Line total": 1.0, "json_evaluator.CustomStringEvaluator.Table.Total Discount": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Total Discount": 1.0, "json_evaluator.CustomStringEvaluator.Table.Subtotal": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Subtotal": 1.0, "json_evaluator.CustomStringEvaluator.Table.Sales Tax": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Sales Tax": 1.0, "json_evaluator.CustomStringEvaluator.Table.Total": 0.0, "json_evaluator.FuzzStringEvaluator.Table.Total": 1.0, "json_evaluator.CustomStringEvaluator.Footer.Customer Name": 1.0, "json_evaluator.FuzzStringEvaluator.Footer.Customer Name": 1.0, "json_evaluator.CustomStringEvaluator.Footer.Address": 1.0, "json_evaluator.FuzzStringEvaluator.Footer.Address": 1.0, "json_evaluator.CustomStringEvaluator.Footer.Website": 0.0, "json_evaluator.FuzzStringEvaluator.Footer.Website": 1.0, "json_evaluator.CustomStringEvaluator.Footer.Phone number": 0.0, "json_evaluator.FuzzStringEvaluator.Footer.Phone number": 1.0, "json_evaluator.CustomStringEvaluator.Footer.Fax number": 0.0, "json_evaluator.FuzzStringEvaluator.Footer.Fax number": 1.0, "json_evaluator.CustomStringEvaluator.Footer.Email": 1.0, "json_evaluator.FuzzStringEvaluator.Footer.Email": 1.0, "json_evaluator.CustomStringEvaluator.ratio": 0.6818181818, "json_evaluator.FuzzStringEvaluator.ratio": 0.9393181818}, "studio_url": null}
--------------------------------------------------------------------------------
/notebooks/requirements.txt:
--------------------------------------------------------------------------------
1 | # DO NOT include azure-functions-worker in this file
2 | # The Python Worker is managed by Azure Functions platform
3 | # Manually managing azure-functions-worker may cause unexpected issues
4 |
5 | azure-functions
6 | openai
7 | python-dotenv
8 | pillow
9 | requests_html
10 | azure-cosmos
11 | python-dotenv
12 | azure-ai-documentintelligence
13 | azure-identity
14 | PyMuPDF
15 | langchain
16 | langchain_core
17 | langchain_community
18 | langchain_openai
19 | tiktoken
20 | python-multipart
21 | promptflow-evals
22 | jsonpath-ng
23 | thefuzz
24 | azure-ai-formrecognizer
25 | seaborn
26 |
--------------------------------------------------------------------------------
/sample-invoice.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/sample-invoice.pdf
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/src/__init__.py
--------------------------------------------------------------------------------
/src/containerapp/Dockerfile:
--------------------------------------------------------------------------------
1 | # Multi-stage build for production Container App
2 | FROM python:3.11-slim as builder
3 |
4 | # Set environment variables
5 | ENV PYTHONDONTWRITEBYTECODE=1 \
6 | PYTHONUNBUFFERED=1 \
7 | PIP_NO_CACHE_DIR=1 \
8 | PIP_DISABLE_PIP_VERSION_CHECK=1
9 |
10 | # Install system dependencies
11 | RUN apt-get update && apt-get install -y \
12 | gcc \
13 | g++ \
14 | libc6-dev \
15 | libffi-dev \
16 | && rm -rf /var/lib/apt/lists/*
17 |
18 | # Create and activate virtual environment
19 | RUN python -m venv /opt/venv
20 | ENV PATH="/opt/venv/bin:$PATH"
21 |
22 | # Copy requirements and install Python dependencies
23 | COPY requirements.txt .
24 | RUN pip install --no-cache-dir -r requirements.txt
25 |
26 | # Production stage
27 | FROM python:3.11-slim
28 |
29 | # Set environment variables
30 | ENV PYTHONDONTWRITEBYTECODE=1 \
31 | PYTHONUNBUFFERED=1 \
32 | PATH="/opt/venv/bin:$PATH"
33 |
34 | # Install runtime dependencies
35 | RUN apt-get update && apt-get install -y \
36 | curl \
37 | && rm -rf /var/lib/apt/lists/*
38 |
39 | # Copy virtual environment from builder stage
40 | COPY --from=builder /opt/venv /opt/venv
41 |
42 | # Create non-root user
43 | RUN groupadd -r appuser && useradd -r -g appuser appuser
44 |
45 | # Set working directory
46 | WORKDIR /app
47 |
48 | # Copy application code - modular structure
49 | COPY main.py .
50 | COPY models.py .
51 | COPY dependencies.py .
52 | COPY logic_app_manager.py .
53 | COPY blob_processing.py .
54 | COPY api_routes.py .
55 | COPY requirements.txt .
56 |
57 | # Copy the original AI OCR modules from the functionapp directory
58 | # This will be handled by the deployment script to copy the files first
59 | COPY ai_ocr ./ai_ocr
60 |
61 | # Copy example datasets for schema and prompt loading
62 | COPY example-datasets ./example-datasets
63 |
64 | # Change ownership to non-root user
65 | RUN chown -R appuser:appuser /app
66 | USER appuser
67 |
68 | # Expose port
69 | EXPOSE 8000
70 |
71 | # Health check
72 | HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
73 | CMD curl -f http://localhost:8000/health || exit 1
74 |
75 | # Run the application using the new modular structure
76 | CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
77 |
--------------------------------------------------------------------------------
/src/containerapp/REFACTORING_SUMMARY.md:
--------------------------------------------------------------------------------
1 | # ARGUS Backend Refactoring Summary
2 |
3 | ## Overview
4 | Successfully refactored the monolithic `main.py` file (1675 lines) into a modular architecture for better maintainability and organization.
5 |
6 | ## New Modular Structure
7 |
8 | ### 📄 `main.py` (139 lines)
9 | - **Purpose**: FastAPI application entry point
10 | - **Responsibilities**:
11 | - App initialization and lifespan management
12 | - Route registration and delegation
13 | - Health check endpoints
14 | - **Key Features**: Clean separation of concerns, all routes delegate to api_routes module
15 |
16 | ### 📄 `models.py` (40 lines)
17 | - **Purpose**: Data models and classes
18 | - **Contains**:
19 | - `EventGridEvent`: Event Grid event model
20 | - `BlobInputStream`: Mock blob input stream for processing interface
21 |
22 | ### 📄 `dependencies.py` (112 lines)
23 | - **Purpose**: Azure client management and global state
24 | - **Responsibilities**:
25 | - Azure service client initialization (Blob, Cosmos DB)
26 | - Logic App Manager initialization
27 | - Global thread pool and semaphore management
28 | - Startup/cleanup lifecycle management
29 | - **Key Functions**: `initialize_azure_clients()`, `cleanup_azure_clients()`, getter functions
30 |
31 | ### 📄 `logic_app_manager.py` (217 lines)
32 | - **Purpose**: Logic App concurrency management via Azure Management API
33 | - **Key Features**:
34 | - Get/update Logic App concurrency settings
35 | - Workflow definition inspection
36 | - Action-level concurrency control
37 | - Comprehensive error handling and validation
38 |
39 | ### 📄 `blob_processing.py` (407 lines)
40 | - **Purpose**: Document and blob processing logic
41 | - **Responsibilities**:
42 | - Blob input stream creation and processing
43 | - Document processing pipeline (OCR, GPT extraction, evaluation, summary)
44 | - Page range structure creation
45 | - Concurrency control and background task management
46 | - **Key Functions**: `process_blob_event()`, `process_blob()`, helper functions
47 |
48 | ### 📄 `api_routes.py` (635 lines)
49 | - **Purpose**: All FastAPI route handlers
50 | - **Route Categories**:
51 | - **Health**: `/`, `/health`
52 | - **Blob Processing**: `/api/blob-created`, `/api/process-blob`, `/api/process-file`
53 | - **Configuration**: `/api/configuration/*`
54 | - **Concurrency**: `/api/concurrency/*`, `/api/workflow-definition`
55 | - **OpenAI**: `/api/openai-settings`
56 | - **Chat**: `/api/chat`
57 |
58 | ## Backup Files
59 | - **`main_old.py`**: Original monolithic file (1675 lines) - kept for reference
60 |
61 | ## Benefits Achieved
62 |
63 | ### ✅ Maintainability
64 | - Each module has a single, clear responsibility
65 | - Easier to locate and modify specific functionality
66 | - Reduced cognitive load when working on specific features
67 |
68 | ### ✅ Testability
69 | - Individual modules can be tested in isolation
70 | - Cleaner dependency injection through dependency.py
71 | - Easier to mock dependencies for unit tests
72 |
73 | ### ✅ Scalability
74 | - New route handlers can be added to api_routes.py
75 | - New processing logic can be added to blob_processing.py
76 | - Easy to add new Azure service integrations through dependencies.py
77 |
78 | ### ✅ Code Organization
79 | - Related functionality is grouped together
80 | - Clear separation between:
81 | - Application setup (main.py)
82 | - Business logic (blob_processing.py)
83 | - API endpoints (api_routes.py)
84 | - Infrastructure (dependencies.py, logic_app_manager.py)
85 | - Data models (models.py)
86 |
87 | ## Docker Integration
88 | - **Updated Dockerfile** to copy all modular files
89 | - **Updated CMD** to use the new main.py
90 | - All routes and functionality preserved
91 |
92 | ## Import Management
93 | - Fixed relative imports to work both as modules and standalone scripts
94 | - All imports now use absolute imports for better compatibility
95 | - No breaking changes to the API interface
96 |
97 | ## Validation
98 | - ✅ All 20 API routes preserved and functional
99 | - ✅ Import system working correctly
100 | - ✅ FastAPI app initialization successful
101 | - ✅ Docker configuration updated
102 |
103 | ## Next Steps
104 | 1. **Testing**: Run comprehensive tests to ensure all endpoints work as before
105 | 2. **Documentation**: Update API documentation if needed
106 | 3. **Monitoring**: Verify logging and monitoring continues to work
107 | 4. **Deployment**: Test the containerized application
108 | 5. **Cleanup**: Remove `main_old.py` after confirming everything works
109 |
110 | ## File Line Count Comparison
111 | - **Before**: 1 file (1675 lines)
112 | - **After**: 6 files (139 + 40 + 112 + 217 + 407 + 635 = 1550 lines)
113 | - **Reduction**: ~125 lines (removal of duplicate imports and better organization)
114 |
115 | The refactoring maintains 100% API compatibility while providing a much more maintainable and organized codebase.
116 |
--------------------------------------------------------------------------------
/src/containerapp/ai_ocr/azure/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 |
4 | from dotenv import load_dotenv
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 | def get_config(cosmos_config_container=None):
9 | """
10 | Get configuration from environment variables only.
11 |
12 | Note: cosmos_config_container parameter is kept for backwards compatibility
13 | but is ignored. Configuration is now sourced exclusively from environment variables.
14 | """
15 | load_dotenv()
16 |
17 | # Configuration from environment variables only
18 | config = {
19 | "doc_intelligence_endpoint": os.getenv("DOCUMENT_INTELLIGENCE_ENDPOINT", None),
20 | "openai_api_key": os.getenv("AZURE_OPENAI_KEY", None),
21 | "openai_api_endpoint": os.getenv("AZURE_OPENAI_ENDPOINT", None),
22 | "openai_api_version": "2024-12-01-preview",
23 | "openai_model_deployment": os.getenv("AZURE_OPENAI_MODEL_DEPLOYMENT_NAME", None),
24 | "temp_images_outdir": os.getenv("TEMP_IMAGES_OUTDIR", "/tmp/")
25 | }
26 |
27 | # Log which values are configured (without exposing secrets)
28 | logger.info("Using OpenAI configuration from environment variables")
29 | logger.info(f"OpenAI endpoint: {'✓ Set' if config['openai_api_endpoint'] else '✗ Missing'}")
30 | logger.info(f"OpenAI API key: {'✓ Set' if config['openai_api_key'] else '✗ Missing'}")
31 | logger.info(f"OpenAI deployment: {'✓ Set' if config['openai_model_deployment'] else '✗ Missing'}")
32 |
33 | return config
34 |
--------------------------------------------------------------------------------
/src/containerapp/ai_ocr/azure/doc_intelligence.py:
--------------------------------------------------------------------------------
1 | import json
2 | import pandas as pd
3 | from azure.identity import DefaultAzureCredential
4 | from azure.ai.documentintelligence import DocumentIntelligenceClient
5 | from azure.ai.documentintelligence.models import DocumentAnalysisFeature
6 | from ai_ocr.azure.config import get_config
7 |
8 |
9 | def get_document_intelligence_client(cosmos_config_container=None):
10 | """Create a new Document Intelligence client instance for each request to avoid connection pooling issues"""
11 | config = get_config(cosmos_config_container)
12 | return DocumentIntelligenceClient(
13 | endpoint=config["doc_intelligence_endpoint"],
14 | credential=DefaultAzureCredential(),
15 | headers={"solution":"ARGUS-1.0"}
16 | )
17 |
18 | def get_ocr_results(file_path: str, cosmos_config_container=None):
19 | import threading
20 | import logging
21 |
22 | thread_id = threading.current_thread().ident
23 | logger = logging.getLogger(__name__)
24 |
25 | logger.info(f"[Thread-{thread_id}] Starting Document Intelligence OCR for: {file_path}")
26 |
27 | # Create a new client instance for this request to ensure parallel processing
28 | client = get_document_intelligence_client(cosmos_config_container)
29 |
30 | with open(file_path, "rb") as f:
31 | logger.info(f"[Thread-{thread_id}] Submitting document to Document Intelligence API")
32 | poller = client.begin_analyze_document("prebuilt-layout", body=f)
33 |
34 | logger.info(f"[Thread-{thread_id}] Waiting for Document Intelligence results...")
35 | ocr_result = poller.result().content
36 | logger.info(f"[Thread-{thread_id}] Document Intelligence OCR completed, {len(ocr_result)} characters")
37 |
38 | return ocr_result
39 |
40 |
--------------------------------------------------------------------------------
/src/containerapp/ai_ocr/azure/images.py:
--------------------------------------------------------------------------------
1 | import fitz # PyMuPDF
2 | from PIL import Image
3 | from pathlib import Path
4 | import io
5 | import os
6 | import tempfile
7 | import logging
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 | def convert_pdf_into_image(pdf_path):
12 | """
13 | Convert PDF pages to PNG images in a temporary directory.
14 | Returns the temporary directory path containing the images.
15 | Caller is responsible for cleaning up the temporary directory.
16 | """
17 | # Create a temporary directory for the images
18 | temp_dir = tempfile.mkdtemp(prefix="pdf_images_")
19 |
20 | # Open the PDF file
21 | pdf_document = None
22 | try:
23 | pdf_document = fitz.open(pdf_path)
24 |
25 | # Iterate through all the pages
26 | for page_num in range(len(pdf_document)):
27 | page = pdf_document.load_page(page_num)
28 |
29 | # Convert the page to an image
30 | pix = page.get_pixmap()
31 |
32 | # Convert the pixmap to bytes
33 | image_bytes = pix.tobytes("png")
34 |
35 | # Convert the image to a PIL Image object
36 | image = Image.open(io.BytesIO(image_bytes))
37 |
38 | # Define the output path in the temporary directory
39 | output_path = os.path.join(temp_dir, f"page_{page_num + 1}.png")
40 |
41 | # Save the image as a PNG file
42 | image.save(output_path, "PNG")
43 | logger.debug(f"Saved image: {output_path}")
44 |
45 | except Exception as e:
46 | logger.error(f"Error converting PDF to images: {e}")
47 | raise
48 | finally:
49 | # Ensure PDF document is properly closed
50 | if pdf_document:
51 | pdf_document.close()
52 |
53 | return temp_dir
54 |
--------------------------------------------------------------------------------
/src/containerapp/ai_ocr/azure/openai_ops.py:
--------------------------------------------------------------------------------
1 | import base64
2 |
3 | def load_image(image_path) -> str:
4 | """Load image from file and encode it as base64."""
5 | with open(image_path, "rb") as image_file:
6 | return base64.b64encode(image_file.read()).decode('utf-8')
7 |
8 |
9 | def get_size_of_base64_images(images):
10 | total_size = 0
11 | for img in images:
12 | total_size += len(img)
13 | return total_size
14 |
--------------------------------------------------------------------------------
/src/containerapp/ai_ocr/model.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class Config(BaseModel):
5 | max_images: int = 10
6 | gpt_vision_limit_mb: int = 20
7 |
--------------------------------------------------------------------------------
/src/containerapp/ai_ocr/timeout.py:
--------------------------------------------------------------------------------
1 | import signal
2 |
3 | class TimeoutException(Exception):
4 | pass
5 |
6 | def timeout_handler(signum, frame):
7 | raise TimeoutException
8 |
9 | class timeout:
10 | def __init__(self, seconds):
11 | self.seconds = seconds
12 |
13 | def __enter__(self):
14 | signal.signal(signal.SIGALRM, timeout_handler)
15 | signal.alarm(self.seconds)
16 |
17 | def __exit__(self, type, value, traceback):
18 | signal.alarm(0)
--------------------------------------------------------------------------------
/src/containerapp/datasets/default-dataset/demo.docx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/src/containerapp/datasets/default-dataset/demo.docx
--------------------------------------------------------------------------------
/src/containerapp/dependencies.py:
--------------------------------------------------------------------------------
1 | """
2 | Azure client dependencies and global state management
3 | """
4 | import asyncio
5 | import logging
6 | import os
7 | from concurrent.futures import ThreadPoolExecutor
8 | from azure.storage.blob import BlobServiceClient
9 | from azure.identity import DefaultAzureCredential
10 |
11 | # Import your existing processing functions
12 | import sys
13 | sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'functionapp'))
14 | from ai_ocr.process import connect_to_cosmos
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 | # Azure credentials
19 | credential = DefaultAzureCredential()
20 |
21 | # Global variables for Azure clients
22 | blob_service_client = None
23 | data_container = None
24 | conf_container = None
25 | logic_app_manager = None
26 |
27 | # Global thread pool executor for parallel processing
28 | global_executor = None
29 |
30 | # Global semaphore for concurrency control based on Logic App settings
31 | global_processing_semaphore = None
32 |
33 |
34 | async def initialize_azure_clients():
35 | """Initialize Azure clients on startup"""
36 | global blob_service_client, data_container, conf_container, global_executor, logic_app_manager, global_processing_semaphore
37 |
38 | try:
39 | # Initialize global thread pool executor
40 | global_executor = ThreadPoolExecutor(max_workers=10)
41 | logger.info("Initialized global ThreadPoolExecutor with 10 workers")
42 |
43 | # Initialize processing semaphore with default concurrency of 5
44 | # This will be updated when Logic App concurrency settings are retrieved
45 | global_processing_semaphore = asyncio.Semaphore(5)
46 | logger.info("Initialized global processing semaphore with 5 permits")
47 |
48 | # Initialize Logic App Manager
49 | from logic_app_manager import LogicAppManager
50 | logic_app_manager = LogicAppManager()
51 |
52 | # Try to get current Logic App concurrency to set proper semaphore value
53 | if logic_app_manager.enabled:
54 | try:
55 | settings = await logic_app_manager.get_concurrency_settings()
56 | if settings.get('enabled'):
57 | max_runs = settings.get('current_max_runs', 1)
58 | global_processing_semaphore = asyncio.Semaphore(max_runs)
59 | logger.info(f"Updated processing semaphore to {max_runs} permits based on Logic App settings")
60 | except Exception as e:
61 | logger.warning(f"Could not retrieve Logic App concurrency settings on startup: {e}")
62 |
63 | # Initialize blob service client
64 | storage_account_url = os.getenv('BLOB_ACCOUNT_URL')
65 | if not storage_account_url:
66 | storage_account_name = os.getenv('AZURE_STORAGE_ACCOUNT_NAME')
67 | if storage_account_name:
68 | storage_account_url = f"https://{storage_account_name}.blob.core.windows.net"
69 | else:
70 | raise ValueError("Either BLOB_ACCOUNT_URL or AZURE_STORAGE_ACCOUNT_NAME must be set")
71 |
72 | blob_service_client = BlobServiceClient(
73 | account_url=storage_account_url,
74 | credential=credential
75 | )
76 |
77 | # Initialize Cosmos DB containers
78 | data_container, conf_container = connect_to_cosmos()
79 |
80 | logger.info("Successfully initialized Azure clients")
81 |
82 | except Exception as e:
83 | logger.error(f"Failed to initialize Azure clients: {e}")
84 | raise
85 |
86 |
87 | async def cleanup_azure_clients():
88 | """Cleanup Azure clients on shutdown"""
89 | global global_executor
90 |
91 | if global_executor:
92 | logger.info("Shutting down global ThreadPoolExecutor")
93 | global_executor.shutdown(wait=True)
94 | logger.info("Shutting down application")
95 |
96 |
97 | def get_blob_service_client():
98 | """Get the global blob service client"""
99 | return blob_service_client
100 |
101 |
102 | def get_data_container():
103 | """Get the global data container"""
104 | return data_container
105 |
106 |
107 | def get_conf_container():
108 | """Get the global configuration container"""
109 | return conf_container
110 |
111 |
112 | def get_logic_app_manager():
113 | """Get the global logic app manager"""
114 | return logic_app_manager
115 |
116 |
117 | def get_global_executor():
118 | """Get the global thread pool executor"""
119 | return global_executor
120 |
121 |
122 | def get_global_processing_semaphore():
123 | """Get the global processing semaphore"""
124 | return global_processing_semaphore
125 |
126 |
127 | def set_global_processing_semaphore(semaphore):
128 | """Set the global processing semaphore"""
129 | global global_processing_semaphore
130 | global_processing_semaphore = semaphore
131 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/src/containerapp/evaluators/__init__.py
--------------------------------------------------------------------------------
/src/containerapp/evaluators/cosine_similarity_string_evaluator.py:
--------------------------------------------------------------------------------
1 | class CosineSimilarityStringEvaluator:
2 |
3 | def __call__(self, ground_truth: str, actual: str, config: dict = {}):
4 | raise "Not implemented"
5 |
6 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/custom_string_evaluator.py:
--------------------------------------------------------------------------------
1 | from src.evaluators.field_evaluator_base import FieldEvaluatorBase
2 |
3 | class CustomStringEvaluator(FieldEvaluatorBase):
4 |
5 | class Config:
6 | IGNORE_DOLLAR_SIGN = "IGNORE_DOLLAR_SIGN"
7 | ADDITIONAL_MATCHES = "ADDITIONAL_MATCHES"
8 | IGNORE_DOTS = "IGNORE_DOTS"
9 | IGNORE_COMMAS = "IGNORE_COMMAS"
10 | IGNORE_PARENTHETHES = "IGNORE_PARENTHETHES"
11 | IGNORE_DASHES = "IGNORE_DASHES"
12 |
13 | def __init__(self, default_config = {}) -> None:
14 | self.default_config = default_config
15 |
16 | def __call__(self, ground_truth: str, actual: str, config: dict = None):
17 | if not config:
18 | config = self.default_config
19 |
20 | actual_processed = str(actual).lower()
21 | ground_truth_processed = str(ground_truth).lower()
22 |
23 | if config.get(self.Config.IGNORE_DOTS, False):
24 | actual_processed = actual_processed.replace('.', '')
25 | ground_truth_processed = ground_truth_processed.replace('.', '')
26 |
27 | if config.get(self.Config.IGNORE_COMMAS, False):
28 | actual_processed = actual_processed.replace(',', '')
29 | ground_truth_processed = ground_truth_processed.replace(',', '')
30 |
31 | if config.get(self.Config.IGNORE_DASHES, False):
32 | actual_processed = actual_processed.replace('-', '')
33 | ground_truth_processed = ground_truth_processed.replace('-', '')
34 |
35 | if config.get(self.Config.IGNORE_PARENTHETHES, False):
36 | actual_processed = actual_processed.replace('(', '')
37 | ground_truth_processed = ground_truth_processed.replace('(', '')
38 | actual_processed = actual_processed.replace(')', '')
39 | ground_truth_processed = ground_truth_processed.replace(')', '')
40 |
41 | if config.get(self.Config.IGNORE_DOLLAR_SIGN, False):
42 | # Remove leading dollar signs from both strings
43 | ground_truth_processed = ground_truth_processed.lstrip("$")
44 | actual_processed = actual_processed.lstrip("$")
45 |
46 | additional_matches = config.get(
47 | self.Config.ADDITIONAL_MATCHES, []
48 | )
49 | additional_matches.append(ground_truth_processed)
50 |
51 | if actual_processed in additional_matches:
52 | return 1
53 |
54 | return 0
55 |
56 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/field_evaluator_base.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | class FieldEvaluatorBase(ABC):
4 |
5 | @abstractmethod
6 | def __call__(self, ground_truth: str, actual: str, config: dict = {}) -> int:
7 | raise NotImplementedError
8 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/fuzz_string_evaluator.py:
--------------------------------------------------------------------------------
1 | from thefuzz import fuzz
2 |
3 | class FuzzStringEvaluator:
4 |
5 | def __call__(self, ground_truth: str, actual: str, config: dict = {}):
6 | return fuzz.partial_token_set_ratio(ground_truth,actual)/100.0
7 |
8 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/json_evaluator.py:
--------------------------------------------------------------------------------
1 | from src.evaluators.custom_string_evaluator import CustomStringEvaluator
2 | from src.evaluators.fuzz_string_evaluator import FuzzStringEvaluator
3 |
4 |
5 | class JsonEvaluator:
6 |
7 | class FieldEvaluatorWrapper:
8 | def __init__(self, evaluator_instance):
9 | self.name = evaluator_instance.__class__.__name__
10 | self.instance = evaluator_instance
11 | self.total_strings_compared = 0
12 | self.total_score = 0
13 |
14 | def calculate_ratio(self):
15 | return (
16 | self.total_score / self.total_strings_compared
17 | if self.total_strings_compared > 0
18 | else 0
19 | )
20 |
21 | def __init__(
22 | self,
23 | field_evaluators: list = [CustomStringEvaluator(), FuzzStringEvaluator()],
24 | ):
25 | self.eval_wrappers = []
26 | for evaluator in field_evaluators:
27 | self.eval_wrappers.append(self.FieldEvaluatorWrapper(evaluator))
28 |
29 | self.result = {}
30 |
31 | def __call__(self, ground_truth, actual, eval_schema={}):
32 | self.compare_values(ground_truth, actual, eval_schema, None)
33 | for wrapper in self.eval_wrappers:
34 | self.result[f"{wrapper.name}.ratio"] = (
35 | wrapper.calculate_ratio()
36 | )
37 |
38 | return self.result
39 |
40 | def compare_values(self, ground_truth, actual, eval_schema, curr_key):
41 | if isinstance(ground_truth, dict):
42 | return self.compare_dicts(ground_truth, actual, eval_schema, curr_key)
43 | elif isinstance(ground_truth, list):
44 | return self.compare_lists(ground_truth, actual, eval_schema, curr_key)
45 | else:
46 | for wrapper in self.eval_wrappers:
47 | if actual is None:
48 | score = 0
49 | else:
50 | score = wrapper.instance(
51 | ground_truth,
52 | actual,
53 | eval_schema.get(wrapper.name, None),
54 | )
55 | wrapper.total_strings_compared += 1
56 | self.result[f"{wrapper.name}.{curr_key}"] = score
57 | wrapper.total_score += score
58 |
59 | def compare_dicts(self, ground_truth_dict, actual_dict, eval_schema, curr_key=None):
60 | for key in ground_truth_dict:
61 | # handle defaults if is None
62 | next_key = f"{curr_key}.{key}" if curr_key is not None else key
63 | actual = actual_dict.get(key, None) if actual_dict is not None else None
64 | curr_eval_schema = eval_schema.get(key, {}) if eval_schema is not None else {}
65 |
66 | self.compare_values(
67 | ground_truth_dict[key],
68 | actual,
69 | curr_eval_schema,
70 | next_key,
71 | )
72 |
73 | def compare_lists(self, ground_truth_list, actual_list, eval_schema, curr_key):
74 | for i in range(len(ground_truth_list)):
75 | # handle defaults if is None
76 | next_key = f"{curr_key}[{i}]" if curr_key is not None else f"[{i}]"
77 | try:
78 | actual = actual_list[i]
79 | except Exception:
80 | actual = None
81 | try:
82 | curr_eval_schema = eval_schema[i]
83 | except Exception:
84 | curr_eval_schema = {}
85 |
86 | self.compare_values(
87 | ground_truth_list[i],
88 | actual,
89 | curr_eval_schema,
90 | next_key,
91 | )
92 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/src/containerapp/evaluators/tests/__init__.py
--------------------------------------------------------------------------------
/src/containerapp/evaluators/tests/test_custom_string_evaluator.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from src.evaluators.custom_string_evaluator import CustomStringEvaluator
4 |
5 |
6 | class TestCustomStringEvaluator(unittest.TestCase):
7 |
8 | def test_string_evaluator_exact_match(
9 | self
10 | ):
11 | evaluator = CustomStringEvaluator()
12 | exact_match = evaluator("value", "value")
13 | no_match = evaluator("value", "not_value")
14 | assert exact_match == True
15 | assert no_match == False
16 |
17 | def test_string_evaluator_commas_ignored(
18 | self
19 | ):
20 | evaluator = CustomStringEvaluator()
21 | match_1 = evaluator("value", "va,lue",config={CustomStringEvaluator.Config.IGNORE_COMMAS: True})
22 | assert match_1 == True
23 |
24 |
25 | def test_string_evaluator_commas_not_ignored(
26 | self
27 | ):
28 | evaluator = CustomStringEvaluator()
29 | match_1 = evaluator("value", "value", config={CustomStringEvaluator.Config.IGNORE_COMMAS: False})
30 | match_2 = evaluator("value", "va,lue", config={CustomStringEvaluator.Config.IGNORE_COMMAS: False})
31 | assert match_1 == True
32 | assert match_2 == False
33 |
34 |
35 | def test_string_evaluator_dots_ignored(
36 | self
37 | ):
38 | evaluator = CustomStringEvaluator()
39 | match_1 = evaluator("value", "va.lue",config={CustomStringEvaluator.Config.IGNORE_DOTS: True})
40 | assert match_1 == True
41 |
42 |
43 | def test_string_evaluator_dots_not_ignored(
44 | self
45 | ):
46 | evaluator = CustomStringEvaluator()
47 | match_1 = evaluator("value", "value",config={CustomStringEvaluator.Config.IGNORE_DOTS: False})
48 | match_2 = evaluator("value", "va.lue",config={CustomStringEvaluator.Config.IGNORE_DOTS: False})
49 | assert match_1 == True
50 | assert match_2 == False
51 |
52 |
53 | def test_string_evaluator_dollar_sign_ignored(
54 | self
55 | ):
56 | evaluator = CustomStringEvaluator()
57 | match_1 = evaluator("$10", "10",config={CustomStringEvaluator.Config.IGNORE_DOLLAR_SIGN: True})
58 | assert match_1 == True
59 |
60 |
61 | def test_string_evaluator_dollar_sign_not_ignored(
62 | self
63 | ):
64 | evaluator = CustomStringEvaluator()
65 | match_1 = evaluator("$10", "10",config={CustomStringEvaluator.Config.IGNORE_DOLLAR_SIGN: False})
66 | assert match_1 == False
67 |
68 |
69 |
70 | def test_string_evaluator_parenthesis_ignored(
71 | self
72 | ):
73 | evaluator = CustomStringEvaluator()
74 | match_1 = evaluator("(256)3300488", "2563300488",config={CustomStringEvaluator.Config.IGNORE_PARENTHETHES: True})
75 | assert match_1 == True
76 |
77 |
78 | def test_string_evaluator_parenthesis_not_ignored(
79 | self
80 | ):
81 | evaluator = CustomStringEvaluator()
82 | match_1 = evaluator("(256)3300488", "2563300488",config={CustomStringEvaluator.Config.IGNORE_PARENTHETHES: False})
83 | assert match_1 == False
84 |
85 | def test_string_evaluator_dashes_ignored(
86 | self
87 | ):
88 | evaluator = CustomStringEvaluator()
89 | match_1 = evaluator("(256)330-0488", "(256)3300488",config={CustomStringEvaluator.Config.IGNORE_DASHES: True})
90 | assert match_1 == True
91 |
92 |
93 | def test_string_evaluator_dashes_not_ignored(
94 | self
95 | ):
96 | evaluator = CustomStringEvaluator()
97 | match_1 = evaluator("(256)3300-488", "(256)3300488",config={CustomStringEvaluator.Config.IGNORE_DASHES: False})
98 | assert match_1 == False
99 |
100 | def test_string_evaluator_additional_matches(
101 | self
102 | ):
103 | evaluator = CustomStringEvaluator()
104 | match_1 = evaluator("correct", "correct",config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
105 | match_2 = evaluator("correct", "yes", config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
106 | match_3 = evaluator("correct", "true", config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
107 | match_4 = evaluator("correct", "false", config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
108 | assert match_1 == True
109 | assert match_2 == True
110 | assert match_3 == True
111 | assert match_4 == False
112 |
--------------------------------------------------------------------------------
/src/containerapp/evaluators/tests/test_json_evaluator.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from src.evaluators.custom_string_evaluator import CustomStringEvaluator
4 | from src.evaluators.fuzz_string_evaluator import FuzzStringEvaluator
5 | from src.evaluators.json_evaluator import JsonEvaluator
6 |
7 |
8 | class TestJsonEvaluator(unittest.TestCase):
9 |
10 | def test_json_evaluator_no_eval_schema(self):
11 | ground_truth_data = {
12 | "key1": "value1", # value 1
13 | "key2": {
14 | "key1": "value2", # value 2
15 | "key2": {"key1": "value3"}, # value 3
16 | "key3": ["value4", "value5"], # Values 4 and 5
17 | "key4": {
18 | "key1": [{"key1": "value6", "key2": "value7"}] # value 6 # value 7
19 | },
20 | "key5": "value8", # value 8
21 | },
22 | "key3": "value9", # value 9
23 | "key4": "value10", # value 10
24 | }
25 | # Total values = 10
26 |
27 | actual_data = {
28 | "key1": "wrong_value", # wrong 1 - Should be "value1"
29 | "key2": {
30 | "key1": "value2", # correct 1 - this should be marked correct as the ground truth int will be made a str in the string evaluator
31 | "key2": {
32 | "key1": "value,3" # wrong 2 - should be "5.0" - puctuation is ignored when word does NOT contains a number
33 | },
34 | "key3": ["value4", "value5"], # correct 2 # correct 3
35 | "key4": {
36 | "key1": [
37 | {"key1": "value6", "key2": "value7"} # correct 4 # correct 5
38 | ]
39 | },
40 | # key5 is missing
41 | },
42 | # key3 is missing
43 | "key4": "value10", # correct 6
44 | }
45 | # Total correct = 6
46 | # ratio = 6/10 = 0.6
47 |
48 | json_evaluator = JsonEvaluator()
49 | result = json_evaluator(ground_truth_data, actual_data)
50 | assert result["CustomStringEvaluator.ratio"] == 0.6
51 | assert result['FuzzStringEvaluator.ratio'] == 0.782
52 |
53 | def test_json_evaluator_with_eval_schema(self):
54 | ground_truth_data = {
55 | "key1": "value1", # value 1
56 | "key2": {
57 | "key1": "value2", # value 2
58 | "key2": {"key1": "value3"}, # value 3
59 | "key3": ["value4", "value5"], # Values 4 and 5
60 | "key4": {
61 | "key1": [{"key1": "value6", "key2": "value7"}] # value 6 # value 7
62 | },
63 | "key5": "value8", # value 8
64 | },
65 | "key3": "value9", # value 9
66 | "key4": "value10", # value 10
67 | }
68 | # Total values = 10
69 |
70 | actual_data = {
71 | "key1": "wrong_value", # wrong 1 - Should be "value1"
72 | "key2": {
73 | "key1": "value.2", # correct 1 - this should be marked correct as the ground truth int will be made a str in the string evaluator
74 | "key2": {"key1": "$value3"}, # correct 2
75 | "key3": ["value4", "value,5"], # correct 3
76 | "key4": {
77 | "key1": [
78 | {"key1": "value,6", "key2": "value7"} # correct 4 # correct 5
79 | ]
80 | },
81 | # key5 is missing
82 | },
83 | "key4": "value10", # correct 6
84 | # key2 is missing
85 | }
86 | # Total correct = 6
87 | # ratio = 6/10 = 0.6
88 |
89 | eval_schema = {
90 | "key1": {},
91 | "key2": {
92 | "key1": {"CustomStringEvaluator": {"IGNORE_DOTS": "True"}},
93 | "key2": {
94 | "key1": {"CustomStringEvaluator": {"IGNORE_DOLLAR_SIGN": "True"}}
95 | },
96 | "key3": {},
97 | "key4": {
98 | "key1": [
99 | {
100 | "key1": {
101 | "CustomStringEvaluator": {"IGNORE_COMMAS": "True"}
102 | },
103 | "key2": {},
104 | } # correct 4 # correct 5
105 | ]
106 | },
107 | "key5": {},
108 | },
109 | "key3": {},
110 | "key4": {},
111 | }
112 |
113 | json_evaluator = JsonEvaluator()
114 | result = json_evaluator(ground_truth_data, actual_data, eval_schema)
115 | assert result['FuzzStringEvaluator.ratio'] == 0.764
116 | assert result["CustomStringEvaluator.ratio"] == 0.6
117 |
118 | def test_json_evaluator_no_eval_schema_with_default_config(self):
119 | ground_truth_data = {
120 | "key1": "value1", # value 1
121 | "key2": {
122 | "key1": "value2", # value 2
123 | "key2": {"key1": "value3"}, # value 3
124 | "key3": ["value4", "value5"], # Values 4 and 5
125 | "key4": {
126 | "key1": [{"key1": "value6", "key2": "value7"}] # value 6 # value 7
127 | },
128 | "key5": "value8", # value 8
129 | },
130 | "key3": "value9", # value 9
131 | "key4": "value10", # value 10
132 | }
133 | # Total values = 10
134 |
135 | actual_data = {
136 | "key1": "wrong_value", # wrong 1 - Should be "value1"
137 | "key2": {
138 | "key1": "value.2", # correct 1 - this should be marked correct as the ground truth int will be made a str in the string evaluator
139 | "key2": {"key1": "$value3"}, # correct 2
140 | "key3": ["value4", "value,5"], # correct 3
141 | "key4": {
142 | "key1": [
143 | {"key1": "value,6", "key2": "value7"} # correct 4 # correct 5
144 | ]
145 | },
146 | # key5 is missing
147 | },
148 | "key4": "value10", # correct 6
149 | # key2 is missing
150 | }
151 | # Total correct = 6
152 | # ratio = 6/10 = 0.6
153 |
154 | evaluators = [
155 | CustomStringEvaluator({
156 | CustomStringEvaluator.Config.IGNORE_DOLLAR_SIGN: True,
157 | CustomStringEvaluator.Config.IGNORE_DASHES: True,
158 | CustomStringEvaluator.Config.IGNORE_DOTS: True,
159 | }),
160 | FuzzStringEvaluator(),
161 | ]
162 |
163 | # Total correct = 5
164 | # ratio = 5/10 = 0.5
165 |
166 | json_evaluator = JsonEvaluator(evaluators)
167 | result = json_evaluator(ground_truth_data, actual_data)
168 | assert result["CustomStringEvaluator.ratio"] == 0.5
169 | assert result['FuzzStringEvaluator.ratio'] == 0.764
170 |
171 | def test_json_evaluator_different_array_length_in_actual(self):
172 | ground_truth_data = {
173 | "key1": "value1", # value 1
174 | "key2": ["test1", "test2", "test3"], # Values 2, 3, 4
175 | }
176 | # Total values = 4
177 |
178 | actual_data = {
179 | "key1": "value1", # correct 1
180 | "key2": ["test1"], # correct 2, wrong 1, wrong 2 (missing index 1, 2)
181 | }
182 |
183 | evaluators = [CustomStringEvaluator()]
184 |
185 | # Total correct = 2
186 | # ratio = 2/4 = 0.5
187 |
188 | json_evaluator = JsonEvaluator(evaluators)
189 | result = json_evaluator(ground_truth_data, actual_data)
190 | assert result["CustomStringEvaluator.ratio"] == 0.5
191 | assert result['CustomStringEvaluator.key1'] == 1
192 | assert result['CustomStringEvaluator.key2[0]'] == 1
193 | assert result['CustomStringEvaluator.key2[1]'] == 0
194 | assert result['CustomStringEvaluator.key2[2]'] == 0
195 |
196 | def test_json_evaluator_handles_array_first_value(self):
197 | ground_truth_data = [
198 | {"key1": "value1"}, # value 1
199 | {"key2": ["1", "2", "3"]},
200 | "array_value_3"
201 | ]
202 | # Total values = 5
203 |
204 | actual_data = [
205 | {"key1": "value1"}, # correct 1
206 | {"key2": ["1", "wrong", "3"]}, # correct 2, wrong 1, correct 3
207 | "array_value_3" # correct 4
208 | ]
209 |
210 | # Total correct = 4
211 | # ratio = 4/5 = 0.8
212 |
213 | evaluators = [CustomStringEvaluator()]
214 |
215 | json_evaluator = JsonEvaluator(evaluators)
216 | result = json_evaluator(ground_truth_data, actual_data)
217 | assert result["CustomStringEvaluator.ratio"] == 0.8
218 | assert result['CustomStringEvaluator.[0].key1'] == 1
219 | assert result['CustomStringEvaluator.[1].key2[0]'] == 1
220 | assert result['CustomStringEvaluator.[1].key2[1]'] == 0
221 | assert result['CustomStringEvaluator.[1].key2[2]'] == 1
222 | assert result['CustomStringEvaluator.[2]'] == 1
223 |
224 | def test_json_evaluator_handles_array_dict_mismatch(self):
225 | ground_truth_data = [
226 | {"key1": "value1"}, # value 1
227 | {"key2": ["1", "2", "3"]},
228 | "array_value_3"
229 | ]
230 | # Total values = 5
231 |
232 | # all values should be wrong, as this is a dict and not an array
233 | actual_data = {
234 | "key1": "value1",
235 | "key2": ["1", "wrong", "3"],
236 | }
237 |
238 | # Total correct = 0
239 | # ratio = 0/5 = 0
240 |
241 | evaluators = [CustomStringEvaluator()]
242 |
243 | json_evaluator = JsonEvaluator(evaluators)
244 | result = json_evaluator(ground_truth_data, actual_data)
245 | assert result["CustomStringEvaluator.ratio"] == 0
246 | assert result['CustomStringEvaluator.[0].key1'] == 0
247 | assert result['CustomStringEvaluator.[1].key2[0]'] == 0
248 | assert result['CustomStringEvaluator.[1].key2[1]'] == 0
249 | assert result['CustomStringEvaluator.[1].key2[2]'] == 0
250 | assert result['CustomStringEvaluator.[2]'] == 0
--------------------------------------------------------------------------------
/src/containerapp/example-datasets/default-dataset/output_schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "Customer Name": "",
3 | "Invoice Number": "",
4 | "Date": "",
5 | "Billing info": {
6 | "Customer": "",
7 | "Customer ID": "",
8 | "Address": "",
9 | "Phone": ""
10 | },
11 | "Payment Due": "",
12 | "Salesperson": "",
13 | "Payment Terms": "",
14 | "Shipping info": {
15 | "Recipient": "",
16 | "Address": "",
17 | "Phone": ""
18 | },
19 | "Delivery Date": "",
20 | "Shipping Method": "",
21 | "Shipping Terms": "",
22 | "Table": {
23 | "Items": [
24 | {
25 | "Qty": "",
26 | "Item#": "",
27 | "Description": "",
28 | "Unit price": "",
29 | "Discount": "",
30 | "Line total": ""
31 | }
32 | ],
33 | "Total Discount": "",
34 | "Subtotal": "",
35 | "Sales Tax": "",
36 | "Total": ""
37 | },
38 | "Footer": {
39 | "Customer Name": "",
40 | "Address": "",
41 | "Website": "",
42 | "Phone number": "",
43 | "Fax number": "",
44 | "Email": ""
45 | }
46 | }
--------------------------------------------------------------------------------
/src/containerapp/example-datasets/default-dataset/system_prompt.txt:
--------------------------------------------------------------------------------
1 | Extract all data from the document in a comprehensive and structured manner.
2 |
3 | Focus on:
4 | - Key identifiers (invoice numbers, reference numbers, IDs)
5 | - Financial information (amounts, totals, currency, taxes)
6 | - Parties involved (vendors, customers, suppliers, recipients)
7 | - Dates and timelines (invoice dates, due dates, service periods)
8 | - Line items and details (products, services, quantities, prices)
9 | - Contact information (addresses, phone numbers, emails)
10 | - Any other relevant structured data visible in the document
11 |
12 | When both text and images are available, use the text as the primary source and cross-reference with images for accuracy. When only images are available, extract all visible information directly from the visual content.
--------------------------------------------------------------------------------
/src/containerapp/example-datasets/medical-dataset/output_schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "id" : "medical_report",
3 | "categorization" : "",
4 | "title": "Medical Report",
5 | "type": "object",
6 | "properties": {
7 | "doctor": {
8 | "type": "object",
9 | "properties": {
10 | "specialty": { "type": "string" },
11 | "name": { "type": "string" },
12 | "clinic": { "type": "string" },
13 | "phone": { "type": "string" },
14 | "fax": { "type": "string" }
15 | }
16 | },
17 | "patient": {
18 | "type": "object",
19 | "properties": {
20 | "name": { "type": "string" }
21 | }
22 | },
23 | "post_surgery_follow_up": {
24 | "type": "array",
25 | "items": {
26 | "type": "object",
27 | "properties": {
28 | "period": { "type": "string" },
29 | "date": { "type": "string", "format": "date" },
30 | "ODv": { "type": "string" },
31 | "ODT": { "type": "string" },
32 | "OSv": { "type": "string" },
33 | "OST": { "type": "string" },
34 | "therapy": { "type": "string" }
35 | }
36 | }
37 | },
38 | "pre_surgery_evaluation": {
39 | "type": "object",
40 | "properties": {
41 | "anamnesis_data": { "type": "string" },
42 | "night_glare": { "type": "string" },
43 | "contact_lens_tolerance": { "type": "string" },
44 | "medications": { "type": "string" },
45 | "ocular_dryness": { "type": "string" },
46 | "collagen_disorders": { "type": "string" },
47 | "diabetes": { "type": "string" },
48 | "autorefractometry": {
49 | "type": "object",
50 | "properties": {
51 | "OD": { "type": "string" },
52 | "OS": { "type": "string" }
53 | }
54 | },
55 | "visual_acuity": {
56 | "type": "object",
57 | "properties": {
58 | "OD": { "type": "string" },
59 | "OS": { "type": "string" }
60 | }
61 | },
62 | "corneal_map": { "type": "string" },
63 | "schirmer_tear_test": { "type": "string" },
64 | "pupilometry": { "type": "string" },
65 | "pachymetry": {
66 | "type": "object",
67 | "properties": {
68 | "OD": { "type": "string" },
69 | "OS": { "type": "string" }
70 | }
71 | },
72 | "cornea": { "type": "string" },
73 | "crystalline_lens": { "type": "string" },
74 | "fundus": { "type": "string" },
75 | "tonometry": { "type": "string" },
76 | "eyelid_conjunctiva_anomalies": { "type": "string" }
77 | }
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/containerapp/example-datasets/medical-dataset/system_prompt.txt:
--------------------------------------------------------------------------------
1 | Extract information about patients, medical conditions, treatments, analysis or appointments/visits they made at hospitals, doctors or laboratories, payments of invoices or purchases of medicaments.
2 | On the field 'categorization' choose one of these: 1) 'invoice' 2) 'medical_report' based on your classification.
3 | If you cannot determine that the content belongs to one of these categories then apply a classification 'N/A'.
4 |
--------------------------------------------------------------------------------
/src/containerapp/logic_app_manager.py:
--------------------------------------------------------------------------------
1 | """
2 | Logic App Manager for Azure Logic App concurrency management
3 | """
4 | import logging
5 | import os
6 | from datetime import datetime
7 | from typing import Dict, Any
8 | from azure.identity import DefaultAzureCredential
9 | from azure.mgmt.logic import LogicManagementClient
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | class LogicAppManager:
15 | """Manages Logic App concurrency settings via Azure Management API"""
16 |
17 | def __init__(self):
18 | self.credential = DefaultAzureCredential()
19 | self.subscription_id = os.getenv('AZURE_SUBSCRIPTION_ID')
20 | self.resource_group_name = os.getenv('AZURE_RESOURCE_GROUP_NAME')
21 | self.logic_app_name = os.getenv('LOGIC_APP_NAME')
22 |
23 | if not all([self.subscription_id, self.resource_group_name, self.logic_app_name]):
24 | logger.warning("Logic App management requires AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP_NAME, and LOGIC_APP_NAME environment variables")
25 | self.enabled = False
26 | else:
27 | self.enabled = True
28 | logger.info(f"Logic App Manager initialized for {self.logic_app_name} in {self.resource_group_name}")
29 |
30 | def get_logic_management_client(self):
31 | """Create a Logic Management client"""
32 | if not self.enabled:
33 | raise ValueError("Logic App Manager is not properly configured")
34 | return LogicManagementClient(self.credential, self.subscription_id)
35 |
36 | async def get_concurrency_settings(self) -> Dict[str, Any]:
37 | """Get current Logic App concurrency settings"""
38 | try:
39 | if not self.enabled:
40 | return {"error": "Logic App Manager not configured", "enabled": False}
41 |
42 | logic_client = self.get_logic_management_client()
43 |
44 | # Get the Logic App workflow
45 | workflow = logic_client.workflows.get(
46 | resource_group_name=self.resource_group_name,
47 | workflow_name=self.logic_app_name
48 | )
49 |
50 | # Extract concurrency settings from workflow definition
51 | definition = workflow.definition or {}
52 | triggers = definition.get('triggers', {})
53 |
54 | # Get concurrency from the first trigger (most common case)
55 | runs_on = 5 # Default value
56 | trigger_name = None
57 | for name, trigger_config in triggers.items():
58 | trigger_name = name
59 | runtime_config = trigger_config.get('runtimeConfiguration', {})
60 | concurrency = runtime_config.get('concurrency', {})
61 | runs_on = concurrency.get('runs', 5)
62 | break # Use the first trigger found
63 |
64 | return {
65 | "enabled": True,
66 | "logic_app_name": self.logic_app_name,
67 | "resource_group": self.resource_group_name,
68 | "current_max_runs": runs_on,
69 | "trigger_name": trigger_name,
70 | "workflow_state": workflow.state,
71 | "last_modified": workflow.changed_time.isoformat() if workflow.changed_time else None
72 | }
73 |
74 | except Exception as e:
75 | logger.error(f"Error getting Logic App concurrency settings: {e}")
76 | return {"error": str(e), "enabled": False}
77 |
78 | async def update_concurrency_settings(self, max_runs: int) -> Dict[str, Any]:
79 | """Update Logic App concurrency settings"""
80 | try:
81 | if not self.enabled:
82 | return {"error": "Logic App Manager not configured", "success": False}
83 |
84 | if max_runs < 1 or max_runs > 100:
85 | return {"error": "Max runs must be between 1 and 100", "success": False}
86 |
87 | logic_client = self.get_logic_management_client()
88 |
89 | # Get the current workflow
90 | current_workflow = logic_client.workflows.get(
91 | resource_group_name=self.resource_group_name,
92 | workflow_name=self.logic_app_name
93 | )
94 |
95 | # Update the workflow definition with new concurrency settings
96 | updated_definition = current_workflow.definition.copy() if current_workflow.definition else {}
97 |
98 | # Find the trigger and update its concurrency settings using runtimeConfiguration
99 | triggers = updated_definition.get('triggers', {})
100 | for trigger_name, trigger_config in triggers.items():
101 | # Set runtime configuration for concurrency control
102 | if 'runtimeConfiguration' not in trigger_config:
103 | trigger_config['runtimeConfiguration'] = {}
104 | if 'concurrency' not in trigger_config['runtimeConfiguration']:
105 | trigger_config['runtimeConfiguration']['concurrency'] = {}
106 | trigger_config['runtimeConfiguration']['concurrency']['runs'] = max_runs
107 | logger.info(f"Updated concurrency for trigger {trigger_name} to {max_runs}")
108 |
109 | # Create the workflow update request using the proper Workflow object
110 | from azure.mgmt.logic.models import Workflow
111 |
112 | workflow_update = Workflow(
113 | location=current_workflow.location,
114 | definition=updated_definition,
115 | state=current_workflow.state,
116 | parameters=current_workflow.parameters,
117 | tags=current_workflow.tags # Include tags to maintain existing metadata
118 | )
119 |
120 | # Update the workflow
121 | updated_workflow = logic_client.workflows.create_or_update(
122 | resource_group_name=self.resource_group_name,
123 | workflow_name=self.logic_app_name,
124 | workflow=workflow_update
125 | )
126 |
127 | logger.info(f"Successfully updated Logic App {self.logic_app_name} max concurrent runs to {max_runs}")
128 |
129 | return {
130 | "success": True,
131 | "logic_app_name": self.logic_app_name,
132 | "new_max_runs": max_runs,
133 | "updated_at": datetime.utcnow().isoformat()
134 | }
135 |
136 | except Exception as e:
137 | logger.error(f"Error updating Logic App concurrency settings: {e}")
138 | return {"error": str(e), "success": False}
139 |
140 | async def get_workflow_definition(self) -> Dict[str, Any]:
141 | """Get the complete Logic App workflow definition for inspection"""
142 | try:
143 | if not self.enabled:
144 | return {"error": "Logic App Manager not configured", "enabled": False}
145 |
146 | logic_client = self.get_logic_management_client()
147 |
148 | # Get the Logic App workflow
149 | workflow = logic_client.workflows.get(
150 | resource_group_name=self.resource_group_name,
151 | workflow_name=self.logic_app_name
152 | )
153 |
154 | return {
155 | "enabled": True,
156 | "logic_app_name": self.logic_app_name,
157 | "resource_group": self.resource_group_name,
158 | "workflow_state": workflow.state,
159 | "definition": workflow.definition,
160 | "last_modified": workflow.changed_time.isoformat() if workflow.changed_time else None
161 | }
162 |
163 | except Exception as e:
164 | logger.error(f"Error getting Logic App workflow definition: {e}")
165 | return {"error": str(e), "enabled": False}
166 |
167 | async def update_action_concurrency_settings(self, max_runs: int) -> Dict[str, Any]:
168 | """Update Logic App action-level concurrency settings for HTTP actions"""
169 | try:
170 | if not self.enabled:
171 | return {"error": "Logic App Manager not configured", "success": False}
172 |
173 | if max_runs < 1 or max_runs > 100:
174 | return {"error": "Max runs must be between 1 and 100", "success": False}
175 |
176 | logic_client = self.get_logic_management_client()
177 |
178 | # Get the current workflow
179 | current_workflow = logic_client.workflows.get(
180 | resource_group_name=self.resource_group_name,
181 | workflow_name=self.logic_app_name
182 | )
183 |
184 | # Update the workflow definition with new concurrency settings
185 | updated_definition = current_workflow.definition.copy() if current_workflow.definition else {}
186 |
187 | # Update trigger-level concurrency
188 | triggers = updated_definition.get('triggers', {})
189 | for trigger_name, trigger_config in triggers.items():
190 | if 'runtimeConfiguration' not in trigger_config:
191 | trigger_config['runtimeConfiguration'] = {}
192 | if 'concurrency' not in trigger_config['runtimeConfiguration']:
193 | trigger_config['runtimeConfiguration']['concurrency'] = {}
194 | trigger_config['runtimeConfiguration']['concurrency']['runs'] = max_runs
195 | logger.info(f"Updated trigger concurrency for {trigger_name} to {max_runs}")
196 |
197 | # Update action-level concurrency for HTTP actions and loops
198 | actions = updated_definition.get('actions', {})
199 | updated_actions = 0
200 |
201 | def update_action_concurrency(actions_dict):
202 | nonlocal updated_actions
203 | for action_name, action_config in actions_dict.items():
204 | # Set concurrency for HTTP actions
205 | if action_config.get('type') in ['Http', 'ApiConnection']:
206 | if 'runtimeConfiguration' not in action_config:
207 | action_config['runtimeConfiguration'] = {}
208 | if 'concurrency' not in action_config['runtimeConfiguration']:
209 | action_config['runtimeConfiguration']['concurrency'] = {}
210 | action_config['runtimeConfiguration']['concurrency']['runs'] = max_runs
211 | logger.info(f"Updated action concurrency for {action_name} to {max_runs}")
212 | updated_actions += 1
213 |
214 | # Handle nested actions in conditionals and loops
215 | if 'actions' in action_config:
216 | update_action_concurrency(action_config['actions'])
217 | if 'else' in action_config and 'actions' in action_config['else']:
218 | update_action_concurrency(action_config['else']['actions'])
219 |
220 | # Handle foreach loops specifically
221 | if action_config.get('type') == 'Foreach':
222 | if 'runtimeConfiguration' not in action_config:
223 | action_config['runtimeConfiguration'] = {}
224 | if 'concurrency' not in action_config['runtimeConfiguration']:
225 | action_config['runtimeConfiguration']['concurrency'] = {}
226 | action_config['runtimeConfiguration']['concurrency']['repetitions'] = max_runs
227 | logger.info(f"Updated foreach concurrency for {action_name} to {max_runs}")
228 | updated_actions += 1
229 |
230 | # Also update nested actions
231 | if 'actions' in action_config:
232 | update_action_concurrency(action_config['actions'])
233 |
234 | update_action_concurrency(actions)
235 |
236 | # Create the workflow update request
237 | from azure.mgmt.logic.models import Workflow
238 |
239 | workflow_update = Workflow(
240 | location=current_workflow.location,
241 | definition=updated_definition,
242 | state=current_workflow.state,
243 | parameters=current_workflow.parameters,
244 | tags=current_workflow.tags
245 | )
246 |
247 | # Update the workflow
248 | updated_workflow = logic_client.workflows.create_or_update(
249 | resource_group_name=self.resource_group_name,
250 | workflow_name=self.logic_app_name,
251 | workflow=workflow_update
252 | )
253 |
254 | logger.info(f"Successfully updated Logic App {self.logic_app_name} concurrency: trigger and {updated_actions} actions to {max_runs}")
255 |
256 | return {
257 | "success": True,
258 | "logic_app_name": self.logic_app_name,
259 | "new_max_runs": max_runs,
260 | "updated_triggers": len(triggers),
261 | "updated_actions": updated_actions,
262 | "updated_at": datetime.utcnow().isoformat()
263 | }
264 |
265 | except Exception as e:
266 | logger.error(f"Error updating Logic App action concurrency settings: {e}")
267 | return {"error": str(e), "success": False}
268 |
--------------------------------------------------------------------------------
/src/containerapp/main.py:
--------------------------------------------------------------------------------
1 | """
2 | ARGUS Container App - Main FastAPI Application
3 | Reorganized modular structure for better maintainability
4 | """
5 | import logging
6 | from contextlib import asynccontextmanager
7 |
8 | from fastapi import FastAPI, Request, BackgroundTasks
9 | from fastapi.responses import JSONResponse
10 |
11 | from dependencies import initialize_azure_clients, cleanup_azure_clients
12 | import api_routes
13 |
14 | # Configure logging
15 | logging.basicConfig(
16 | level=logging.INFO,
17 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
18 | )
19 | logger = logging.getLogger(__name__)
20 |
21 | MAX_TIMEOUT = 45*60 # Set timeout duration in seconds
22 |
23 |
24 | @asynccontextmanager
25 | async def lifespan(app: FastAPI):
26 | """Initialize Azure clients on startup"""
27 | try:
28 | await initialize_azure_clients()
29 | logger.info("Successfully initialized Azure clients")
30 | except Exception as e:
31 | logger.error(f"Failed to initialize Azure clients: {e}")
32 | raise
33 |
34 | yield
35 |
36 | # Cleanup
37 | await cleanup_azure_clients()
38 |
39 |
40 | # Initialize FastAPI app
41 | app = FastAPI(
42 | title="ARGUS Backend",
43 | description="Document processing backend using Azure AI services",
44 | version="1.0.0",
45 | lifespan=lifespan
46 | )
47 |
48 |
49 | # Health check endpoints
50 | @app.get("/")
51 | async def root():
52 | return await api_routes.root()
53 |
54 |
55 | @app.get("/health")
56 | async def health_check():
57 | return await api_routes.health_check()
58 |
59 |
60 | # Blob processing endpoints
61 | @app.post("/api/blob-created")
62 | async def handle_blob_created(request: Request, background_tasks: BackgroundTasks):
63 | return await api_routes.handle_blob_created(request, background_tasks)
64 |
65 |
66 | @app.post("/api/process-blob")
67 | async def process_blob_manual(request: Request, background_tasks: BackgroundTasks):
68 | return await api_routes.process_blob_manual(request, background_tasks)
69 |
70 |
71 | @app.post("/api/process-file")
72 | async def process_file(request: Request, background_tasks: BackgroundTasks):
73 | return await api_routes.process_file(request, background_tasks)
74 |
75 |
76 | # Configuration management endpoints
77 | @app.get("/api/configuration")
78 | async def get_configuration():
79 | return await api_routes.get_configuration()
80 |
81 |
82 | @app.post("/api/configuration")
83 | async def update_configuration(request: Request):
84 | return await api_routes.update_configuration(request)
85 |
86 |
87 | @app.post("/api/configuration/refresh")
88 | async def refresh_configuration():
89 | return await api_routes.refresh_configuration()
90 |
91 |
92 | # Logic App concurrency management endpoints
93 | @app.get("/api/concurrency")
94 | async def get_concurrency_settings():
95 | return await api_routes.get_concurrency_settings()
96 |
97 |
98 | @app.put("/api/concurrency")
99 | async def update_concurrency_settings(request: Request):
100 | return await api_routes.update_concurrency_settings(request)
101 |
102 |
103 | @app.get("/api/workflow-definition")
104 | async def get_workflow_definition():
105 | return await api_routes.get_workflow_definition()
106 |
107 |
108 | @app.put("/api/concurrency-full")
109 | async def update_full_concurrency_settings(request: Request):
110 | return await api_routes.update_full_concurrency_settings(request)
111 |
112 |
113 | @app.get("/api/concurrency/diagnostics")
114 | async def get_concurrency_diagnostics():
115 | return await api_routes.get_concurrency_diagnostics()
116 |
117 |
118 | # OpenAI configuration management endpoints
119 | @app.get("/api/openai-settings")
120 | async def get_openai_settings():
121 | return await api_routes.get_openai_settings()
122 |
123 |
124 | @app.put("/api/openai-settings")
125 | async def update_openai_settings(request: Request):
126 | return await api_routes.update_openai_settings(request)
127 |
128 |
129 | # Chat endpoint
130 | @app.post("/api/chat")
131 | async def chat_with_document(request: Request):
132 | return await api_routes.chat_with_document(request)
133 |
134 |
135 | # Optional: If you want to run this directly
136 | if __name__ == "__main__":
137 | import uvicorn
138 | uvicorn.run(app, host="0.0.0.0", port=8000)
139 |
--------------------------------------------------------------------------------
/src/containerapp/main_local.py:
--------------------------------------------------------------------------------
1 | """
2 | Local development version of the ARGUS backend
3 | Works without Azure Cosmos DB by using in-memory storage
4 | """
5 | import logging
6 | import os
7 | import json
8 | import traceback
9 | import sys
10 | from datetime import datetime
11 | from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
12 | from typing import Dict, Any, List, Optional
13 | import asyncio
14 | from contextlib import asynccontextmanager
15 |
16 | from fastapi import FastAPI, Request, BackgroundTasks, HTTPException, UploadFile, File, Form
17 | from fastapi.responses import JSONResponse
18 | from fastapi.middleware.cors import CORSMiddleware
19 | from pydantic import BaseModel
20 | import uvicorn
21 |
22 | # Configure logging
23 | logging.basicConfig(
24 | level=logging.INFO,
25 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
26 | )
27 | logger = logging.getLogger(__name__)
28 |
29 | # In-memory storage for local development
30 | documents_storage = {}
31 | config_storage = {}
32 |
33 | class DocumentModel(BaseModel):
34 | id: str
35 | properties: Dict[str, Any]
36 | state: Dict[str, bool]
37 | extracted_data: Dict[str, Any]
38 |
39 | class HealthResponse(BaseModel):
40 | status: str
41 | timestamp: str
42 | version: str
43 |
44 | class DocumentListResponse(BaseModel):
45 | documents: List[DocumentModel]
46 | count: int
47 |
48 | @asynccontextmanager
49 | async def lifespan(app: FastAPI):
50 | """Initialize local development environment"""
51 | logger.info("Starting ARGUS Backend in LOCAL DEVELOPMENT mode")
52 | logger.info("Note: Using in-memory storage instead of Azure Cosmos DB")
53 |
54 | # Create some sample data for testing
55 | sample_doc = DocumentModel(
56 | id="sample-invoice-123",
57 | properties={
58 | "blob_name": "sample-invoice.pdf",
59 | "blob_size": 12345,
60 | "request_timestamp": datetime.now().isoformat(),
61 | "num_pages": 2
62 | },
63 | state={
64 | "file_landed": True,
65 | "ocr_completed": True,
66 | "gpt_extraction_completed": True,
67 | "gpt_evaluation_completed": False,
68 | "gpt_summary_completed": False,
69 | "processing_completed": False
70 | },
71 | extracted_data={
72 | "ocr_output": "Sample OCR text from invoice...",
73 | "gpt_output": {"invoice_number": "INV-001", "total": 1250.00},
74 | "gpt_evaluation": {},
75 | "gpt_summary": ""
76 | }
77 | )
78 |
79 | documents_storage[sample_doc.id] = sample_doc
80 |
81 | logger.info("Successfully initialized local development environment")
82 | yield
83 | logger.info("Shutting down local development environment")
84 |
85 | # Initialize FastAPI app
86 | app = FastAPI(
87 | title="ARGUS Backend (Local Development)",
88 | description="Document processing backend - Local development version",
89 | version="1.0.0",
90 | lifespan=lifespan
91 | )
92 |
93 | # Add CORS middleware for local development
94 | app.add_middleware(
95 | CORSMiddleware,
96 | allow_origins=["http://localhost:8501", "http://127.0.0.1:8501"],
97 | allow_credentials=True,
98 | allow_methods=["*"],
99 | allow_headers=["*"],
100 | )
101 |
102 | @app.get("/health", response_model=HealthResponse)
103 | async def health_check():
104 | """Health check endpoint"""
105 | return HealthResponse(
106 | status="healthy",
107 | timestamp=datetime.now().isoformat(),
108 | version="1.0.0-local"
109 | )
110 |
111 | @app.get("/api/documents", response_model=DocumentListResponse)
112 | async def list_documents():
113 | """List all documents"""
114 | documents = list(documents_storage.values())
115 | return DocumentListResponse(
116 | documents=documents,
117 | count=len(documents)
118 | )
119 |
120 | @app.get("/api/documents/{doc_id}", response_model=DocumentModel)
121 | async def get_document(doc_id: str):
122 | """Get a specific document by ID"""
123 | if doc_id not in documents_storage:
124 | raise HTTPException(status_code=404, detail="Document not found")
125 |
126 | return documents_storage[doc_id]
127 |
128 | @app.post("/api/documents/{doc_id}")
129 | async def update_document(doc_id: str, document: DocumentModel):
130 | """Update a document"""
131 | documents_storage[doc_id] = document
132 | return {"message": "Document updated successfully", "id": doc_id}
133 |
134 | @app.delete("/api/documents/{doc_id}")
135 | async def delete_document(doc_id: str):
136 | """Delete a document"""
137 | if doc_id not in documents_storage:
138 | raise HTTPException(status_code=404, detail="Document not found")
139 |
140 | del documents_storage[doc_id]
141 | return {"message": "Document deleted successfully", "id": doc_id}
142 |
143 | @app.post("/api/upload")
144 | async def upload_file(file: UploadFile = File(...), dataset_name: str = "default-dataset"):
145 | """Upload a file for processing (mock implementation)"""
146 | doc_id = f"uploaded-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{file.filename}"
147 |
148 | # Create a mock document entry
149 | document = DocumentModel(
150 | id=doc_id,
151 | properties={
152 | "blob_name": f"{dataset_name}/{file.filename}",
153 | "blob_size": file.size or 0,
154 | "request_timestamp": datetime.now().isoformat(),
155 | "num_pages": 1, # Mock value
156 | "dataset": dataset_name
157 | },
158 | state={
159 | "file_landed": True,
160 | "ocr_completed": False,
161 | "gpt_extraction_completed": False,
162 | "gpt_evaluation_completed": False,
163 | "gpt_summary_completed": False,
164 | "processing_completed": False
165 | },
166 | extracted_data={
167 | "ocr_output": "",
168 | "gpt_output": {},
169 | "gpt_evaluation": {},
170 | "gpt_summary": ""
171 | }
172 | )
173 |
174 | documents_storage[doc_id] = document
175 |
176 | return {
177 | "message": "File uploaded successfully",
178 | "id": doc_id,
179 | "filename": file.filename,
180 | "dataset": dataset_name,
181 | "status": "uploaded"
182 | }
183 |
184 | @app.post("/api/process/{doc_id}")
185 | async def process_document(doc_id: str, background_tasks: BackgroundTasks):
186 | """Start processing a document (mock implementation)"""
187 | if doc_id not in documents_storage:
188 | raise HTTPException(status_code=404, detail="Document not found")
189 |
190 | # Mock processing - update states progressively
191 | background_tasks.add_task(mock_process_document, doc_id)
192 |
193 | return {
194 | "message": "Document processing started",
195 | "id": doc_id,
196 | "status": "processing"
197 | }
198 |
199 | async def mock_process_document(doc_id: str):
200 | """Mock document processing function"""
201 | import asyncio
202 |
203 | if doc_id not in documents_storage:
204 | return
205 |
206 | document = documents_storage[doc_id]
207 |
208 | # Simulate OCR processing
209 | await asyncio.sleep(2)
210 | document.state["ocr_completed"] = True
211 | document.extracted_data["ocr_output"] = "Mock OCR text extracted from document..."
212 |
213 | # Simulate GPT extraction
214 | await asyncio.sleep(3)
215 | document.state["gpt_extraction_completed"] = True
216 | document.extracted_data["gpt_output"] = {
217 | "document_type": "invoice",
218 | "total_amount": 1250.00,
219 | "invoice_number": "INV-001",
220 | "date": "2024-01-15"
221 | }
222 |
223 | # Simulate GPT evaluation
224 | await asyncio.sleep(2)
225 | document.state["gpt_evaluation_completed"] = True
226 | document.extracted_data["gpt_evaluation"] = {
227 | "confidence_score": 0.95,
228 | "quality_score": 0.88
229 | }
230 |
231 | # Simulate GPT summary
232 | await asyncio.sleep(1)
233 | document.state["gpt_summary_completed"] = True
234 | document.extracted_data["gpt_summary"] = "This is a mock summary of the processed document."
235 |
236 | # Mark as completed
237 | document.state["processing_completed"] = True
238 |
239 | logger.info(f"Mock processing completed for document {doc_id}")
240 |
241 | @app.get("/api/config")
242 | async def get_config():
243 | """Get configuration settings"""
244 | return {
245 | "environment": "local-development",
246 | "features": {
247 | "ocr_enabled": True,
248 | "gpt_extraction_enabled": True,
249 | "gpt_evaluation_enabled": True,
250 | "gpt_summary_enabled": True
251 | },
252 | "limits": {
253 | "max_file_size_mb": 50,
254 | "max_pages": 100
255 | }
256 | }
257 |
258 | @app.get("/api/configuration")
259 | async def get_configuration():
260 | """Get configuration settings (alternative endpoint for frontend compatibility)"""
261 | return await get_config()
262 |
263 | @app.post("/api/configuration")
264 | async def update_configuration(config_data: dict):
265 | """Update configuration settings"""
266 | # In local development, just return the updated config
267 | return {
268 | "message": "Configuration updated successfully (local development mode)",
269 | "config": config_data
270 | }
271 |
272 | @app.get("/api/datasets")
273 | async def get_datasets():
274 | """Get list of available datasets"""
275 | return ["default-dataset", "medical-dataset", "test-dataset"]
276 |
277 | @app.get("/api/datasets/{dataset_name}/files")
278 | async def get_dataset_files(dataset_name: str):
279 | """Get files in a specific dataset"""
280 | # Mock files for different datasets
281 | mock_files = {
282 | "default-dataset": [
283 | {"filename": "invoice-001.pdf", "size": 12345, "uploaded_at": "2025-06-17T09:00:00Z"},
284 | {"filename": "receipt-002.pdf", "size": 8765, "uploaded_at": "2025-06-17T08:30:00Z"}
285 | ],
286 | "medical-dataset": [
287 | {"filename": "medical-report-001.pdf", "size": 23456, "uploaded_at": "2025-06-17T07:15:00Z"}
288 | ],
289 | "test-dataset": []
290 | }
291 | return mock_files.get(dataset_name, [])
292 |
293 | @app.get("/api/stats")
294 | async def get_stats():
295 | """Get processing statistics"""
296 | total_docs = len(documents_storage)
297 | completed_docs = sum(1 for doc in documents_storage.values() if doc.state["processing_completed"])
298 |
299 | return {
300 | "total_documents": total_docs,
301 | "completed_documents": completed_docs,
302 | "pending_documents": total_docs - completed_docs,
303 | "success_rate": completed_docs / total_docs if total_docs > 0 else 0.0
304 | }
305 |
306 | if __name__ == "__main__":
307 | uvicorn.run(
308 | "main_local:app",
309 | host="0.0.0.0",
310 | port=8000,
311 | reload=True,
312 | log_level="info"
313 | )
314 |
--------------------------------------------------------------------------------
/src/containerapp/models.py:
--------------------------------------------------------------------------------
1 | """
2 | Data models for the ARGUS Container App
3 | """
4 | from typing import Dict, Any
5 |
6 |
7 | class EventGridEvent:
8 | """Event Grid event model"""
9 | def __init__(self, event_data: Dict[str, Any]):
10 | self.id = event_data.get('id')
11 | self.event_type = event_data.get('eventType')
12 | self.subject = event_data.get('subject')
13 | self.event_time = event_data.get('eventTime')
14 | self.data = event_data.get('data', {})
15 | self.data_version = event_data.get('dataVersion')
16 | self.metadata_version = event_data.get('metadataVersion')
17 |
18 |
19 | class BlobInputStream:
20 | """Mock BlobInputStream to match the original function interface"""
21 | def __init__(self, blob_name: str, blob_size: int, blob_client):
22 | self.name = blob_name
23 | self.length = blob_size
24 | self._blob_client = blob_client
25 | self._content = None
26 |
27 | def read(self, size: int = -1):
28 | """Read blob content"""
29 | if self._content is None:
30 | blob_data = self._blob_client.download_blob()
31 | self._content = blob_data.readall()
32 |
33 | if size == -1:
34 | return self._content
35 | else:
36 | return self._content[:size]
37 |
--------------------------------------------------------------------------------
/src/containerapp/requirements.txt:
--------------------------------------------------------------------------------
1 | fastapi==0.104.1
2 | uvicorn[standard]==0.24.0
3 | azure-storage-blob==12.19.0
4 | azure-identity==1.19.0
5 | azure-cosmos==4.9.0
6 | azure-mgmt-logic==10.0.0
7 | azure-mgmt-resource==23.1.1
8 | azure-ai-formrecognizer==3.3.3
9 | azure-ai-documentintelligence==1.0.0
10 | azure-cognitiveservices-vision-computervision==0.9.0
11 | openai==1.58.1
12 | requests==2.31.0
13 | python-multipart==0.0.20
14 | Pillow==11.0.0
15 | pandas==2.2.3
16 | numpy>=1.26.0
17 | python-dotenv==1.0.1
18 | aiofiles==23.2.1
19 | PyMuPDF==1.25.1
20 | PyPDF2==3.0.1
21 | langchain==0.3.12
22 | langchain-core==0.3.25
23 | langchain-community==0.3.12
24 | langchain-openai==0.2.12
25 | tiktoken==0.8.0
26 | requests-html==0.10.0
27 |
--------------------------------------------------------------------------------
/src/evaluators/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/src/evaluators/__init__.py
--------------------------------------------------------------------------------
/src/evaluators/cosine_similarity_string_evaluator.py:
--------------------------------------------------------------------------------
1 | class CosineSimilarityStringEvaluator:
2 |
3 | def __call__(self, ground_truth: str, actual: str, config: dict = {}):
4 | raise "Not implemented"
5 |
6 |
--------------------------------------------------------------------------------
/src/evaluators/custom_string_evaluator.py:
--------------------------------------------------------------------------------
1 | from src.evaluators.field_evaluator_base import FieldEvaluatorBase
2 |
3 | class CustomStringEvaluator(FieldEvaluatorBase):
4 |
5 | class Config:
6 | IGNORE_DOLLAR_SIGN = "IGNORE_DOLLAR_SIGN"
7 | ADDITIONAL_MATCHES = "ADDITIONAL_MATCHES"
8 | IGNORE_DOTS = "IGNORE_DOTS"
9 | IGNORE_COMMAS = "IGNORE_COMMAS"
10 | IGNORE_PARENTHETHES = "IGNORE_PARENTHETHES"
11 | IGNORE_DASHES = "IGNORE_DASHES"
12 |
13 | def __init__(self, default_config = {}) -> None:
14 | self.default_config = default_config
15 |
16 | def __call__(self, ground_truth: str, actual: str, config: dict = None):
17 | if not config:
18 | config = self.default_config
19 |
20 | actual_processed = str(actual).lower()
21 | ground_truth_processed = str(ground_truth).lower()
22 |
23 | if config.get(self.Config.IGNORE_DOTS, False):
24 | actual_processed = actual_processed.replace('.', '')
25 | ground_truth_processed = ground_truth_processed.replace('.', '')
26 |
27 | if config.get(self.Config.IGNORE_COMMAS, False):
28 | actual_processed = actual_processed.replace(',', '')
29 | ground_truth_processed = ground_truth_processed.replace(',', '')
30 |
31 | if config.get(self.Config.IGNORE_DASHES, False):
32 | actual_processed = actual_processed.replace('-', '')
33 | ground_truth_processed = ground_truth_processed.replace('-', '')
34 |
35 | if config.get(self.Config.IGNORE_PARENTHETHES, False):
36 | actual_processed = actual_processed.replace('(', '')
37 | ground_truth_processed = ground_truth_processed.replace('(', '')
38 | actual_processed = actual_processed.replace(')', '')
39 | ground_truth_processed = ground_truth_processed.replace(')', '')
40 |
41 | if config.get(self.Config.IGNORE_DOLLAR_SIGN, False):
42 | # Remove leading dollar signs from both strings
43 | ground_truth_processed = ground_truth_processed.lstrip("$")
44 | actual_processed = actual_processed.lstrip("$")
45 |
46 | additional_matches = config.get(
47 | self.Config.ADDITIONAL_MATCHES, []
48 | )
49 | additional_matches.append(ground_truth_processed)
50 |
51 | if actual_processed in additional_matches:
52 | return 1
53 |
54 | return 0
55 |
56 |
--------------------------------------------------------------------------------
/src/evaluators/field_evaluator_base.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | class FieldEvaluatorBase(ABC):
4 |
5 | @abstractmethod
6 | def __call__(self, ground_truth: str, actual: str, config: dict = {}) -> int:
7 | raise NotImplementedError
8 |
--------------------------------------------------------------------------------
/src/evaluators/fuzz_string_evaluator.py:
--------------------------------------------------------------------------------
1 | from thefuzz import fuzz
2 |
3 | class FuzzStringEvaluator:
4 |
5 | def __call__(self, ground_truth: str, actual: str, config: dict = {}):
6 | return fuzz.partial_token_set_ratio(ground_truth,actual)/100.0
7 |
8 |
--------------------------------------------------------------------------------
/src/evaluators/json_evaluator.py:
--------------------------------------------------------------------------------
1 | from src.evaluators.custom_string_evaluator import CustomStringEvaluator
2 | from src.evaluators.fuzz_string_evaluator import FuzzStringEvaluator
3 |
4 |
5 | class JsonEvaluator:
6 |
7 | class FieldEvaluatorWrapper:
8 | def __init__(self, evaluator_instance):
9 | self.name = evaluator_instance.__class__.__name__
10 | self.instance = evaluator_instance
11 | self.total_strings_compared = 0
12 | self.total_score = 0
13 |
14 | def calculate_ratio(self):
15 | return (
16 | self.total_score / self.total_strings_compared
17 | if self.total_strings_compared > 0
18 | else 0
19 | )
20 |
21 | def __init__(
22 | self,
23 | field_evaluators: list = [CustomStringEvaluator(), FuzzStringEvaluator()],
24 | ):
25 | self.eval_wrappers = []
26 | for evaluator in field_evaluators:
27 | self.eval_wrappers.append(self.FieldEvaluatorWrapper(evaluator))
28 |
29 | self.result = {}
30 |
31 | def __call__(self, ground_truth, actual, eval_schema={}):
32 | self.compare_values(ground_truth, actual, eval_schema, None)
33 | for wrapper in self.eval_wrappers:
34 | self.result[f"{wrapper.name}.ratio"] = (
35 | wrapper.calculate_ratio()
36 | )
37 |
38 | return self.result
39 |
40 | def compare_values(self, ground_truth, actual, eval_schema, curr_key):
41 | if isinstance(ground_truth, dict):
42 | return self.compare_dicts(ground_truth, actual, eval_schema, curr_key)
43 | elif isinstance(ground_truth, list):
44 | return self.compare_lists(ground_truth, actual, eval_schema, curr_key)
45 | else:
46 | for wrapper in self.eval_wrappers:
47 | if actual is None:
48 | score = 0
49 | else:
50 | score = wrapper.instance(
51 | ground_truth,
52 | actual,
53 | eval_schema.get(wrapper.name, None),
54 | )
55 | wrapper.total_strings_compared += 1
56 | self.result[f"{wrapper.name}.{curr_key}"] = score
57 | wrapper.total_score += score
58 |
59 | def compare_dicts(self, ground_truth_dict, actual_dict, eval_schema, curr_key=None):
60 | for key in ground_truth_dict:
61 | # handle defaults if is None
62 | next_key = f"{curr_key}.{key}" if curr_key is not None else key
63 | actual = actual_dict.get(key, None) if actual_dict is not None else None
64 | curr_eval_schema = eval_schema.get(key, {}) if eval_schema is not None else {}
65 |
66 | self.compare_values(
67 | ground_truth_dict[key],
68 | actual,
69 | curr_eval_schema,
70 | next_key,
71 | )
72 |
73 | def compare_lists(self, ground_truth_list, actual_list, eval_schema, curr_key):
74 | for i in range(len(ground_truth_list)):
75 | # handle defaults if is None
76 | next_key = f"{curr_key}[{i}]" if curr_key is not None else f"[{i}]"
77 | try:
78 | actual = actual_list[i]
79 | except Exception:
80 | actual = None
81 | try:
82 | curr_eval_schema = eval_schema[i]
83 | except Exception:
84 | curr_eval_schema = {}
85 |
86 | self.compare_values(
87 | ground_truth_list[i],
88 | actual,
89 | curr_eval_schema,
90 | next_key,
91 | )
92 |
--------------------------------------------------------------------------------
/src/evaluators/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Azure-Samples/ARGUS/337c456c8a3a341c6b63237191a99f87807d8283/src/evaluators/tests/__init__.py
--------------------------------------------------------------------------------
/src/evaluators/tests/test_custom_string_evaluator.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from src.evaluators.custom_string_evaluator import CustomStringEvaluator
4 |
5 |
6 | class TestCustomStringEvaluator(unittest.TestCase):
7 |
8 | def test_string_evaluator_exact_match(
9 | self
10 | ):
11 | evaluator = CustomStringEvaluator()
12 | exact_match = evaluator("value", "value")
13 | no_match = evaluator("value", "not_value")
14 | assert exact_match == True
15 | assert no_match == False
16 |
17 | def test_string_evaluator_commas_ignored(
18 | self
19 | ):
20 | evaluator = CustomStringEvaluator()
21 | match_1 = evaluator("value", "va,lue",config={CustomStringEvaluator.Config.IGNORE_COMMAS: True})
22 | assert match_1 == True
23 |
24 |
25 | def test_string_evaluator_commas_not_ignored(
26 | self
27 | ):
28 | evaluator = CustomStringEvaluator()
29 | match_1 = evaluator("value", "value", config={CustomStringEvaluator.Config.IGNORE_COMMAS: False})
30 | match_2 = evaluator("value", "va,lue", config={CustomStringEvaluator.Config.IGNORE_COMMAS: False})
31 | assert match_1 == True
32 | assert match_2 == False
33 |
34 |
35 | def test_string_evaluator_dots_ignored(
36 | self
37 | ):
38 | evaluator = CustomStringEvaluator()
39 | match_1 = evaluator("value", "va.lue",config={CustomStringEvaluator.Config.IGNORE_DOTS: True})
40 | assert match_1 == True
41 |
42 |
43 | def test_string_evaluator_dots_not_ignored(
44 | self
45 | ):
46 | evaluator = CustomStringEvaluator()
47 | match_1 = evaluator("value", "value",config={CustomStringEvaluator.Config.IGNORE_DOTS: False})
48 | match_2 = evaluator("value", "va.lue",config={CustomStringEvaluator.Config.IGNORE_DOTS: False})
49 | assert match_1 == True
50 | assert match_2 == False
51 |
52 |
53 | def test_string_evaluator_dollar_sign_ignored(
54 | self
55 | ):
56 | evaluator = CustomStringEvaluator()
57 | match_1 = evaluator("$10", "10",config={CustomStringEvaluator.Config.IGNORE_DOLLAR_SIGN: True})
58 | assert match_1 == True
59 |
60 |
61 | def test_string_evaluator_dollar_sign_not_ignored(
62 | self
63 | ):
64 | evaluator = CustomStringEvaluator()
65 | match_1 = evaluator("$10", "10",config={CustomStringEvaluator.Config.IGNORE_DOLLAR_SIGN: False})
66 | assert match_1 == False
67 |
68 |
69 |
70 | def test_string_evaluator_parenthesis_ignored(
71 | self
72 | ):
73 | evaluator = CustomStringEvaluator()
74 | match_1 = evaluator("(256)3300488", "2563300488",config={CustomStringEvaluator.Config.IGNORE_PARENTHETHES: True})
75 | assert match_1 == True
76 |
77 |
78 | def test_string_evaluator_parenthesis_not_ignored(
79 | self
80 | ):
81 | evaluator = CustomStringEvaluator()
82 | match_1 = evaluator("(256)3300488", "2563300488",config={CustomStringEvaluator.Config.IGNORE_PARENTHETHES: False})
83 | assert match_1 == False
84 |
85 | def test_string_evaluator_dashes_ignored(
86 | self
87 | ):
88 | evaluator = CustomStringEvaluator()
89 | match_1 = evaluator("(256)330-0488", "(256)3300488",config={CustomStringEvaluator.Config.IGNORE_DASHES: True})
90 | assert match_1 == True
91 |
92 |
93 | def test_string_evaluator_dashes_not_ignored(
94 | self
95 | ):
96 | evaluator = CustomStringEvaluator()
97 | match_1 = evaluator("(256)3300-488", "(256)3300488",config={CustomStringEvaluator.Config.IGNORE_DASHES: False})
98 | assert match_1 == False
99 |
100 | def test_string_evaluator_additional_matches(
101 | self
102 | ):
103 | evaluator = CustomStringEvaluator()
104 | match_1 = evaluator("correct", "correct",config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
105 | match_2 = evaluator("correct", "yes", config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
106 | match_3 = evaluator("correct", "true", config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
107 | match_4 = evaluator("correct", "false", config={CustomStringEvaluator.Config.ADDITIONAL_MATCHES: ["yes", "true"]})
108 | assert match_1 == True
109 | assert match_2 == True
110 | assert match_3 == True
111 | assert match_4 == False
112 |
--------------------------------------------------------------------------------
/src/evaluators/tests/test_json_evaluator.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from src.evaluators.custom_string_evaluator import CustomStringEvaluator
4 | from src.evaluators.fuzz_string_evaluator import FuzzStringEvaluator
5 | from src.evaluators.json_evaluator import JsonEvaluator
6 |
7 |
8 | class TestJsonEvaluator(unittest.TestCase):
9 |
10 | def test_json_evaluator_no_eval_schema(self):
11 | ground_truth_data = {
12 | "key1": "value1", # value 1
13 | "key2": {
14 | "key1": "value2", # value 2
15 | "key2": {"key1": "value3"}, # value 3
16 | "key3": ["value4", "value5"], # Values 4 and 5
17 | "key4": {
18 | "key1": [{"key1": "value6", "key2": "value7"}] # value 6 # value 7
19 | },
20 | "key5": "value8", # value 8
21 | },
22 | "key3": "value9", # value 9
23 | "key4": "value10", # value 10
24 | }
25 | # Total values = 10
26 |
27 | actual_data = {
28 | "key1": "wrong_value", # wrong 1 - Should be "value1"
29 | "key2": {
30 | "key1": "value2", # correct 1 - this should be marked correct as the ground truth int will be made a str in the string evaluator
31 | "key2": {
32 | "key1": "value,3" # wrong 2 - should be "5.0" - puctuation is ignored when word does NOT contains a number
33 | },
34 | "key3": ["value4", "value5"], # correct 2 # correct 3
35 | "key4": {
36 | "key1": [
37 | {"key1": "value6", "key2": "value7"} # correct 4 # correct 5
38 | ]
39 | },
40 | # key5 is missing
41 | },
42 | # key3 is missing
43 | "key4": "value10", # correct 6
44 | }
45 | # Total correct = 6
46 | # ratio = 6/10 = 0.6
47 |
48 | json_evaluator = JsonEvaluator()
49 | result = json_evaluator(ground_truth_data, actual_data)
50 | assert result["CustomStringEvaluator.ratio"] == 0.6
51 | assert result['FuzzStringEvaluator.ratio'] == 0.782
52 |
53 | def test_json_evaluator_with_eval_schema(self):
54 | ground_truth_data = {
55 | "key1": "value1", # value 1
56 | "key2": {
57 | "key1": "value2", # value 2
58 | "key2": {"key1": "value3"}, # value 3
59 | "key3": ["value4", "value5"], # Values 4 and 5
60 | "key4": {
61 | "key1": [{"key1": "value6", "key2": "value7"}] # value 6 # value 7
62 | },
63 | "key5": "value8", # value 8
64 | },
65 | "key3": "value9", # value 9
66 | "key4": "value10", # value 10
67 | }
68 | # Total values = 10
69 |
70 | actual_data = {
71 | "key1": "wrong_value", # wrong 1 - Should be "value1"
72 | "key2": {
73 | "key1": "value.2", # correct 1 - this should be marked correct as the ground truth int will be made a str in the string evaluator
74 | "key2": {"key1": "$value3"}, # correct 2
75 | "key3": ["value4", "value,5"], # correct 3
76 | "key4": {
77 | "key1": [
78 | {"key1": "value,6", "key2": "value7"} # correct 4 # correct 5
79 | ]
80 | },
81 | # key5 is missing
82 | },
83 | "key4": "value10", # correct 6
84 | # key2 is missing
85 | }
86 | # Total correct = 6
87 | # ratio = 6/10 = 0.6
88 |
89 | eval_schema = {
90 | "key1": {},
91 | "key2": {
92 | "key1": {"CustomStringEvaluator": {"IGNORE_DOTS": "True"}},
93 | "key2": {
94 | "key1": {"CustomStringEvaluator": {"IGNORE_DOLLAR_SIGN": "True"}}
95 | },
96 | "key3": {},
97 | "key4": {
98 | "key1": [
99 | {
100 | "key1": {
101 | "CustomStringEvaluator": {"IGNORE_COMMAS": "True"}
102 | },
103 | "key2": {},
104 | } # correct 4 # correct 5
105 | ]
106 | },
107 | "key5": {},
108 | },
109 | "key3": {},
110 | "key4": {},
111 | }
112 |
113 | json_evaluator = JsonEvaluator()
114 | result = json_evaluator(ground_truth_data, actual_data, eval_schema)
115 | assert result['FuzzStringEvaluator.ratio'] == 0.764
116 | assert result["CustomStringEvaluator.ratio"] == 0.6
117 |
118 | def test_json_evaluator_no_eval_schema_with_default_config(self):
119 | ground_truth_data = {
120 | "key1": "value1", # value 1
121 | "key2": {
122 | "key1": "value2", # value 2
123 | "key2": {"key1": "value3"}, # value 3
124 | "key3": ["value4", "value5"], # Values 4 and 5
125 | "key4": {
126 | "key1": [{"key1": "value6", "key2": "value7"}] # value 6 # value 7
127 | },
128 | "key5": "value8", # value 8
129 | },
130 | "key3": "value9", # value 9
131 | "key4": "value10", # value 10
132 | }
133 | # Total values = 10
134 |
135 | actual_data = {
136 | "key1": "wrong_value", # wrong 1 - Should be "value1"
137 | "key2": {
138 | "key1": "value.2", # correct 1 - this should be marked correct as the ground truth int will be made a str in the string evaluator
139 | "key2": {"key1": "$value3"}, # correct 2
140 | "key3": ["value4", "value,5"], # correct 3
141 | "key4": {
142 | "key1": [
143 | {"key1": "value,6", "key2": "value7"} # correct 4 # correct 5
144 | ]
145 | },
146 | # key5 is missing
147 | },
148 | "key4": "value10", # correct 6
149 | # key2 is missing
150 | }
151 | # Total correct = 6
152 | # ratio = 6/10 = 0.6
153 |
154 | evaluators = [
155 | CustomStringEvaluator({
156 | CustomStringEvaluator.Config.IGNORE_DOLLAR_SIGN: True,
157 | CustomStringEvaluator.Config.IGNORE_DASHES: True,
158 | CustomStringEvaluator.Config.IGNORE_DOTS: True,
159 | }),
160 | FuzzStringEvaluator(),
161 | ]
162 |
163 | # Total correct = 5
164 | # ratio = 5/10 = 0.5
165 |
166 | json_evaluator = JsonEvaluator(evaluators)
167 | result = json_evaluator(ground_truth_data, actual_data)
168 | assert result["CustomStringEvaluator.ratio"] == 0.5
169 | assert result['FuzzStringEvaluator.ratio'] == 0.764
170 |
171 | def test_json_evaluator_different_array_length_in_actual(self):
172 | ground_truth_data = {
173 | "key1": "value1", # value 1
174 | "key2": ["test1", "test2", "test3"], # Values 2, 3, 4
175 | }
176 | # Total values = 4
177 |
178 | actual_data = {
179 | "key1": "value1", # correct 1
180 | "key2": ["test1"], # correct 2, wrong 1, wrong 2 (missing index 1, 2)
181 | }
182 |
183 | evaluators = [CustomStringEvaluator()]
184 |
185 | # Total correct = 2
186 | # ratio = 2/4 = 0.5
187 |
188 | json_evaluator = JsonEvaluator(evaluators)
189 | result = json_evaluator(ground_truth_data, actual_data)
190 | assert result["CustomStringEvaluator.ratio"] == 0.5
191 | assert result['CustomStringEvaluator.key1'] == 1
192 | assert result['CustomStringEvaluator.key2[0]'] == 1
193 | assert result['CustomStringEvaluator.key2[1]'] == 0
194 | assert result['CustomStringEvaluator.key2[2]'] == 0
195 |
196 | def test_json_evaluator_handles_array_first_value(self):
197 | ground_truth_data = [
198 | {"key1": "value1"}, # value 1
199 | {"key2": ["1", "2", "3"]},
200 | "array_value_3"
201 | ]
202 | # Total values = 5
203 |
204 | actual_data = [
205 | {"key1": "value1"}, # correct 1
206 | {"key2": ["1", "wrong", "3"]}, # correct 2, wrong 1, correct 3
207 | "array_value_3" # correct 4
208 | ]
209 |
210 | # Total correct = 4
211 | # ratio = 4/5 = 0.8
212 |
213 | evaluators = [CustomStringEvaluator()]
214 |
215 | json_evaluator = JsonEvaluator(evaluators)
216 | result = json_evaluator(ground_truth_data, actual_data)
217 | assert result["CustomStringEvaluator.ratio"] == 0.8
218 | assert result['CustomStringEvaluator.[0].key1'] == 1
219 | assert result['CustomStringEvaluator.[1].key2[0]'] == 1
220 | assert result['CustomStringEvaluator.[1].key2[1]'] == 0
221 | assert result['CustomStringEvaluator.[1].key2[2]'] == 1
222 | assert result['CustomStringEvaluator.[2]'] == 1
223 |
224 | def test_json_evaluator_handles_array_dict_mismatch(self):
225 | ground_truth_data = [
226 | {"key1": "value1"}, # value 1
227 | {"key2": ["1", "2", "3"]},
228 | "array_value_3"
229 | ]
230 | # Total values = 5
231 |
232 | # all values should be wrong, as this is a dict and not an array
233 | actual_data = {
234 | "key1": "value1",
235 | "key2": ["1", "wrong", "3"],
236 | }
237 |
238 | # Total correct = 0
239 | # ratio = 0/5 = 0
240 |
241 | evaluators = [CustomStringEvaluator()]
242 |
243 | json_evaluator = JsonEvaluator(evaluators)
244 | result = json_evaluator(ground_truth_data, actual_data)
245 | assert result["CustomStringEvaluator.ratio"] == 0
246 | assert result['CustomStringEvaluator.[0].key1'] == 0
247 | assert result['CustomStringEvaluator.[1].key2[0]'] == 0
248 | assert result['CustomStringEvaluator.[1].key2[1]'] == 0
249 | assert result['CustomStringEvaluator.[1].key2[2]'] == 0
250 | assert result['CustomStringEvaluator.[2]'] == 0
--------------------------------------------------------------------------------