Skip to content

Commit e0e85f1

Browse files
committed
Merge branch 'main' into feat-chat-history-cosmos
2 parents b0aacb2 + 009d5e1 commit e0e85f1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1828
-2162
lines changed

.azdo/pipelines/azure-dev.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ steps:
101101
AZURE_SPEECH_SERVICE_RESOURCE_GROUP: $(AZURE_SPEECH_SERVICE_RESOURCE_GROUP)
102102
AZURE_SPEECH_SERVICE_LOCATION: $(AZURE_SPEECH_SERVICE_LOCATION)
103103
AZURE_SPEECH_SERVICE_SKU: $(AZURE_SPEECH_SERVICE_SKU)
104+
AZURE_SPEECH_SERVICE_VOICE: $(AZURE_SPEECH_SERVICE_VOICE)
104105
AZURE_KEY_VAULT_NAME: $(AZURE_KEY_VAULT_NAME)
105106
AZURE_USE_AUTHENTICATION: $(AZURE_USE_AUTHENTICATION)
106107
AZURE_ENFORCE_ACCESS_CONTROL: $(AZURE_ENFORCE_ACCESS_CONTROL)

.github/workflows/azure-dev.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ jobs:
3232
AZURE_OPENAI_SERVICE: ${{ vars.AZURE_OPENAI_SERVICE }}
3333
AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }}
3434
AZURE_OPENAI_RESOURCE_GROUP: ${{ vars.AZURE_OPENAI_RESOURCE_GROUP }}
35+
AZURE_DOCUMENTINTELLIGENCE_SERVICE: ${{ vars.AZURE_DOCUMENTINTELLIGENCE_SERVICE }}
36+
AZURE_DOCUMENTINTELLIGENCE_RESOURCE_GROUP: ${{ vars.AZURE_DOCUMENTINTELLIGENCE_RESOURCE_GROUP }}
37+
AZURE_DOCUMENTINTELLIGENCE_SKU: ${{ vars.AZURE_DOCUMENTINTELLIGENCE_SKU }}
38+
AZURE_DOCUMENTINTELLIGENCE_LOCATION: ${{ vars.AZURE_DOCUMENTINTELLIGENCE_LOCATION }}
3539
AZURE_COMPUTER_VISION_SERVICE: ${{ vars.AZURE_COMPUTER_VISION_SERVICE }}
3640
AZURE_COMPUTER_VISION_RESOURCE_GROUP: ${{ vars.AZURE_COMPUTER_VISION_RESOURCE_GROUP }}
3741
AZURE_COMPUTER_VISION_LOCATION: ${{ vars.AZURE_COMPUTER_VISION_LOCATION }}
3842
AZURE_COMPUTER_VISION_SKU: ${{ vars.AZURE_COMPUTER_VISION_SKU }}
39-
AZURE_FORMRECOGNIZER_SERVICE: ${{ vars.AZURE_FORMRECOGNIZER_SERVICE }}
40-
AZURE_FORMRECOGNIZER_RESOURCE_GROUP: ${{ vars.AZURE_FORMRECOGNIZER_RESOURCE_GROUP }}
41-
AZURE_FORMRECOGNIZER_SKU: ${{ vars.AZURE_FORMRECOGNIZER_SKU }}
4243
AZURE_SEARCH_INDEX: ${{ vars.AZURE_SEARCH_INDEX }}
4344
AZURE_SEARCH_SERVICE: ${{ vars.AZURE_SEARCH_SERVICE }}
4445
AZURE_SEARCH_SERVICE_RESOURCE_GROUP: ${{ vars.AZURE_SEARCH_SERVICE_RESOURCE_GROUP }}
@@ -62,6 +63,11 @@ jobs:
6263
AZURE_OPENAI_EMB_DEPLOYMENT_CAPACITY: ${{ vars.AZURE_OPENAI_EMB_DEPLOYMENT_CAPACITY }}
6364
AZURE_OPENAI_EMB_DEPLOYMENT_VERSION: ${{ vars.AZURE_OPENAI_EMB_DEPLOYMENT_VERSION }}
6465
AZURE_OPENAI_EMB_DIMENSIONS: ${{ vars.AZURE_OPENAI_EMB_DIMENSIONS }}
66+
AZURE_OPENAI_GPT4V_MODEL: ${{ vars.AZURE_OPENAI_GPT4V_MODEL }}
67+
AZURE_OPENAI_GPT4V_DEPLOYMENT: ${{ vars.AZURE_OPENAI_GPT4V_DEPLOYMENT }}
68+
AZURE_OPENAI_GPT4V_DEPLOYMENT_CAPACITY: ${{ vars.AZURE_OPENAI_GPT4V_DEPLOYMENT_CAPACITY }}
69+
AZURE_OPENAI_GPT4V_DEPLOYMENT_VERSION: ${{ vars.AZURE_OPENAI_GPT4V_DEPLOYMENT_VERSION }}
70+
AZURE_OPENAI_GPT4V_DEPLOYMENT_SKU: ${{ vars.AZURE_OPENAI_GPT4V_DEPLOYMENT_SKU }}
6571
OPENAI_HOST: ${{ vars.OPENAI_HOST }}
6672
OPENAI_API_KEY: ${{ vars.OPENAI_API_KEY }}
6773
OPENAI_ORGANIZATION: ${{ vars.OPENAI_ORGANIZATION }}
@@ -81,6 +87,7 @@ jobs:
8187
AZURE_SPEECH_SERVICE_RESOURCE_GROUP: ${{ vars.AZURE_SPEECH_RESOURCE_GROUP }}
8288
AZURE_SPEECH_SERVICE_LOCATION: ${{ vars.AZURE_SPEECH_SERVICE_LOCATION }}
8389
AZURE_SPEECH_SERVICE_SKU: ${{ vars.AZURE_SPEECH_SERVICE_SKU }}
90+
AZURE_SPEECH_SERVICE_VOICE: ${{ vars.AZURE_SPEECH_SERVICE_VOICE }}
8491
AZURE_KEY_VAULT_NAME: ${{ vars.AZURE_KEY_VAULT_NAME }}
8592
AZURE_USE_AUTHENTICATION: ${{ vars.AZURE_USE_AUTHENTICATION }}
8693
AZURE_ENFORCE_ACCESS_CONTROL: ${{ vars.AZURE_ENFORCE_ACCESS_CONTROL }}

.github/workflows/python-test.yaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,21 @@ jobs:
2626
matrix:
2727
os: ["ubuntu-20.04", "windows-latest"]
2828
python_version: ["3.9", "3.10", "3.11", "3.12"]
29+
env:
30+
UV_SYSTEM_PYTHON: 1
2931
steps:
3032
- uses: actions/checkout@v4
3133
- name: Setup python
3234
uses: actions/setup-python@v5
3335
with:
3436
python-version: ${{ matrix.python_version }}
3537
architecture: x64
38+
- name: Install uv
39+
uses: astral-sh/setup-uv@v3
40+
with:
41+
enable-cache: true
42+
version: "0.4.20"
43+
cache-dependency-glob: "requirements**.txt"
3644
- name: Setup node
3745
uses: actions/setup-node@v4
3846
with:
@@ -44,8 +52,7 @@ jobs:
4452
npm run build
4553
- name: Install dependencies
4654
run: |
47-
python -m pip install --upgrade pip
48-
pip install -r requirements-dev.txt
55+
uv pip install -r requirements-dev.txt
4956
- name: Lint with ruff
5057
run: ruff check .
5158
- name: Check types with mypy

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
exclude: '^tests/snapshots/'
22
repos:
33
- repo: https://github.com/pre-commit/pre-commit-hooks
4-
rev: v4.5.0
4+
rev: v5.0.0
55
hooks:
66
- id: check-yaml
77
- id: end-of-file-fixer
88
- id: trailing-whitespace
99
- repo: https://github.com/astral-sh/ruff-pre-commit
10-
rev: v0.1.14
10+
rev: v0.7.2
1111
hooks:
1212
- id: ruff
1313
- repo: https://github.com/psf/black
14-
rev: 24.1.0
14+
rev: 24.10.0
1515
hooks:
1616
- id: black
1717
- repo: https://github.com/pre-commit/mirrors-prettier

.vscode/launch.json

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"version": "0.2.0",
66
"configurations": [
77
{
8-
"name": "Python: Quart",
8+
"name": "Backend (Python)",
99
"type": "debugpy",
1010
"request": "launch",
1111
"module": "quart",
@@ -22,18 +22,17 @@
2222
"-p 50505"
2323
],
2424
"console": "integratedTerminal",
25-
"justMyCode": false,
26-
"envFile": "${input:dotEnvFilePath}",
25+
"justMyCode": false
2726
},
2827
{
29-
"name": "Frontend: watch",
28+
"name": "Frontend",
3029
"type": "node-terminal",
3130
"request": "launch",
3231
"command": "npm run dev",
3332
"cwd": "${workspaceFolder}/app/frontend",
3433
},
3534
{
36-
"name": "Python: Debug Tests",
35+
"name": "Tests (Python)",
3736
"type": "debugpy",
3837
"request": "launch",
3938
"program": "${file}",
@@ -42,11 +41,11 @@
4241
"justMyCode": false
4342
}
4443
],
45-
"inputs": [
44+
"compounds": [
4645
{
47-
"id": "dotEnvFilePath",
48-
"type": "command",
49-
"command": "azure-dev.commands.getDotEnvFilePath"
46+
"name": "Frontend & Backend",
47+
"configurations": ["Backend (Python)", "Frontend"],
48+
"stopAll": true
5049
}
5150
]
5251
}

.vscode/tasks.json

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,6 @@
33
"tasks": [
44
{
55
"label": "Start App",
6-
"type": "dotenv",
7-
"targetTasks": [
8-
"Start App (Script)"
9-
],
10-
"file": "${input:dotEnvFilePath}"
11-
},
12-
{
13-
"label": "Start App (Script)",
146
"type": "shell",
157
"command": "${workspaceFolder}/app/start.sh",
168
"windows": {
@@ -24,12 +16,5 @@
2416
},
2517
"problemMatcher": []
2618
}
27-
],
28-
"inputs": [
29-
{
30-
"id": "dotEnvFilePath",
31-
"type": "command",
32-
"command": "azure-dev.commands.getDotEnvFilePath"
33-
}
3419
]
35-
}
20+
}

README.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
1-
# ChatGPT-like app with your data using Azure OpenAI and Azure AI Search (Python)
1+
<!--
2+
---
3+
name: RAG chat app with your data (Python)
4+
description: Chat with your domain data using Azure OpenAI and Azure AI Search.
5+
languages:
6+
- python
7+
- typescript
8+
- bicep
9+
- azdeveloper
10+
products:
11+
- azure-openai
12+
- azure-cognitive-search
13+
- azure-app-service
14+
- azure
15+
page_type: sample
16+
urlFragment: azure-search-openai-demo
17+
---
18+
-->
19+
20+
# RAG chat app with Azure OpenAI and Azure AI Search (Python)
21+
22+
This solution creates a ChatGPT-like frontend experience over your own documents using RAG (Retrieval Augmented Generation). It uses Azure OpenAI Service to access GPT models, and Azure AI Search for data indexing and retrieval.
223

324
This solution's backend is written in Python. There are also [**JavaScript**](https://aka.ms/azai/js/code), [**.NET**](https://aka.ms/azai/net/code), and [**Java**](https://aka.ms/azai/java/code) samples based on this one. Learn more about [developing AI apps using Azure AI Services](https://aka.ms/azai).
425

@@ -65,8 +86,8 @@ The repo includes sample data so it's ready to try end to end. In this sample ap
6586
Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage.
6687
However, you can try the [Azure pricing calculator](https://azure.com/e/a87a169b256e43c089015fda8182ca87) for the resources below.
6788

68-
- Azure App Service: Basic Tier with 1 CPU core, 1.75 GB RAM. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/app-service/linux/)
69-
- Azure Container Apps: Only provisioned if you deploy to Azure Container Apps following [the ACA deployment guide](docs/azure_container_apps.md). Consumption plan with 1 CPU core, 2.0 GB RAM. Pricing with Pay-as-You-Go. [Pricing](https://azure.microsoft.com/pricing/details/container-apps/)
89+
- Azure Container Apps: Default host for app deployment as of 10/28/2024. See more details in [the ACA deployment guide](docs/azure_container_apps.md). Consumption plan with 1 CPU core, 2.0 GB RAM. Pricing with Pay-as-You-Go. [Pricing](https://azure.microsoft.com/pricing/details/container-apps/)
90+
- Azure App Service: Only provisioned if you deploy to Azure App Service following [the App Service deployment guide](docs/azure_app_service.md). Basic Tier with 1 CPU core, 1.75 GB RAM. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/app-service/linux/)
7091
- Azure OpenAI: Standard tier, GPT and Ada models. Pricing per 1K tokens used, and at least 1K tokens are used per question. [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/)
7192
- Azure AI Document Intelligence: SO (Standard) tier using pre-built layout. Pricing per document page, sample documents have 261 pages total. [Pricing](https://azure.microsoft.com/pricing/details/form-recognizer/)
7293
- Azure AI Search: Basic tier, 1 replica, free level of semantic search. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/search/)
@@ -128,7 +149,7 @@ A related option is VS Code Dev Containers, which will open the project in your
128149

129150
## Deploying
130151

131-
The steps below will provision Azure resources and deploy the application code to Azure App Service. To deploy to Azure Container Apps instead, follow [the container apps deployment guide](docs/azure_container_apps.md).
152+
The steps below will provision Azure resources and deploy the application code to Azure Container Apps. To deploy to Azure App Service instead, follow [the app service deployment guide](docs/azure_app_service.md).
132153

133154
1. Login to your Azure account:
134155

@@ -137,6 +158,7 @@ The steps below will provision Azure resources and deploy the application code t
137158
```
138159

139160
For GitHub Codespaces users, if the previous command fails, try:
161+
140162
```shell
141163
azd auth login --use-device-code
142164
```
@@ -158,7 +180,7 @@ It will look like the following:
158180

159181
!['Output from running azd up'](docs/images/endpoint.png)
160182

161-
> NOTE: It may take 5-10 minutes after you see 'SUCCESS' for the application to be fully deployed. If you see a "Python Developer" welcome screen or an error page, then wait a bit and refresh the page. See [guide on debugging App Service deployments](docs/appservice.md).
183+
> NOTE: It may take 5-10 minutes after you see 'SUCCESS' for the application to be fully deployed. If you see a "Python Developer" welcome screen or an error page, then wait a bit and refresh the page.
162184

163185
### Deploying again
164186

@@ -262,16 +284,16 @@ Here are the most common failure scenarios and solutions:
262284
263285
1. You see `CERTIFICATE_VERIFY_FAILED` when the `prepdocs.py` script runs. That's typically due to incorrect SSL certificates setup on your machine. Try the suggestions in this [StackOverflow answer](https://stackoverflow.com/questions/35569042/ssl-certificate-verify-failed-with-python3/43855394#43855394).
264286

265-
1. After running `azd up` and visiting the website, you see a '404 Not Found' in the browser. Wait 10 minutes and try again, as it might be still starting up. Then try running `azd deploy` and wait again. If you still encounter errors with the deployed app, consult the [guide on debugging App Service deployments](docs/appservice.md). Please file an issue if the logs don't help you resolve the error.
287+
1. After running `azd up` and visiting the website, you see a '404 Not Found' in the browser. Wait 10 minutes and try again, as it might be still starting up. Then try running `azd deploy` and wait again. If you still encounter errors with the deployed app and are deploying to App Service, consult the [guide on debugging App Service deployments](docs/appservice.md). Please file an issue if the logs don't help you resolve the error.
266288
267289
### Resources
268290
269291
- [Additional documentation for this app](docs/README.md)
270-
- [📖 Revolutionize your Enterprise Data with ChatGPT: Next-gen Apps w/ Azure OpenAI and AI Search](https://aka.ms/entgptsearchblog)
292+
- [📖 Revolutionize your Enterprise Data with ChatGPT: Next-gen Apps w/ Azure OpenAI and AI Search](https://techcommunity.microsoft.com/blog/azure-ai-services-blog/revolutionize-your-enterprise-data-with-chatgpt-next-gen-apps-w-azure-openai-and/3762087)
271293
- [📖 Azure AI Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search)
272294
- [📖 Azure OpenAI Service](https://learn.microsoft.com/azure/cognitive-services/openai/overview)
273295
- [📖 Comparing Azure OpenAI and OpenAI](https://learn.microsoft.com/azure/cognitive-services/openai/overview#comparing-azure-openai-and-openai/)
274-
- [📖 Access Control in Generative AI applications with Azure Cognitive Search](https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/access-control-in-generative-ai-applications-with-azure/ba-p/3956408)
296+
- [📖 Access Control in Generative AI applications with Azure AI Search](https://techcommunity.microsoft.com/blog/azure-ai-services-blog/access-control-in-generative-ai-applications-with-azure-ai-search/3956408)
275297
- [📺 Quickly build and deploy OpenAI apps on Azure, infused with your own data](https://www.youtube.com/watch?v=j8i-OM5kwiY)
276298
- [📺 AI Chat App Hack series](https://www.youtube.com/playlist?list=PL5lwDBUC0ag6_dGZst5m3G72ewfwXLcXV)
277299

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ We prefer all communications to be in English.
3838

3939
Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/msrc/cvd).
4040

41-
<!-- END MICROSOFT SECURITY.MD BLOCK -->
41+
<!-- END MICROSOFT SECURITY.MD BLOCK -->

app/backend/app.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,8 @@ async def setup_clients():
429429
)
430430
AZURE_OPENAI_EMB_DEPLOYMENT = os.getenv("AZURE_OPENAI_EMB_DEPLOYMENT") if OPENAI_HOST.startswith("azure") else None
431431
AZURE_OPENAI_CUSTOM_URL = os.getenv("AZURE_OPENAI_CUSTOM_URL")
432+
# https://learn.microsoft.com/azure/ai-services/openai/api-version-deprecation#latest-ga-api-release
433+
AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION") or "2024-06-01"
432434
AZURE_VISION_ENDPOINT = os.getenv("AZURE_VISION_ENDPOINT", "")
433435
# Used only with non-Azure OpenAI deployments
434436
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
@@ -453,7 +455,7 @@ async def setup_clients():
453455

454456
AZURE_SPEECH_SERVICE_ID = os.getenv("AZURE_SPEECH_SERVICE_ID")
455457
AZURE_SPEECH_SERVICE_LOCATION = os.getenv("AZURE_SPEECH_SERVICE_LOCATION")
456-
AZURE_SPEECH_VOICE = os.getenv("AZURE_SPEECH_VOICE", "en-US-AndrewMultilingualNeural")
458+
AZURE_SPEECH_SERVICE_VOICE = os.getenv("AZURE_SPEECH_SERVICE_VOICE") or "en-US-AndrewMultilingualNeural"
457459

458460
USE_GPT4V = os.getenv("USE_GPT4V", "").lower() == "true"
459461
USE_USER_UPLOAD = os.getenv("USE_USER_UPLOAD", "").lower() == "true"
@@ -560,6 +562,7 @@ async def setup_clients():
560562
openai_custom_url=AZURE_OPENAI_CUSTOM_URL,
561563
openai_deployment=AZURE_OPENAI_EMB_DEPLOYMENT,
562564
openai_dimensions=OPENAI_EMB_DIMENSIONS,
565+
openai_api_version=AZURE_OPENAI_API_VERSION,
563566
openai_key=clean_key_if_exists(OPENAI_API_KEY),
564567
openai_org=OPENAI_ORGANIZATION,
565568
disable_vectors=os.getenv("USE_VECTORS", "").lower() == "false",
@@ -580,12 +583,11 @@ async def setup_clients():
580583
raise ValueError("Azure speech resource not configured correctly, missing AZURE_SPEECH_SERVICE_LOCATION")
581584
current_app.config[CONFIG_SPEECH_SERVICE_ID] = AZURE_SPEECH_SERVICE_ID
582585
current_app.config[CONFIG_SPEECH_SERVICE_LOCATION] = AZURE_SPEECH_SERVICE_LOCATION
583-
current_app.config[CONFIG_SPEECH_SERVICE_VOICE] = AZURE_SPEECH_VOICE
586+
current_app.config[CONFIG_SPEECH_SERVICE_VOICE] = AZURE_SPEECH_SERVICE_VOICE
584587
# Wait until token is needed to fetch for the first time
585588
current_app.config[CONFIG_SPEECH_SERVICE_TOKEN] = None
586589

587590
if OPENAI_HOST.startswith("azure"):
588-
api_version = os.getenv("AZURE_OPENAI_API_VERSION") or "2024-03-01-preview"
589591
if OPENAI_HOST == "azure_custom":
590592
current_app.logger.info("OPENAI_HOST is azure_custom, setting up Azure OpenAI custom client")
591593
if not AZURE_OPENAI_CUSTOM_URL:
@@ -598,12 +600,14 @@ async def setup_clients():
598600
endpoint = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.com"
599601
if api_key := os.getenv("AZURE_OPENAI_API_KEY_OVERRIDE"):
600602
current_app.logger.info("AZURE_OPENAI_API_KEY_OVERRIDE found, using as api_key for Azure OpenAI client")
601-
openai_client = AsyncAzureOpenAI(api_version=api_version, azure_endpoint=endpoint, api_key=api_key)
603+
openai_client = AsyncAzureOpenAI(
604+
api_version=AZURE_OPENAI_API_VERSION, azure_endpoint=endpoint, api_key=api_key
605+
)
602606
else:
603607
current_app.logger.info("Using Azure credential (passwordless authentication) for Azure OpenAI client")
604608
token_provider = get_bearer_token_provider(azure_credential, "https://cognitiveservices.azure.com/.default")
605609
openai_client = AsyncAzureOpenAI(
606-
api_version=api_version,
610+
api_version=AZURE_OPENAI_API_VERSION,
607611
azure_endpoint=endpoint,
608612
azure_ad_token_provider=token_provider,
609613
)

app/backend/approaches/approach.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,12 @@ def __init__(
123123
self.vision_token_provider = vision_token_provider
124124

125125
def build_filter(self, overrides: dict[str, Any], auth_claims: dict[str, Any]) -> Optional[str]:
126+
include_category = overrides.get("include_category")
126127
exclude_category = overrides.get("exclude_category")
127128
security_filter = self.auth_helper.build_security_filters(overrides, auth_claims)
128129
filters = []
130+
if include_category:
131+
filters.append("category eq '{}'".format(include_category.replace("'", "''")))
129132
if exclude_category:
130133
filters.append("category ne '{}'".format(exclude_category.replace("'", "''")))
131134
if security_filter:

0 commit comments

Comments
 (0)