diff --git a/.flake8 b/.flake8
new file mode 100644
index 000000000..93f63e5d1
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,5 @@
+[flake8]
+max-line-length = 88
+extend-ignore = E501
+exclude = .venv, frontend
+ignore = E203, W503, G004, G200
\ No newline at end of file
diff --git a/.github/workflows/broken-links-checker.yml b/.github/workflows/broken-links-checker.yml
new file mode 100644
index 000000000..3b19db1df
--- /dev/null
+++ b/.github/workflows/broken-links-checker.yml
@@ -0,0 +1,58 @@
+name: Broken Link Checker
+
+on:
+ pull_request:
+ paths:
+ - '**/*.md'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ markdown-link-check:
+ name: Check Markdown Broken Links
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ # For PR : Get only changed markdown files
+ - name: Get changed markdown files (PR only)
+ id: changed-markdown-files
+ if: github.event_name == 'pull_request'
+ uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v46
+ with:
+ files: |
+ **/*.md
+
+
+ # For PR: Check broken links only in changed files
+ - name: Check Broken Links in Changed Markdown Files
+ id: lychee-check-pr
+ if: github.event_name == 'pull_request' && steps.changed-markdown-files.outputs.any_changed == 'true'
+ uses: lycheeverse/lychee-action@v2.7.0
+ with:
+ args: >
+ --verbose --no-progress --exclude ^https?://
+ ${{ steps.changed-markdown-files.outputs.all_changed_files }}
+ failIfEmpty: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # For manual trigger: Check all markdown files in repo
+ - name: Check Broken Links in All Markdown Files in Entire Repo (Manual Trigger)
+ id: lychee-check-manual
+ if: github.event_name == 'workflow_dispatch'
+ uses: lycheeverse/lychee-action@v2.7.0
+ with:
+ args: >
+ --verbose --no-progress --exclude ^https?://
+ '**/*.md'
+ failIfEmpty: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..ac9b1b756
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,44 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ "main" ]
+ paths:
+ - '**/*.py'
+ - '.github/workflows/codeql.yml'
+ pull_request:
+ branches: [ "main" ]
+ paths:
+ - '**/*.py'
+ - '.github/workflows/codeql.yml'
+ schedule:
+ - cron: '17 11 * * 0'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ timeout-minutes: 360
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'python' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
new file mode 100644
index 000000000..c9e3f788a
--- /dev/null
+++ b/.github/workflows/pylint.yml
@@ -0,0 +1,37 @@
+name: PyLint
+
+on:
+ push:
+ paths:
+ - 'content-gen/src/backend/**/*.py'
+ - 'content-gen/src/backend/requirements*.txt'
+ - '.flake8'
+ - '.github/workflows/pylint.yml'
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.11"]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r content-gen/src/backend/requirements.txt
+ pip install flake8 # Ensure flake8 is installed explicitly
+
+ - name: Run flake8
+ run: |
+ flake8 --config=.flake8 content-gen/src/backend # Specify the directory to lint
diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml
new file mode 100644
index 000000000..99bbca6d8
--- /dev/null
+++ b/.github/workflows/stale-bot.yml
@@ -0,0 +1,74 @@
+name: "Manage Stale Issues, PRs & Unmerged Branches"
+on:
+ schedule:
+ - cron: '30 1 * * *' # Runs daily at 1:30 AM UTC
+ workflow_dispatch: # Allows manual triggering
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Mark Stale Issues and PRs
+ uses: actions/stale@v10
+ with:
+ stale-issue-message: "This issue is stale because it has been open 180 days with no activity. Remove stale label or comment, or it will be closed in 30 days."
+ stale-pr-message: "This PR is stale because it has been open 180 days with no activity. Please update or it will be closed in 30 days."
+ days-before-stale: 180
+ days-before-close: 30
+ exempt-issue-labels: "keep"
+ exempt-pr-labels: "keep"
+ cleanup-branches:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # Fetch full history for accurate branch checks
+ - name: Fetch All Branches
+ run: git fetch --all --prune
+ - name: List Merged Branches With No Activity in Last 3 Months
+ run: |
+ echo "Branch Name,Last Commit Date,Committer,Committed In Branch,Action" > merged_branches_report.csv
+ for branch in $(git for-each-ref --format '%(refname:short) %(committerdate:unix)' refs/remotes/origin | awk -v date=$(date -d '3 months ago' +%s) '$2 < date {print $1}'); do
+ if [[ "$branch" != "origin/main" && "$branch" != "origin/dev" && "$branch" != "origin/demo" ]]; then
+ branch_name=${branch#origin/}
+ git fetch origin "$branch_name" || echo "Could not fetch branch: $branch_name"
+ last_commit_date=$(git log -1 --format=%ci "origin/$branch_name" || echo "Unknown")
+ committer_name=$(git log -1 --format=%cn "origin/$branch_name" || echo "Unknown")
+ committed_in_branch=$(git branch -r --contains "origin/$branch_name" | tr -d ' ' | paste -sd "," -)
+ echo "$branch_name,$last_commit_date,$committer_name,$committed_in_branch,Delete" >> merged_branches_report.csv
+ fi
+ done
+ - name: List PR Approved and Merged Branches Older Than 30 Days
+ run: |
+ for branch in $(gh api repos/${{ github.repository }}/pulls --state closed --paginate --jq '.[] | select(.merged_at != null and (.base.ref == "main" or .base.ref == "dev" or .base.ref == "demo")) | select(.merged_at | fromdateiso8601 < (now - 2592000)) | .head.ref'); do
+ git fetch origin "$branch" || echo "Could not fetch branch: $branch"
+ last_commit_date=$(git log -1 --format=%ci origin/$branch || echo "Unknown")
+ committer_name=$(git log -1 --format=%cn origin/$branch || echo "Unknown")
+ committed_in_branch=$(git branch -r --contains "origin/$branch" | tr -d ' ' | paste -sd "," -)
+ echo "$branch,$last_commit_date,$committer_name,$committed_in_branch,Delete" >> merged_branches_report.csv
+ done
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: List Open PR Branches With No Activity in Last 3 Months
+ run: |
+ for branch in $(gh api repos/${{ github.repository }}/pulls --state open --jq '.[] | select(.base.ref == "main" or .base.ref == "dev" or .base.ref == "demo") | .head.ref'); do
+ git fetch origin "$branch" || echo "Could not fetch branch: $branch"
+ last_commit_date=$(git log -1 --format=%ci origin/$branch || echo "Unknown")
+ committer_name=$(git log -1 --format=%cn origin/$branch || echo "Unknown")
+ if [[ $(date -d "$last_commit_date" +%s) -lt $(date -d '3 months ago' +%s) ]]; then
+ committed_in_branch=$(git branch -r --contains "origin/$branch" | tr -d ' ' | paste -sd "," -)
+ echo "$branch,$last_commit_date,$committer_name,$committed_in_branch,Delete" >> merged_branches_report.csv
+ fi
+ done
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Upload CSV Report of Inactive Branches
+ uses: actions/upload-artifact@v6
+ with:
+ name: merged-branches-report
+ path: merged_branches_report.csv
+ retention-days: 30
diff --git a/.github/workflows/telemetry-template-check.yml b/.github/workflows/telemetry-template-check.yml
new file mode 100644
index 000000000..f3688c5e0
--- /dev/null
+++ b/.github/workflows/telemetry-template-check.yml
@@ -0,0 +1,34 @@
+name: validate template property for telemetry
+
+on:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'content-gen/azure.yaml'
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ validate-template-property:
+ name: validate-template-property
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Check for required metadata template line
+ run: |
+ if grep -E '^\s*#\s*template:\s*content-generation@' content-gen/azure.yaml; then
+ echo "ERROR: 'template' line is commented out in content-gen/azure.yaml! Please uncomment template line."
+ exit 1
+ fi
+
+ if ! grep -E '^\s*template:\s*content-generation@' content-gen/azure.yaml; then
+ echo "ERROR: Required 'template' line is missing in content-gen/azure.yaml! Please add template line for telemetry."
+ exit 1
+ fi
+ echo "template line is present and not commented."
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..e2c1e902b
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,69 @@
+name: Test Workflow with Coverage
+
+on:
+ push:
+ branches:
+ - main
+ - dev
+ paths:
+ - '**/*.py'
+ - 'content-gen/src/backend/requirements*.txt'
+ - '.github/workflows/test.yml'
+ pull_request:
+ types:
+ - opened
+ - ready_for_review
+ - reopened
+ - synchronize
+ branches:
+ - main
+ - dev
+ paths:
+ - '**/*.py'
+ - 'content-gen/src/backend/requirements*.txt'
+ - '.github/workflows/test.yml'
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ backend_tests:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+
+ - name: Install Backend Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r content-gen/src/backend/requirements.txt
+ pip install pytest-cov
+ pip install pytest-asyncio
+
+ - name: Check if Backend Test Files Exist
+ id: check_backend_tests
+ run: |
+ if [ -z "$(find content-gen/src/tests -type f -name 'test_*.py' 2>/dev/null)" ]; then
+ echo "No backend test files found, skipping backend tests."
+ echo "skip_backend_tests=true" >> $GITHUB_ENV
+ else
+ echo "Backend test files found, running tests."
+ echo "skip_backend_tests=false" >> $GITHUB_ENV
+ fi
+
+ - name: Run Backend Tests with Coverage
+ if: env.skip_backend_tests == 'false'
+ run: |
+ pytest --cov=. --cov-report=term-missing --cov-report=xml ./content-gen/src/tests
+
+ - name: Skip Backend Tests
+ if: env.skip_backend_tests == 'true'
+ run: |
+ echo "Skipping backend tests because no test files were found."
diff --git a/README.md b/README.md
index c8438f615..9055a8dba 100644
--- a/README.md
+++ b/README.md
@@ -78,13 +78,13 @@ Follow the quick deploy steps on the deployment guide to deploy this solution to
> β οΈ **Important: Check Azure OpenAI Quota Availability**
-
To ensure sufficient quota is available in your subscription, please follow [quota check instructions guide](./docs/QuotaCheck.md) before you deploy the solution.
+
To ensure sufficient quota is available in your subscription, please follow [quota check instructions guide](./content-gen/docs/QuotaCheck.md) before you deploy the solution.
### Prerequisites and costs
-To deploy this solution accelerator, ensure you have access to an [Azure subscription](https://azure.microsoft.com/free/) with the necessary permissions to create **resource groups, resources, app registrations, and assign roles at the resource group level**. This should include Contributor role at the subscription level and Role Based Access Control role on the subscription and/or resource group level. Follow the steps in [Azure Account Set Up](./docs/AzureAccountSetUp.md).
+To deploy this solution accelerator, ensure you have access to an [Azure subscription](https://azure.microsoft.com/free/) with the necessary permissions to create **resource groups, resources, app registrations, and assign roles at the resource group level**. This should include Contributor role at the subscription level and Role Based Access Control role on the subscription and/or resource group level. Follow the steps in [Azure Account Set Up](./content-gen/docs/AzureAccountSetUp.md).
Check the [Azure Products by Region](https://azure.microsoft.com/en-us/explore/global-infrastructure/products-by-region/?products=all®ions=all) page and select a **region** where the following services are available.
diff --git a/content-gen/README.md b/content-gen/README.md
index df7179765..810e0e2ad 100644
--- a/content-gen/README.md
+++ b/content-gen/README.md
@@ -183,8 +183,7 @@ BRAND_SECONDARY_COLOR=#107C10
- [AZD Deployment Guide](docs/AZD_DEPLOYMENT.md) - Deploy with Azure Developer CLI
- [Manual Deployment Guide](docs/DEPLOYMENT.md) - Step-by-step manual deployment
- [Image Generation Configuration](docs/IMAGE_GENERATION.md) - DALL-E 3 and GPT-Image-1 setup
-- [API Reference](docs/API.md)
## License
-MIT License - See [LICENSE](LICENSE) for details.
+MIT License - See [LICENSE](../LICENSE) for details.
diff --git a/content-gen/docs/AZD_DEPLOYMENT.md b/content-gen/docs/AZD_DEPLOYMENT.md
index 693b888ba..3d65e5216 100644
--- a/content-gen/docs/AZD_DEPLOYMENT.md
+++ b/content-gen/docs/AZD_DEPLOYMENT.md
@@ -58,6 +58,11 @@ azd auth login
# Login to Azure CLI (required for some post-deployment scripts)
az login
+```
+ Alternatively, login to Azure using a device code (recommended when using VS Code Web):
+
+```
+az login --use-device-code
```
### 2. Initialize Environment
@@ -72,52 +77,30 @@ azd env new
azd env new content-gen-dev
```
-### 3. Configure Parameters (Optional)
-
-The deployment has sensible defaults, but you can customize:
-
-```bash
-# Set the Azure region (default: eastus)
-azd env set AZURE_LOCATION swedencentral
-
-# Set AI Services region (must support your models)
-azd env set AZURE_ENV_OPENAI_LOCATION swedencentral
+### 3. Choose Deployment Configuration
-# GPT Model configuration
-azd env set gptModelName gpt-4o
-azd env set gptModelVersion 2024-11-20
-azd env set gptModelDeploymentType GlobalStandard
-azd env set gptModelCapacity 50
+The [`infra`](../infra) folder contains the [`main.bicep`](../infra/main.bicep) Bicep script, which defines all Azure infrastructure components for this solution.
-# Image generation model (dalle-3 or gpt-image-1)
-azd env set imageModelChoice gpt-image-1
-azd env set dalleModelCapacity 1
+By default, the `azd up` command uses the [`main.parameters.json`](../infra/main.parameters.json) file to deploy the solution. This file is pre-configured for a **sandbox environment**.
-# Embedding model
-azd env set embeddingModel text-embedding-3-large
-azd env set embeddingDeploymentCapacity 50
+For **production deployments**, the repository also provides [`main.waf.parameters.json`](../infra/main.waf.parameters.json), which applies a [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) configuration. This can be used for Production scenarios.
-# Azure OpenAI API version
-azd env set azureOpenaiAPIVersion 2024-12-01-preview
-```
+**How to choose your deployment configuration:**
-### 4. Enable Optional Features (WAF Pillars)
-
-```bash
-# Enable private networking (VNet integration)
-azd env set enablePrivateNetworking true
+* **To use sandbox/dev environment** β Use the default `main.parameters.json` file.
-# Enable monitoring (Log Analytics + App Insights)
-azd env set enableMonitoring true
+* **To use production configuration:**
-# Enable scalability (auto-scaling, higher SKUs)
-azd env set enableScalability true
+Before running `azd up`, copy the contents from the production configuration file to your main parameters file:
-# Enable redundancy (zone redundancy, geo-replication)
-azd env set enableRedundancy true
-```
+1. Navigate to the `infra` folder in your project.
+2. Open `main.waf.parameters.json` in a text editor (like Notepad, VS Code, etc.).
+3. Select all content (Ctrl+A) and copy it (Ctrl+C).
+4. Open `main.parameters.json` in the same text editor.
+5. Select all existing content (Ctrl+A) and paste the copied content (Ctrl+V).
+6. Save the file (Ctrl+S).
-### 5. Deploy
+### 4. Deploy
```bash
azd up
@@ -132,25 +115,6 @@ This single command will:
6. **Configure** RBAC and Cosmos DB roles
7. **Upload** sample data and create the search index
-## Deployment Parameters Reference
-
-| Parameter | Default | Description |
-|-----------|---------|-------------|
-| `AZURE_LOCATION` | eastus | Primary Azure region |
-| `azureAiServiceLocation` | eastus | Region for AI Services (must support chosen models) |
-| `gptModelName` | gpt-4o | GPT model for content generation |
-| `gptModelVersion` | 2024-11-20 | Model version |
-| `gptModelDeploymentType` | GlobalStandard | Deployment type |
-| `gptModelCapacity` | 50 | TPM capacity (in thousands) |
-| `imageModelChoice` | dalle-3 | Image model: `dalle-3` or `gpt-image-1` |
-| `dalleModelCapacity` | 1 | Image model capacity |
-| `embeddingModel` | text-embedding-3-large | Embedding model |
-| `embeddingDeploymentCapacity` | 50 | Embedding TPM capacity |
-| `enablePrivateNetworking` | false | Enable VNet and private endpoints |
-| `enableMonitoring` | false | Enable Log Analytics + App Insights |
-| `enableScalability` | false | Enable auto-scaling |
-| `enableRedundancy` | false | Enable zone/geo redundancy |
-
## Using Existing Resources
### Reuse Existing AI Foundry Project
@@ -167,13 +131,6 @@ azd env set AZURE_EXISTING_AI_PROJECT_RESOURCE_ID "/subscriptions//resou
azd env set AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID "/subscriptions//resourceGroups//providers/Microsoft.OperationalInsights/workspaces/"
```
-### Use Existing Container Registry
-
-```bash
-# Set the name of your existing ACR
-azd env set ACR_NAME myexistingacr
-```
-
## Post-Deployment
After `azd up` completes, you'll see output like:
@@ -205,51 +162,6 @@ azd env get-value WEB_APP_URL
azd env get-value RESOURCE_GROUP_NAME
```
-## Day-2 Operations
-
-### Update the Application
-
-After making code changes:
-
-```bash
-# Rebuild and redeploy everything
-azd up
-
-# Or just redeploy (no infra changes)
-azd deploy
-```
-
-### Update Only the Backend (Container)
-
-```bash
-# Get ACR and ACI names
-ACR_NAME=$(azd env get-value ACR_NAME)
-ACI_NAME=$(azd env get-value CONTAINER_INSTANCE_NAME)
-RG_NAME=$(azd env get-value RESOURCE_GROUP_NAME)
-
-# Build and push new image
-az acr build --registry $ACR_NAME --image content-gen-app:latest --file ./src/WebApp.Dockerfile ./src
-
-# Restart ACI to pull new image
-az container restart --name $ACI_NAME --resource-group $RG_NAME
-```
-
-### Update Only the Frontend
-
-```bash
-cd src/app/frontend
-npm install && npm run build
-
-cd ../frontend-server
-zip -r frontend-deploy.zip static/ server.js package.json package-lock.json
-
-az webapp deploy \
- --resource-group $(azd env get-value RESOURCE_GROUP_NAME) \
- --name $(azd env get-value APP_SERVICE_NAME) \
- --src-path frontend-deploy.zip \
- --type zip
-```
-
### View Logs
```bash
@@ -324,7 +236,7 @@ Error: az acr build failed
**Solution**: Check the Dockerfile and ensure all required files are present:
```bash
# Manual build for debugging
-cd src
+cd src/app
docker build -f WebApp.Dockerfile -t content-gen-app:test .
```
@@ -396,6 +308,5 @@ When `enablePrivateNetworking` is enabled:
## Related Documentation
-- [Manual Deployment Guide](DEPLOYMENT.md)
- [Image Generation Configuration](IMAGE_GENERATION.md)
- [Azure Developer CLI Documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/)
diff --git a/content-gen/docs/AzureAccountSetUp.md b/content-gen/docs/AzureAccountSetUp.md
new file mode 100644
index 000000000..ce56466c8
--- /dev/null
+++ b/content-gen/docs/AzureAccountSetUp.md
@@ -0,0 +1,14 @@
+## Azure account setup
+
+1. Sign up for a [free Azure account](https://azure.microsoft.com/free/) and create an Azure Subscription.
+2. Check that you have the necessary permissions:
+ * Your Azure account must have `Microsoft.Authorization/roleAssignments/write` permissions, such as [Role Based Access Control Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#role-based-access-control-administrator-preview), [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator), or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner).
+ * Your Azure account also needs `Microsoft.Resources/deployments/write` permissions on the subscription level.
+
+You can view the permissions for your account and subscription by following the steps below:
+- Navigate to the [Azure Portal](https://portal.azure.com/) and click on `Subscriptions` under 'Navigation'
+- Select the subscription you are using for this accelerator from the list.
+ - If you try to search for your subscription and it does not come up, make sure no filters are selected.
+- Select `Access control (IAM)` and you can see the roles that are assigned to your account for this subscription.
+ - If you want to see more information about the roles, you can go to the `Role assignments`
+ tab and search by your account name and then click the role you want to view more information about.
diff --git a/content-gen/docs/DEPLOYMENT.md b/content-gen/docs/DEPLOYMENT.md
index 7100cf403..8d4bf2ee5 100644
--- a/content-gen/docs/DEPLOYMENT.md
+++ b/content-gen/docs/DEPLOYMENT.md
@@ -134,13 +134,12 @@ When you start the deployment, most parameters will have **default values**, but
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Azure Region** | The region where resources will be created. | *(empty)* |
| **Environment Name** | A **3β20 character alphanumeric value** used to generate a unique ID to prefix the resources. | env\_name |
-| **GPT Model** | Choose from **gpt-4, gpt-4o, gpt-4o-mini**. | gpt-4o-mini |
-| **GPT Model Version** | The version of the selected GPT model. | 2024-07-18 |
+| **GPT Model** | Choose from **gpt-4, gpt-4o, gpt-4o-mini, gpt-5.1**. | gpt-5.1 |
+| **GPT Model Version** | The version of the selected GPT model. | 2025-11-13 |
| **OpenAI API Version** | The Azure OpenAI API version to use. | 2025-01-01-preview |
-| **GPT Model Deployment Capacity** | Configure capacity for **GPT models** (in thousands). | 30k |
-| **DALL-E Model** | DALL-E model for image generation. | dall-e-3 |
+| **GPT Model Deployment Capacity** | Configure capacity for **GPT models** (in thousands). | 150k |
+| **Image Model** | Choose from **dall-e-3, gpt-image-1, gpt-image-1.5** | gpt-image-1 |
| **Image Tag** | Docker image tag to deploy. Common values: `latest`, `dev`, `hotfix`. | latest |
-| **Use Local Build** | Boolean flag to determine if local container builds should be used. | false |
| **Existing Log Analytics Workspace** | To reuse an existing Log Analytics Workspace ID. | *(empty)* |
| **Existing Azure AI Foundry Project** | To reuse an existing Azure AI Foundry Project ID instead of creating a new one. | *(empty)* |
@@ -163,7 +162,7 @@ Depending on your subscription quota and capacity, you can adjust quota settings
### Deploying with AZD
-Once you've opened the project in [Codespaces](#github-codespaces), [Dev Containers](#vs-code-dev-containers), or [locally](#local-environment), you can deploy it to Azure by following the steps in the [AZD Deployment Guide](AZD_DEPLOYMENT.md)
+Once you've opened the project in [Codespaces](#github-codespaces), [Dev Containers](#vs-code-dev-containers), or [locally](#local-environment), you can deploy it to Azure by following the steps in the [AZD Deployment Guide](AZD_DEPLOYMENT.md).
## Post Deployment Steps
@@ -171,15 +170,7 @@ Once you've opened the project in [Codespaces](#github-codespaces), [Dev Contain
Follow steps in [App Authentication](./AppAuthentication.md) to configure authentication in app service. Note: Authentication changes can take up to 10 minutes.
-2. **Assign RBAC Roles (if needed)**
-
- If you encounter 401/403 errors, run the RBAC assignment script and wait 5-10 minutes for propagation:
-
- ```shell
- bash ./scripts/assign_rbac_roles.sh
- ```
-
-3. **Deleting Resources After a Failed Deployment**
+2. **Deleting Resources After a Failed Deployment**
- Follow steps in [Delete Resource Group](./DeleteResourceGroup.md) if your deployment fails and/or you need to clean up the resources.
## Troubleshooting
@@ -187,14 +178,6 @@ Once you've opened the project in [Codespaces](#github-codespaces), [Dev Contain
Common Issues and Solutions
-### 401 Unauthorized Errors
-
-**Symptom**: API calls return 401 errors
-
-**Cause**: Missing RBAC role assignments
-
-**Solution**: Run `assign_rbac_roles.sh` and wait 5-10 minutes for propagation
-
### 403 Forbidden from Cosmos DB
**Symptom**: Cosmos DB operations fail with 403
@@ -230,56 +213,29 @@ az webapp config set -g $RESOURCE_GROUP -n --http20-enabled false
### Image Generation Not Working
-**Symptom**: DALL-E requests fail
+**Symptom**: DALL-E/GPT-Image requests fail
-**Cause**: Missing DALL-E model deployment or incorrect endpoint
+**Cause**: Missing DALL-E/GPT-Image model deployment or incorrect endpoint
**Solution**:
-1. Verify DALL-E 3 deployment exists in Azure OpenAI resource
-2. Check `AZURE_OPENAI_DALLE_ENDPOINT` and `AZURE_OPENAI_DALLE_DEPLOYMENT` environment variables
+1. Verify DALL-E 3 or GPT-Image-1 or GPT-Image-1.5 deployment exists in Azure OpenAI resource
+2. Check `AZURE_OPENAI_IMAGE_MODEL` environment variable
-## Environment Variables Reference
-
-
- Backend Environment Variables (ACI)
-
-| Variable | Description | Example |
-|----------|-------------|---------|
-| AZURE_OPENAI_ENDPOINT | GPT model endpoint | https://ai-account.cognitiveservices.azure.com/ |
-| AZURE_OPENAI_DEPLOYMENT_NAME | GPT deployment name | gpt-4o-mini |
-| AZURE_OPENAI_DALLE_ENDPOINT | DALL-E endpoint | https://dalle-account.cognitiveservices.azure.com/ |
-| AZURE_OPENAI_DALLE_DEPLOYMENT | DALL-E deployment name | dall-e-3 |
-| COSMOS_ENDPOINT | Cosmos DB endpoint | https://cosmos.documents.azure.com:443/ |
-| COSMOS_DATABASE | Database name | content-generation |
-| AZURE_STORAGE_ACCOUNT_NAME | Storage account | storagecontentgen |
-| AZURE_STORAGE_CONTAINER | Product images container | product-images |
-| AZURE_STORAGE_GENERATED_CONTAINER | Generated images container | generated-images |
-
-
-
-
- Frontend Environment Variables (App Service)
-
-| Variable | Description | Example |
-|----------|-------------|---------|
-| BACKEND_URL | Backend API URL | http://backend.contentgen.internal:8000 |
-| WEBSITES_PORT | App Service port | 3000 |
-
-
+## Sample Workflow
-## Sample Prompts
+To get started with the Content Generation solution, follow these steps:
-To help you get started, here are some **sample prompts** you can use with the Content Generation Solution:
+1. **Task:** From the welcome screen, select one of the suggested prompts. Sample prompts include:
+ - *"I need to create a social media post about paint products for home remodels. The campaign is titled 'Brighten Your Springtime' and the audience is new homeowners. I need marketing copy plus an image."*
+ - *"Generate a social media campaign with ad copy and an image. This is for 'Back to School' and the audience is parents of school age children. Tone is playful and humorous."*
-- "Create a product description for a new eco-friendly water bottle"
-- "Generate marketing copy for a summer sale campaign"
-- "Write social media posts promoting our latest product launch"
-- "Create an image for a blog post about sustainable living"
-- "Generate a product image showing a modern office setup"
+2. **Task:** Click the **"Confirm Brief"** button.
+ > **Observe:** The system analyzes the creative brief to provide suggestions later.
-These prompts serve as a great starting point to explore the solution's capabilities with text generation, image generation, and content management.
+3. **Task:** Select a product from the product list, then click **"Generate Content"**.
+ > **Observe:** Enters "Thinking Process" with a "Generating Content.." spinner. Once complete, the detailed output is displayed.
## Architecture Overview
diff --git a/content-gen/docs/DeleteResourceGroup.md b/content-gen/docs/DeleteResourceGroup.md
new file mode 100644
index 000000000..9c88e228b
--- /dev/null
+++ b/content-gen/docs/DeleteResourceGroup.md
@@ -0,0 +1,51 @@
+# Deleting Resources After a Failed Deployment in Azure Portal
+
+If your deployment fails and you need to clean up the resources manually, follow these steps in the Azure Portal.
+
+---
+
+## **1. Navigate to the Azure Portal**
+1. Open [Azure Portal](https://portal.azure.com/).
+2. Sign in with your Azure account.
+
+---
+
+## **2. Find the Resource Group**
+1. In the search bar at the top, type **"Resource groups"** and select it.
+2. Locate the **resource group** associated with the failed deployment.
+
+
+
+
+
+---
+
+## **3. Delete the Resource Group**
+1. Click on the **resource group name** to open it.
+2. Click the **Delete resource group** button at the top.
+
+
+
+3. Type the resource group name in the confirmation box and click **Delete**.
+
+π **Note:** Deleting a resource group will remove all resources inside it.
+
+---
+
+## **4. Delete Individual Resources (If Needed)**
+If you donβt want to delete the entire resource group, follow these steps:
+
+1. Open **Azure Portal** and go to the **Resource groups** section.
+2. Click on the specific **resource group**.
+3. Select the **resource** you want to delete (e.g., App Service, Storage Account).
+4. Click **Delete** at the top.
+
+
+
+---
+
+## **5. Verify Deletion**
+- After a few minutes, refresh the **Resource groups** page.
+- Ensure the deleted resource or group no longer appears.
+
+π **Tip:** If a resource fails to delete, check if it's **locked** under the **Locks** section and remove the lock.
diff --git a/content-gen/docs/QuotaCheck.md b/content-gen/docs/QuotaCheck.md
new file mode 100644
index 000000000..2fb9b45b9
--- /dev/null
+++ b/content-gen/docs/QuotaCheck.md
@@ -0,0 +1,105 @@
+## Check Quota Availability Before Deployment
+
+Before deploying the Content Generation Solution Accelerator, **ensure sufficient quota availability** for the required models.
+
+> **For Global Standard | GPT-5.1 - ensure capacity to at least 150 tokens post-deployment for optimal performance.**
+
+> **For Global Standard | GPT-Image-1 - ensure capacity to at least 1 RPM (Requests Per Minute) for image generation.**
+
+
+### Login if you have not done so already
+```sh
+az login
+```
+
+
+### π Default Models & Capacities:
+```
+gpt-5.1:150,gpt-image-1:1
+```
+**Note:** GPT-5.1 capacity is in tokens, GPT-Image-1 capacity is in RPM (Requests Per Minute).
+### π Default Regions:
+```
+australiaeast, centralus, eastasia, eastus, eastus2, japaneast, northeurope, southeastasia, swedencentral, uksouth, westus, westus3
+```
+### Usage Scenarios:
+- No parameters passed β Default models and capacities will be checked in default regions.
+- Only model(s) provided β The script will check for those models in the default regions.
+- Only region(s) provided β The script will check default models in the specified regions.
+- Both models and regions provided β The script will check those models in the specified regions.
+- `--verbose` passed β Enables detailed logging output for debugging and traceability.
+
+### **Input Formats**
+> Use the --models, --regions, and --verbose options for parameter handling:
+
+βοΈ Run without parameters to check default models & regions without verbose logging:
+ ```
+ ./quota_check_params.sh
+ ```
+βοΈ Enable verbose logging:
+ ```
+ ./quota_check_params.sh --verbose
+ ```
+βοΈ Check specific model(s) in default regions:
+ ```
+ ./quota_check_params.sh --models gpt-5.1:150,gpt-image-1:1
+ ```
+βοΈ Check default models in specific region(s):
+ ```
+./quota_check_params.sh --regions eastus,swedencentral
+ ```
+βοΈ Passing Both models and regions:
+ ```
+ ./quota_check_params.sh --models gpt-5.1:150,gpt-image-1:1 --regions eastus,swedencentral
+ ```
+βοΈ All parameters combined:
+ ```
+ ./quota_check_params.sh --models gpt-5.1:150,gpt-image-1:1 --regions eastus,swedencentral --verbose
+ ```
+
+### **Sample Output**
+The final table lists regions with available quota. You can select any of these regions for deployment.
+
+
+
+---
+### **If using Azure Portal and Cloud Shell**
+
+1. Navigate to the [Azure Portal](https://portal.azure.com).
+2. Click on **Azure Cloud Shell** in the top right navigation menu.
+3. Run the appropriate command based on your requirement:
+
+ **To check quota for the deployment**
+
+ ```sh
+ curl -L -o quota_check_params.sh "https://raw.githubusercontent.com/microsoft/content-generation-solution-accelerator/main/content-gen/infra/scripts/quota_check_params.sh"
+ chmod +x quota_check_params.sh
+ ./quota_check_params.sh
+ ```
+ - Refer to [Input Formats](#input-formats) for detailed commands.
+
+### **If using VS Code or Codespaces**
+1. Open the terminal in VS Code or Codespaces.
+2. If you're using VS Code, click the dropdown on the right side of the terminal window, and select `Git Bash`.
+ 
+3. Navigate to the `content-gen/infra/scripts` folder where the script files are located and make the script as executable:
+ ```sh
+ cd content-gen/infra/scripts
+ chmod +x quota_check_params.sh
+ ```
+4. Run the appropriate script based on your requirement:
+
+ **To check quota for the deployment**
+
+ ```sh
+ ./quota_check_params.sh
+ ```
+ - Refer to [Input Formats](#input-formats) for detailed commands.
+
+5. If you see the error `_bash: az: command not found_`, install Azure CLI:
+
+ ```sh
+ curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
+ az login
+ ```
+6. Rerun the script after installing Azure CLI.
diff --git a/content-gen/docs/images/DeleteRG.png b/content-gen/docs/images/DeleteRG.png
new file mode 100644
index 000000000..c435ecf17
Binary files /dev/null and b/content-gen/docs/images/DeleteRG.png differ
diff --git a/content-gen/docs/images/create_new_app_registration.md b/content-gen/docs/images/create_new_app_registration.md
deleted file mode 100644
index 7dcf2c402..000000000
--- a/content-gen/docs/images/create_new_app_registration.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Creating a new App Registration
-
-1. Click on `Home` and select `Microsoft Entra ID`.
-
-
-
-2. Click on `App registrations`.
-
-
-
-3. Click on `+ New registration`.
-
-
-
-4. Provide the `Name`, select supported account types as `Accounts in this organizational directory only(Contoso only - Single tenant)`, select platform as `Web`, enter/select the `URL` and register.
-
-
-
-5. After application is created successfully, then click on `Add a Redirect URL`.
-
-
-
-6. Click on `+ Add a platform`.
-
-
-
-7. Click on `Web`.
-
-
-
-8. Enter the `web app URL` (Provide the app service name in place of XXXX) and Save. Then go back to [Set Up Authentication in Azure App Service](AppAuthentication.md) Step 1 page and follow from _Point 4_ choose `Pick an existing app registration in this directory` from the Add an Identity Provider page and provide the newly registered App Name.
-
-E.g. <>.azurewebsites.net/.auth/login/aad/callback>>
-
-
diff --git a/content-gen/docs/images/deleteservices.png b/content-gen/docs/images/deleteservices.png
new file mode 100644
index 000000000..e31feb016
Binary files /dev/null and b/content-gen/docs/images/deleteservices.png differ
diff --git a/content-gen/docs/images/git_bash.png b/content-gen/docs/images/git_bash.png
new file mode 100644
index 000000000..0e9f53a12
Binary files /dev/null and b/content-gen/docs/images/git_bash.png differ
diff --git a/content-gen/docs/images/quota-check-output.png b/content-gen/docs/images/quota-check-output.png
new file mode 100644
index 000000000..9e2eb8bf2
Binary files /dev/null and b/content-gen/docs/images/quota-check-output.png differ
diff --git a/content-gen/docs/images/resource-groups.png b/content-gen/docs/images/resource-groups.png
new file mode 100644
index 000000000..45beb39d2
Binary files /dev/null and b/content-gen/docs/images/resource-groups.png differ
diff --git a/content-gen/docs/images/resourcegroup.png b/content-gen/docs/images/resourcegroup.png
new file mode 100644
index 000000000..67b058bcc
Binary files /dev/null and b/content-gen/docs/images/resourcegroup.png differ
diff --git a/content-gen/infra/main.bicep b/content-gen/infra/main.bicep
index 092783a12..dc6f99946 100644
--- a/content-gen/infra/main.bicep
+++ b/content-gen/infra/main.bicep
@@ -240,9 +240,9 @@ var useExistingAiFoundryAiProject = !empty(azureExistingAIProjectResourceId)
var aiFoundryAiServicesResourceGroupName = useExistingAiFoundryAiProject
? split(azureExistingAIProjectResourceId, '/')[4]
: 'rg-${solutionSuffix}'
-// var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject
-// ? split(azureExistingAIProjectResourceId, '/')[2]
-// : subscription().id
+var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject
+ ? split(azureExistingAIProjectResourceId, '/')[2]
+ : subscription().subscriptionId
var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject
? split(azureExistingAIProjectResourceId, '/')[8]
: 'aif-${solutionSuffix}'
@@ -572,6 +572,17 @@ var aiFoundryAiProjectEndpoint = useExistingAiFoundryAiProject
? 'https://${aiFoundryAiServicesResourceName}.services.ai.azure.com/api/projects/${aiFoundryAiProjectResourceName}'
: aiFoundryAiServicesProject!.outputs.apiEndpoint
+// ========== Role Assignments for Existing AI Services ========== //
+module existingAiServicesRoleAssignments 'modules/deploy_foundry_role_assignment.bicep' = if (useExistingAiFoundryAiProject) {
+ name: take('module.foundry-role-assignment.${aiFoundryAiServicesResourceName}', 64)
+ scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName)
+ params: {
+ aiServicesName: aiFoundryAiServicesResourceName
+ principalId: userAssignedIdentity.outputs.principalId
+ principalType: 'ServicePrincipal'
+ }
+}
+
// ========== AI Search ========== //
module aiSearch 'br/public:avm/res/search/search-service:0.11.1' = {
name: take('avm.res.search.search-service.${aiSearchName}', 64)
diff --git a/content-gen/infra/main.parameters.json b/content-gen/infra/main.parameters.json
index 9370c6250..115996928 100644
--- a/content-gen/infra/main.parameters.json
+++ b/content-gen/infra/main.parameters.json
@@ -51,7 +51,7 @@
"value": "${ACR_NAME}"
},
"imageTag": {
- "value": "${IMAGE_TAG=latest}"
+ "value": "${imageTag=latest}"
}
}
}
diff --git a/content-gen/infra/main.waf.parameters.json b/content-gen/infra/main.waf.parameters.json
index d5d8438a2..fc60bdf89 100644
--- a/content-gen/infra/main.waf.parameters.json
+++ b/content-gen/infra/main.waf.parameters.json
@@ -8,9 +8,6 @@
"location": {
"value": "${AZURE_LOCATION}"
},
- "secondaryLocation": {
- "value": "${secondaryLocation}"
- },
"gptModelName": {
"value": "${gptModelName}"
},
@@ -39,28 +36,34 @@
"value": "${azureOpenaiAPIVersion}"
},
"azureAiServiceLocation": {
- "value": "${AZURE_ENV_OPENAI_LOCATION}"
+ "value": "${azureAiServiceLocation}"
},
"existingLogAnalyticsWorkspaceId": {
- "value": "${AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID}"
+ "value": "${existingLogAnalyticsWorkspaceId}"
},
"azureExistingAIProjectResourceId": {
- "value": "${AZURE_EXISTING_AI_PROJECT_RESOURCE_ID}"
+ "value": "${azureExistingAIProjectResourceId}"
},
"acrName": {
- "value": "${ACR_NAME}"
+ "value": "${acrName}"
},
"imageTag": {
- "value": "${IMAGE_TAG=latest}"
+ "value": "${imageTag=latest}"
},
- "enablePrivateNetworking": {
+ "enableMonitoring": {
"value": true
},
- "enableMonitoring": {
+ "enablePrivateNetworking": {
"value": true
},
"enableScalability": {
"value": true
+ },
+ "virtualMachineAdminUsername": {
+ "value": "${AZURE_ENV_VM_ADMIN_USERNAME}"
+ },
+ "virtualMachineAdminPassword": {
+ "value": "${AZURE_ENV_VM_ADMIN_PASSWORD}"
}
}
}
diff --git a/content-gen/infra/modules/deploy_foundry_role_assignment.bicep b/content-gen/infra/modules/deploy_foundry_role_assignment.bicep
new file mode 100644
index 000000000..3121ca932
--- /dev/null
+++ b/content-gen/infra/modules/deploy_foundry_role_assignment.bicep
@@ -0,0 +1,84 @@
+// ========== existing-ai-services-roles.bicep ========== //
+// Module to assign RBAC roles to managed identity on an existing AI Services account
+// This is required when reusing an existing AI Foundry project from a different resource group
+
+@description('Required. The principal ID of the managed identity to grant access.')
+param principalId string
+
+@description('Required. The name of the existing AI Services account.')
+param aiServicesName string
+
+@description('Optional. The name of the existing AI Project.')
+param aiProjectName string = ''
+
+@description('Optional. The principal type of the identity.')
+@allowed([
+ 'Device'
+ 'ForeignGroup'
+ 'Group'
+ 'ServicePrincipal'
+ 'User'
+])
+param principalType string = 'ServicePrincipal'
+
+// ========== Role Definitions ========== //
+
+// Azure AI User role - for AI Foundry project access (used by AIProjectClient for image generation)
+resource azureAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
+ name: '53ca6127-db72-4b80-b1b0-d745d6d5456d'
+}
+
+// Cognitive Services OpenAI User role - for chat completions (used by AzureOpenAIChatClient)
+resource cognitiveServicesOpenAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
+ name: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd'
+}
+
+// ========== Existing Resources ========== //
+
+// Reference the existing AI Services account
+resource existingAiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = {
+ name: aiServicesName
+}
+
+// Reference the existing AI Project (if provided)
+resource existingAiProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = if (!empty(aiProjectName)) {
+ name: aiProjectName
+ parent: existingAiServices
+}
+
+// ========== Role Assignments ========== //
+
+// Azure AI User role assignment - same as reference accelerator
+// Required for AIProjectClient (used for image generation in Foundry mode)
+resource assignAzureAiUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
+ name: guid(existingAiServices.id, principalId, azureAiUserRole.id)
+ scope: existingAiServices
+ properties: {
+ roleDefinitionId: azureAiUserRole.id
+ principalId: principalId
+ principalType: principalType
+ }
+}
+
+// Cognitive Services OpenAI User role assignment
+// Required for AzureOpenAIChatClient (used for chat completions)
+resource assignCognitiveServicesOpenAiUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
+ name: guid(existingAiServices.id, principalId, cognitiveServicesOpenAiUserRole.id)
+ scope: existingAiServices
+ properties: {
+ roleDefinitionId: cognitiveServicesOpenAiUserRole.id
+ principalId: principalId
+ principalType: principalType
+ }
+}
+
+// ========== Outputs ========== //
+
+@description('The resource ID of the existing AI Services account.')
+output aiServicesResourceId string = existingAiServices.id
+
+@description('The endpoint of the existing AI Services account.')
+output aiServicesEndpoint string = existingAiServices.properties.endpoint
+
+@description('The principal ID of the existing AI Project (if provided).')
+output aiProjectPrincipalId string = !empty(aiProjectName) ? existingAiProject.identity.principalId : ''
diff --git a/content-gen/infra/scripts/quota_check_params.sh b/content-gen/infra/scripts/quota_check_params.sh
new file mode 100644
index 000000000..9baa18b6e
--- /dev/null
+++ b/content-gen/infra/scripts/quota_check_params.sh
@@ -0,0 +1,237 @@
+#!/bin/bash
+# VERBOSE=false
+
+MODELS=""
+REGIONS=""
+VERBOSE=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --models)
+ MODELS="$2"
+ shift 2
+ ;;
+ --regions)
+ REGIONS="$2"
+ shift 2
+ ;;
+ --verbose)
+ VERBOSE=true
+ shift
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+done
+
+echo "Models: $MODELS"
+echo "Regions: $REGIONS"
+echo "Verbose: $VERBOSE"
+
+log_verbose() {
+ if [ "$VERBOSE" = true ]; then
+ echo "$1"
+ fi
+}
+
+# Default Models and Capacities for Content Generation
+# GPT-5.1: 150 tokens, GPT-Image-1: 1 RPM (Requests Per Minute)
+DEFAULT_MODEL_CAPACITY="gpt-5.1:150,gpt-image-1:1"
+
+# Convert the comma-separated string into an array
+IFS=',' read -r -a MODEL_CAPACITY_PAIRS <<< "$DEFAULT_MODEL_CAPACITY"
+
+echo "π Fetching available Azure subscriptions..."
+SUBSCRIPTIONS=$(az account list --query "[?state=='Enabled'].{Name:name, ID:id}" --output tsv)
+SUB_COUNT=$(echo "$SUBSCRIPTIONS" | wc -l)
+
+if [ "$SUB_COUNT" -eq 0 ]; then
+ echo "β ERROR: No active Azure subscriptions found. Please log in using 'az login' and ensure you have an active subscription."
+ exit 1
+elif [ "$SUB_COUNT" -eq 1 ]; then
+ # If only one subscription, automatically select it
+ AZURE_SUBSCRIPTION_ID=$(echo "$SUBSCRIPTIONS" | awk -F '\t' '{print $2}')
+ if [ -z "$AZURE_SUBSCRIPTION_ID" ]; then
+ echo "β ERROR: No active Azure subscriptions found. Please log in using 'az login' and ensure you have an active subscription."
+ exit 1
+ fi
+ echo "β
Using the only available subscription: $AZURE_SUBSCRIPTION_ID"
+else
+ # If multiple subscriptions exist, prompt the user to choose one
+ echo "Multiple subscriptions found:"
+ echo "$SUBSCRIPTIONS" | awk -F '\t' '{print NR")", $1, "-", $2}'
+
+ while true; do
+ echo "Enter the number of the subscription to use:"
+ read SUB_INDEX
+
+ # Validate user input
+ if [[ "$SUB_INDEX" =~ ^[0-9]+$ ]] && [ "$SUB_INDEX" -ge 1 ] && [ "$SUB_INDEX" -le "$SUB_COUNT" ]; then
+ AZURE_SUBSCRIPTION_ID=$(echo "$SUBSCRIPTIONS" | awk -F '\t' -v idx="$SUB_INDEX" 'NR==idx {print $2}')
+ echo "β
Selected Subscription: $AZURE_SUBSCRIPTION_ID"
+ break
+ else
+ echo "β Invalid selection. Please enter a valid number from the list."
+ fi
+ done
+fi
+
+
+# Set the selected subscription
+az account set --subscription "$AZURE_SUBSCRIPTION_ID"
+echo "π― Active Subscription: $(az account show --query '[name, id]' --output tsv)"
+
+# Default Regions supporting GPT-5.1 and GPT-Image-1 with GlobalStandard SKU
+DEFAULT_REGIONS="australiaeast,centralus,eastasia,eastus,eastus2,japaneast,northeurope,southeastasia,swedencentral,uksouth,westus,westus3"
+IFS=',' read -r -a DEFAULT_REGION_ARRAY <<< "$DEFAULT_REGIONS"
+
+# Read parameters (if any)
+IFS=',' read -r -a USER_PROVIDED_PAIRS <<< "$MODELS"
+USER_REGION="$REGIONS"
+
+IS_USER_PROVIDED_PAIRS=false
+
+if [ ${#USER_PROVIDED_PAIRS[@]} -lt 1 ]; then
+ echo "No parameters provided, using default model-capacity pairs: ${MODEL_CAPACITY_PAIRS[*]}"
+else
+ echo "Using provided model and capacity pairs: ${USER_PROVIDED_PAIRS[*]}"
+ IS_USER_PROVIDED_PAIRS=true
+ MODEL_CAPACITY_PAIRS=("${USER_PROVIDED_PAIRS[@]}")
+fi
+
+declare -a FINAL_MODEL_NAMES
+declare -a FINAL_CAPACITIES
+declare -a TABLE_ROWS
+
+for PAIR in "${MODEL_CAPACITY_PAIRS[@]}"; do
+ MODEL_NAME=$(echo "$PAIR" | cut -d':' -f1 | tr '[:upper:]' '[:lower:]')
+ CAPACITY=$(echo "$PAIR" | cut -d':' -f2)
+
+ if [ -z "$MODEL_NAME" ] || [ -z "$CAPACITY" ]; then
+ echo "β ERROR: Invalid model and capacity pair '$PAIR'. Both model and capacity must be specified."
+ exit 1
+ fi
+
+ FINAL_MODEL_NAMES+=("$MODEL_NAME")
+ FINAL_CAPACITIES+=("$CAPACITY")
+
+done
+
+echo "π Using Models: ${FINAL_MODEL_NAMES[*]} with respective Capacities: ${FINAL_CAPACITIES[*]}"
+echo "----------------------------------------"
+
+# Check if the user provided a region, if not, use the default regions
+if [ -n "$USER_REGION" ]; then
+ echo "π User provided region: $USER_REGION"
+ IFS=',' read -r -a REGIONS <<< "$USER_REGION"
+else
+ echo "No region specified, using default regions: ${DEFAULT_REGION_ARRAY[*]}"
+ REGIONS=("${DEFAULT_REGION_ARRAY[@]}")
+ APPLY_OR_CONDITION=true
+fi
+
+echo "β
Retrieved Azure regions. Checking availability..."
+INDEX=1
+
+VALID_REGIONS=()
+for REGION in "${REGIONS[@]}"; do
+ log_verbose "----------------------------------------"
+ log_verbose "π Checking region: $REGION"
+
+ QUOTA_INFO=$(az cognitiveservices usage list --location "$REGION" --output json | tr '[:upper:]' '[:lower:]')
+ if [ -z "$QUOTA_INFO" ]; then
+ log_verbose "β οΈ WARNING: Failed to retrieve quota for region $REGION. Skipping."
+ continue
+ fi
+
+ IMAGE_MODEL_AVAILABLE=false
+ AT_LEAST_ONE_MODEL_AVAILABLE=false
+ TEMP_TABLE_ROWS=()
+
+ for index in "${!FINAL_MODEL_NAMES[@]}"; do
+ MODEL_NAME="${FINAL_MODEL_NAMES[$index]}"
+ REQUIRED_CAPACITY="${FINAL_CAPACITIES[$index]}"
+ FOUND=false
+ INSUFFICIENT_QUOTA=false
+
+ MODEL_TYPES=("openai.standard.$MODEL_NAME" "openai.globalstandard.$MODEL_NAME")
+
+ for MODEL_TYPE in "${MODEL_TYPES[@]}"; do
+ FOUND=false
+ INSUFFICIENT_QUOTA=false
+ log_verbose "π Checking model: $MODEL_NAME with required capacity: $REQUIRED_CAPACITY ($MODEL_TYPE)"
+
+ MODEL_INFO=$(echo "$QUOTA_INFO" | awk -v model="\"value\": \"$MODEL_TYPE\"" '
+ BEGIN { RS="},"; FS="," }
+ $0 ~ model { print $0 }
+ ')
+
+ if [ -z "$MODEL_INFO" ]; then
+ FOUND=false
+ log_verbose "β οΈ WARNING: No quota information found for model: $MODEL_NAME in region: $REGION for model type: $MODEL_TYPE."
+ continue
+ fi
+
+ if [ -n "$MODEL_INFO" ]; then
+ FOUND=true
+ CURRENT_VALUE=$(echo "$MODEL_INFO" | awk -F': ' '/"currentvalue"/ {print $2}' | tr -d ',' | tr -d ' ')
+ LIMIT=$(echo "$MODEL_INFO" | awk -F': ' '/"limit"/ {print $2}' | tr -d ',' | tr -d ' ')
+
+ CURRENT_VALUE=${CURRENT_VALUE:-0}
+ LIMIT=${LIMIT:-0}
+
+ CURRENT_VALUE=$(echo "$CURRENT_VALUE" | cut -d'.' -f1)
+ LIMIT=$(echo "$LIMIT" | cut -d'.' -f1)
+
+ AVAILABLE=$((LIMIT - CURRENT_VALUE))
+ log_verbose "β
Model: $MODEL_TYPE | Used: $CURRENT_VALUE | Limit: $LIMIT | Available: $AVAILABLE"
+
+ if [ "$AVAILABLE" -ge "$REQUIRED_CAPACITY" ]; then
+ FOUND=true
+ if [ "$MODEL_NAME" = "gpt-image-1" ]; then
+ IMAGE_MODEL_AVAILABLE=true
+ fi
+ AT_LEAST_ONE_MODEL_AVAILABLE=true
+ TEMP_TABLE_ROWS+=("$(printf "| %-4s | %-20s | %-43s | %-10s | %-10s | %-10s |" "$INDEX" "$REGION" "$MODEL_TYPE" "$LIMIT" "$CURRENT_VALUE" "$AVAILABLE")")
+ else
+ INSUFFICIENT_QUOTA=true
+ fi
+ fi
+
+ if [ "$FOUND" = false ]; then
+ log_verbose "β No models found for model: $MODEL_NAME in region: $REGION (${MODEL_TYPES[*]})"
+
+ elif [ "$INSUFFICIENT_QUOTA" = true ]; then
+ log_verbose "β οΈ Model $MODEL_NAME in region: $REGION has insufficient quota (${MODEL_TYPES[*]})."
+ fi
+ done
+ done
+
+if { [ "$IS_USER_PROVIDED_PAIRS" = true ] && [ "$INSUFFICIENT_QUOTA" = false ] && [ "$FOUND" = true ]; } || { [ "$IMAGE_MODEL_AVAILABLE" = true ] && { [ "$APPLY_OR_CONDITION" != true ] || [ "$AT_LEAST_ONE_MODEL_AVAILABLE" = true ]; }; }; then
+ VALID_REGIONS+=("$REGION")
+ TABLE_ROWS+=("${TEMP_TABLE_ROWS[@]}")
+ INDEX=$((INDEX + 1))
+ elif [ ${#USER_PROVIDED_PAIRS[@]} -eq 0 ]; then
+ echo "π« Skipping $REGION as it does not meet quota requirements."
+ fi
+
+done
+
+if [ ${#TABLE_ROWS[@]} -eq 0 ]; then
+ echo "--------------------------------------------------------------------------------------------------------------------"
+
+ echo "β No regions have sufficient quota for all required models. Please request a quota increase: https://aka.ms/oai/stuquotarequest"
+else
+ echo "---------------------------------------------------------------------------------------------------------------------"
+ printf "| %-4s | %-20s | %-43s | %-10s | %-10s | %-10s |\n" "No." "Region" "Model Name" "Limit" "Used" "Available"
+ echo "---------------------------------------------------------------------------------------------------------------------"
+ for ROW in "${TABLE_ROWS[@]}"; do
+ echo "$ROW"
+ done
+ echo "---------------------------------------------------------------------------------------------------------------------"
+ echo "β‘οΈ To request a quota increase, visit: https://aka.ms/oai/stuquotarequest"
+fi
+
+echo "β
Script completed."
diff --git a/content-gen/src/app/frontend/src/components/ChatHistory.tsx b/content-gen/src/app/frontend/src/components/ChatHistory.tsx
index 58fe12a5b..ed9f97762 100644
--- a/content-gen/src/app/frontend/src/components/ChatHistory.tsx
+++ b/content-gen/src/app/frontend/src/components/ChatHistory.tsx
@@ -322,21 +322,29 @@ function ConversationItem({
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(conversation.title || '');
+ const [renameError, setRenameError] = useState('');
const renameInputRef = useRef(null);
const handleRenameClick = () => {
setRenameValue(conversation.title || '');
+ setRenameError('');
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
};
const handleRenameConfirm = async () => {
const trimmedValue = renameValue.trim();
- if (trimmedValue && trimmedValue !== conversation.title) {
- await onRename(conversation.id, trimmedValue);
- onRefresh();
+
+ if (trimmedValue === conversation.title) {
+ setIsRenameDialogOpen(false);
+ setRenameError('');
+ return;
}
+
+ await onRename(conversation.id, trimmedValue);
+ onRefresh();
setIsRenameDialogOpen(false);
+ setRenameError('');
};
const handleDeleteClick = () => {
@@ -446,9 +454,17 @@ function ConversationItem({
setRenameValue(e.target.value)}
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setRenameValue(newValue);
+ if (newValue.trim() === '') {
+ setRenameError('Conversation name cannot be empty or contain only spaces');
+ } else {
+ setRenameError('');
+ }
+ }}
onKeyDown={(e) => {
- if (e.key === 'Enter') {
+ if (e.key === 'Enter' && renameValue.trim()) {
handleRenameConfirm();
} else if (e.key === 'Escape') {
setIsRenameDialogOpen(false);
@@ -457,13 +473,32 @@ function ConversationItem({
placeholder="Enter conversation name"
style={{ width: '100%' }}
/>
+ {renameError && (
+
+ {renameError}
+
+ )}
-
diff --git a/content-gen/src/backend/agents/image_content_agent.py b/content-gen/src/backend/agents/image_content_agent.py
index 24de18331..2340aa0a9 100644
--- a/content-gen/src/backend/agents/image_content_agent.py
+++ b/content-gen/src/backend/agents/image_content_agent.py
@@ -18,24 +18,24 @@ def _truncate_for_dalle(product_description: str, max_chars: int = 1500) -> str:
"""
Truncate product descriptions to fit DALL-E's 4000 character limit.
Extracts the most visually relevant information (colors, hex codes, finishes).
-
+
Args:
product_description: The full product description(s)
max_chars: Maximum characters to allow for product context
-
+
Returns:
Truncated description with essential visual details
"""
if not product_description or len(product_description) <= max_chars:
return product_description
-
+
import re
-
+
# Extract essential visual info: product names, hex codes, color descriptions
lines = product_description.split('\n')
essential_parts = []
current_product = ""
-
+
for line in lines:
# Keep product name headers
if line.startswith('### '):
@@ -54,13 +54,13 @@ def _truncate_for_dalle(product_description: str, max_chars: int = 1500) -> str:
# Keep finish descriptions
elif 'finish' in line.lower() or 'matte' in line.lower() or 'eggshell' in line.lower():
essential_parts.append(line.strip()[:200])
-
+
result = '\n'.join(essential_parts)
-
+
# If still too long, just truncate with ellipsis
if len(result) > max_chars:
- result = result[:max_chars-50] + '\n\n[Additional details truncated for DALL-E]'
-
+ result = result[:max_chars - 50] + '\n\n[Additional details truncated for DALL-E]'
+
return result
@@ -73,9 +73,9 @@ async def generate_dalle_image(
) -> dict:
"""
Generate a marketing image using DALL-E 3, gpt-image-1, or gpt-image-1.5.
-
+
The model used is determined by AZURE_OPENAI_IMAGE_MODEL setting.
-
+
Args:
prompt: The main image generation prompt
product_description: Auto-generated description of product image (for context)
@@ -86,14 +86,14 @@ async def generate_dalle_image(
quality: Image quality (model-specific, uses settings default if not provided)
- dall-e-3: standard, hd
- gpt-image-1/1.5: low, medium, high, auto
-
+
Returns:
Dictionary containing generated image data and metadata
"""
# Determine which model to use
image_model = app_settings.azure_openai.effective_image_model
logger.info(f"Using image generation model: {image_model}")
-
+
# Use appropriate generator based on model
if image_model in ["gpt-image-1", "gpt-image-1.5"]:
return await _generate_gpt_image(prompt, product_description, scene_description, size, quality)
@@ -110,31 +110,31 @@ async def _generate_dalle_image(
) -> dict:
"""
Generate a marketing image using DALL-E 3.
-
+
Args:
prompt: The main image generation prompt
product_description: Auto-generated description of product image (for context)
scene_description: Scene/setting description from creative brief
size: Image size (1024x1024, 1024x1792, 1792x1024)
quality: Image quality (standard, hd)
-
+
Returns:
Dictionary containing generated image data and metadata
"""
brand = app_settings.brand_guidelines
-
+
# Use defaults from settings if not provided
size = size or app_settings.azure_openai.image_size
quality = quality or app_settings.azure_openai.image_quality
-
+
# DALL-E 3 has a 4000 character limit for prompts
# Truncate product descriptions to essential visual info
truncated_product_desc = _truncate_for_dalle(product_description, max_chars=1500)
-
+
# Also truncate the main prompt if it's too long
main_prompt = prompt[:1000] if len(prompt) > 1000 else prompt
scene_desc = scene_description[:500] if scene_description and len(scene_description) > 500 else scene_description
-
+
# Build the full prompt with product context and brand guidelines
full_prompt = f"""β οΈ ABSOLUTE RULE: THIS IMAGE MUST CONTAIN ZERO TEXT. NO WORDS. NO LETTERS. NO PRODUCT NAMES. NO LABELS.
@@ -153,7 +153,7 @@ async def _generate_dalle_image(
MANDATORY FINAL CHECKLIST:
β NO product names in the image
-β NO color names in the image
+β NO color names in the image
β NO text overlays or labels
β NO typography or lettering of any kind
β NO watermarks or logos
@@ -162,7 +162,7 @@ async def _generate_dalle_image(
β Accurately reproduce product colors using exact hex codes
β Professional, polished marketing image
"""
-
+
# Final safety check - DALL-E 3 has 4000 char limit
if len(full_prompt) > 3900:
logger.warning(f"Prompt too long ({len(full_prompt)} chars), truncating...")
@@ -190,20 +190,20 @@ async def _generate_dalle_image(
credential = ManagedIdentityCredential(client_id=client_id)
else:
credential = DefaultAzureCredential()
-
+
# Get token for Azure OpenAI
token = await credential.get_token("https://cognitiveservices.azure.com/.default")
-
+
# Use the dedicated DALL-E endpoint if configured, otherwise fall back to main endpoint
dalle_endpoint = app_settings.azure_openai.dalle_endpoint or app_settings.azure_openai.endpoint
logger.info(f"Using DALL-E endpoint: {dalle_endpoint}")
-
+
client = AsyncAzureOpenAI(
azure_endpoint=dalle_endpoint,
azure_ad_token=token.token,
api_version=app_settings.azure_openai.preview_api_version,
)
-
+
try:
response = await client.images.generate(
model=app_settings.azure_openai.dalle_model,
@@ -213,9 +213,9 @@ async def _generate_dalle_image(
n=1,
response_format="b64_json"
)
-
+
image_data = response.data[0]
-
+
return {
"success": True,
"image_base64": image_data.b64_json,
@@ -226,7 +226,7 @@ async def _generate_dalle_image(
finally:
# Properly close the async client to avoid unclosed session warnings
await client.close()
-
+
except Exception as e:
logger.exception(f"Error generating DALL-E image: {e}")
return {
@@ -246,50 +246,50 @@ async def _generate_gpt_image(
) -> dict:
"""
Generate a marketing image using gpt-image-1 or gpt-image-1.5.
-
+
gpt-image models have different capabilities than DALL-E 3:
- Supports larger prompt sizes
- Different size options: 1024x1024, 1536x1024, 1024x1536, auto
- Different quality options: low, medium, high, auto
- May have better instruction following
-
+
Args:
prompt: The main image generation prompt
product_description: Auto-generated description of product image (for context)
scene_description: Scene/setting description from creative brief
size: Image size (1024x1024, 1536x1024, 1024x1536, auto)
quality: Image quality (low, medium, high, auto)
-
+
Returns:
Dictionary containing generated image data and metadata
"""
brand = app_settings.brand_guidelines
-
+
# Use defaults from settings if not provided
# Map DALL-E quality settings to gpt-image-1 or gpt-image-1.5 equivalents if needed
size = size or app_settings.azure_openai.image_size
quality = quality or app_settings.azure_openai.image_quality
-
+
# Map DALL-E quality values to gpt-image-1 or gpt-image-1.5 equivalents
quality_mapping = {
"standard": "medium",
"hd": "high",
}
quality = quality_mapping.get(quality, quality)
-
+
# Map DALL-E sizes to gpt-image-1 or gpt-image-1.5 equivalents if needed
size_mapping = {
"1024x1792": "1024x1536", # Closest equivalent
"1792x1024": "1536x1024", # Closest equivalent
}
size = size_mapping.get(size, size)
-
+
# gpt-image-1 can handle larger prompts, so we can include more context
truncated_product_desc = _truncate_for_dalle(product_description, max_chars=3000)
-
+
main_prompt = prompt[:2000] if len(prompt) > 2000 else prompt
scene_desc = scene_description[:1000] if scene_description and len(scene_description) > 1000 else scene_description
-
+
# Build the full prompt with product context and brand guidelines
full_prompt = f"""β οΈ ABSOLUTE RULE: THIS IMAGE MUST CONTAIN ZERO TEXT. NO WORDS. NO LETTERS. NO PRODUCT NAMES. NO COLOR NAMES. NO LABELS.
@@ -326,25 +326,23 @@ async def _generate_gpt_image(
credential = ManagedIdentityCredential(client_id=client_id)
else:
credential = DefaultAzureCredential()
-
+
# Get token for Azure OpenAI
token = await credential.get_token("https://cognitiveservices.azure.com/.default")
-
+
# Use gpt-image-1 specific endpoint if configured, otherwise DALL-E endpoint, otherwise main endpoint
- image_endpoint = (
- app_settings.azure_openai.gpt_image_endpoint or
- app_settings.azure_openai.dalle_endpoint or
- app_settings.azure_openai.endpoint
- )
+ image_endpoint = (app_settings.azure_openai.gpt_image_endpoint
+ or app_settings.azure_openai.dalle_endpoint
+ or app_settings.azure_openai.endpoint)
logger.info(f"Using gpt-image-1 endpoint: {image_endpoint}")
-
+
# Use the image-specific API version for gpt-image-1 (requires 2025-04-01-preview or newer)
client = AsyncAzureOpenAI(
azure_endpoint=image_endpoint,
azure_ad_token=token.token,
api_version=app_settings.azure_openai.image_api_version,
)
-
+
try:
# gpt-image-1/1.5 API call - note: gpt-image doesn't support response_format parameter
# It returns base64 data directly in the response
@@ -355,12 +353,12 @@ async def _generate_gpt_image(
quality=quality,
n=1,
)
-
+
image_data = response.data[0]
-
+
# gpt-image-1 returns b64_json directly without needing response_format parameter
image_base64 = getattr(image_data, 'b64_json', None)
-
+
# If no b64_json, try to get URL and fetch the image
if not image_base64 and hasattr(image_data, 'url') and image_data.url:
import aiohttp
@@ -370,7 +368,7 @@ async def _generate_gpt_image(
import base64
image_bytes = await resp.read()
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
-
+
if not image_base64:
return {
"success": False,
@@ -378,7 +376,7 @@ async def _generate_gpt_image(
"prompt_used": full_prompt,
"model": "gpt-image-1",
}
-
+
return {
"success": True,
"image_base64": image_base64,
@@ -389,7 +387,7 @@ async def _generate_gpt_image(
finally:
# Properly close the async client to avoid unclosed session warnings
await client.close()
-
+
except Exception as e:
logger.exception(f"Error generating gpt-image-1 image: {e}")
return {
diff --git a/content-gen/src/backend/api/admin.py b/content-gen/src/backend/api/admin.py
index 9974be307..ab3948523 100644
--- a/content-gen/src/backend/api/admin.py
+++ b/content-gen/src/backend/api/admin.py
@@ -35,14 +35,14 @@
def verify_admin_api_key() -> bool:
"""
Verify the admin API key from request headers.
-
+
If ADMIN_API_KEY is not set, all requests are allowed (development mode).
If set, the request must include X-Admin-API-Key header with matching value.
"""
if not ADMIN_API_KEY:
# No API key configured - allow all requests (development/initial setup)
return True
-
+
provided_key = request.headers.get("X-Admin-API-Key", "")
return provided_key == ADMIN_API_KEY
@@ -61,7 +61,7 @@ def unauthorized_response():
async def upload_images():
"""
Upload product images to Blob Storage.
-
+
Request body:
{
"images": [
@@ -73,7 +73,7 @@ async def upload_images():
...
]
}
-
+
Returns:
{
"success": true,
@@ -87,29 +87,29 @@ async def upload_images():
"""
if not verify_admin_api_key():
return unauthorized_response()
-
+
try:
data = await request.get_json()
images = data.get("images", [])
-
+
if not images:
return jsonify({
"error": "No images provided",
"message": "Request body must contain 'images' array"
}), 400
-
+
blob_service = await get_blob_service()
await blob_service.initialize()
-
+
results = []
uploaded_count = 0
failed_count = 0
-
+
for image_info in images:
filename = image_info.get("filename", "")
content_type = image_info.get("content_type", "image/png")
image_data_b64 = image_info.get("data", "")
-
+
if not filename or not image_data_b64:
results.append({
"filename": filename or "unknown",
@@ -118,11 +118,11 @@ async def upload_images():
})
failed_count += 1
continue
-
+
try:
# Decode base64 image data
image_data = base64.b64decode(image_data_b64)
-
+
# Upload to product-images container
blob_client = blob_service._product_images_container.get_blob_client(filename)
await blob_client.upload_blob(
@@ -130,7 +130,7 @@ async def upload_images():
overwrite=True,
content_settings=ContentSettings(content_type=content_type)
)
-
+
results.append({
"filename": filename,
"status": "uploaded",
@@ -139,7 +139,7 @@ async def upload_images():
})
uploaded_count += 1
logger.info(f"Uploaded image: {filename} ({len(image_data):,} bytes)")
-
+
except Exception as e:
logger.error(f"Failed to upload image {filename}: {e}")
results.append({
@@ -148,14 +148,14 @@ async def upload_images():
"error": str(e)
})
failed_count += 1
-
+
return jsonify({
"success": failed_count == 0,
"uploaded": uploaded_count,
"failed": failed_count,
"results": results
})
-
+
except Exception as e:
logger.exception(f"Error in upload_images: {e}")
return jsonify({
@@ -170,7 +170,7 @@ async def upload_images():
async def load_sample_data():
"""
Load sample product data to Cosmos DB.
-
+
Request body:
{
"products": [
@@ -187,7 +187,7 @@ async def load_sample_data():
],
"clear_existing": true // Optional: delete existing products first
}
-
+
Returns:
{
"success": true,
@@ -202,34 +202,34 @@ async def load_sample_data():
"""
if not verify_admin_api_key():
return unauthorized_response()
-
+
try:
data = await request.get_json()
products_data = data.get("products", [])
clear_existing = data.get("clear_existing", False)
-
+
if not products_data:
return jsonify({
"error": "No products provided",
"message": "Request body must contain 'products' array"
}), 400
-
+
cosmos_service = await get_cosmos_service()
-
+
deleted_count = 0
if clear_existing:
logger.info("Deleting existing products...")
deleted_count = await cosmos_service.delete_all_products()
logger.info(f"Deleted {deleted_count} existing products")
-
+
results = []
loaded_count = 0
failed_count = 0
-
+
for product_data in products_data:
sku = product_data.get("sku", "")
product_name = product_data.get("product_name", "")
-
+
try:
# Map incoming fields to Product model fields
# Note: Product model requires 'description' field, map from incoming 'description' or 'marketing_description'
@@ -248,10 +248,10 @@ async def load_sample_data():
"tags": product_data.get("tags", ""),
"price": product_data.get("price", 0.0),
}
-
+
product = Product(**product_fields)
await cosmos_service.upsert_product(product)
-
+
results.append({
"sku": sku,
"product_name": product_name,
@@ -259,7 +259,7 @@ async def load_sample_data():
})
loaded_count += 1
logger.info(f"Loaded product: {product_name} ({sku})")
-
+
except Exception as e:
logger.error(f"Failed to load product {sku}: {e}")
results.append({
@@ -269,19 +269,19 @@ async def load_sample_data():
"error": str(e)
})
failed_count += 1
-
+
response = {
"success": failed_count == 0,
"loaded": loaded_count,
"failed": failed_count,
"results": results
}
-
+
if clear_existing:
response["deleted"] = deleted_count
-
+
return jsonify(response)
-
+
except Exception as e:
logger.exception(f"Error in load_sample_data: {e}")
return jsonify({
@@ -296,13 +296,13 @@ async def load_sample_data():
async def create_search_index():
"""
Create or update the Azure AI Search index with products from Cosmos DB.
-
+
Request body (optional):
{
"index_name": "products", // Optional: defaults to "products"
"reindex_all": true // Optional: re-index all products
}
-
+
Returns:
{
"success": true,
@@ -317,7 +317,7 @@ async def create_search_index():
"""
if not verify_admin_api_key():
return unauthorized_response()
-
+
try:
# Import search-related dependencies
from azure.core.credentials import AzureKeyCredential
@@ -338,17 +338,17 @@ async def create_search_index():
VectorSearch,
VectorSearchProfile,
)
-
+
data = await request.get_json() or {}
index_name = data.get("index_name", app_settings.search.products_index if app_settings.search else "products")
-
+
search_endpoint = app_settings.search.endpoint if app_settings.search else None
if not search_endpoint:
return jsonify({
"error": "Search service not configured",
"message": "AZURE_AI_SEARCH_ENDPOINT environment variable not set"
}), 500
-
+
# Get credential - try API key first, then RBAC
admin_key = app_settings.search.admin_key if app_settings.search else None
if admin_key:
@@ -357,10 +357,10 @@ async def create_search_index():
else:
credential = DefaultAzureCredential()
logger.info("Using RBAC authentication for search")
-
+
# Create index client
index_client = SearchIndexClient(endpoint=search_endpoint, credential=credential)
-
+
# Define index schema
fields = [
SimpleField(name="id", type=SearchFieldDataType.String, key=True, filterable=True),
@@ -381,12 +381,12 @@ async def create_search_index():
vector_search_profile_name="product-vector-profile"
)
]
-
+
vector_search = VectorSearch(
algorithms=[HnswAlgorithmConfiguration(name="hnsw-algorithm")],
profiles=[VectorSearchProfile(name="product-vector-profile", algorithm_configuration_name="hnsw-algorithm")]
)
-
+
semantic_config = SemanticConfiguration(
name="product-semantic-config",
prioritized_fields=SemanticPrioritizedFields(
@@ -404,24 +404,24 @@ async def create_search_index():
]
)
)
-
+
index = SearchIndex(
name=index_name,
fields=fields,
vector_search=vector_search,
semantic_search=SemanticSearch(configurations=[semantic_config])
)
-
+
# Create or update index
logger.info(f"Creating/updating search index: {index_name}")
index_client.create_or_update_index(index)
logger.info("Search index created/updated successfully")
-
+
# Get products from Cosmos DB
cosmos_service = await get_cosmos_service()
products = await cosmos_service.get_all_products(limit=1000)
logger.info(f"Found {len(products)} products to index")
-
+
if not products:
return jsonify({
"success": True,
@@ -431,15 +431,15 @@ async def create_search_index():
"message": "No products found to index",
"results": []
})
-
+
# Prepare documents for indexing
documents = []
results = []
-
+
for product in products:
p = product.model_dump()
doc_id = p.get('sku', '').lower().replace("-", "_").replace(" ", "_") or p.get('id', 'unknown')
-
+
combined_text = f"""
{p.get('product_name', '')}
Category: {p.get('category', '')} - {p.get('sub_category', '')}
@@ -448,7 +448,7 @@ async def create_search_index():
Specifications: {p.get('detailed_spec_description', '')}
Visual: {p.get('image_description', '')}
"""
-
+
documents.append({
"id": doc_id,
"product_name": p.get("product_name", ""),
@@ -462,22 +462,22 @@ async def create_search_index():
"combined_text": combined_text.strip(),
"content_vector": [0.0] * 1536 # Placeholder vector
})
-
+
results.append({
"sku": p.get("sku", ""),
"product_name": p.get("product_name", ""),
"status": "pending"
})
-
+
# Upload documents to search index
search_client = SearchClient(endpoint=search_endpoint, index_name=index_name, credential=credential)
-
+
try:
upload_result = search_client.upload_documents(documents)
-
+
indexed_count = 0
failed_count = 0
-
+
for i, r in enumerate(upload_result):
if r.succeeded:
results[i]["status"] = "indexed"
@@ -486,9 +486,9 @@ async def create_search_index():
results[i]["status"] = "failed"
results[i]["error"] = str(r.error_message) if hasattr(r, 'error_message') else "Unknown error"
failed_count += 1
-
+
logger.info(f"Indexed {indexed_count} products, {failed_count} failed")
-
+
return jsonify({
"success": failed_count == 0,
"indexed": indexed_count,
@@ -496,14 +496,14 @@ async def create_search_index():
"index_name": index_name,
"results": results
})
-
+
except Exception as e:
logger.exception(f"Failed to index documents: {e}")
return jsonify({
"error": "Failed to index documents",
"message": str(e)
}), 500
-
+
except Exception as e:
logger.exception(f"Error in create_search_index: {e}")
return jsonify({
@@ -518,7 +518,7 @@ async def create_search_index():
async def admin_health():
"""
Health check for admin API.
-
+
Does not require authentication - used to verify the admin API is available.
"""
return jsonify({
diff --git a/content-gen/src/backend/app.py b/content-gen/src/backend/app.py
index 3fe4ffc6c..fb28bde72 100644
--- a/content-gen/src/backend/app.py
+++ b/content-gen/src/backend/app.py
@@ -47,14 +47,14 @@
def get_authenticated_user():
"""
Get the authenticated user from EasyAuth headers.
-
+
In production (with App Service Auth), the X-Ms-Client-Principal-Id header
contains the user's ID. In development mode, returns "anonymous".
"""
user_principal_id = request.headers.get("X-Ms-Client-Principal-Id", "")
user_name = request.headers.get("X-Ms-Client-Principal-Name", "")
auth_provider = request.headers.get("X-Ms-Client-Principal-Idp", "")
-
+
return {
"user_principal_id": user_principal_id or "anonymous",
"user_name": user_name or "",
@@ -82,27 +82,27 @@ async def health_check():
async def chat():
"""
Process a chat message through the agent orchestration.
-
+
Request body:
{
"message": "User's message",
"conversation_id": "optional-uuid",
"user_id": "user identifier"
}
-
+
Returns streaming response with agent responses.
"""
data = await request.get_json()
-
+
message = data.get("message", "")
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
if not message:
return jsonify({"error": "Message is required"}), 400
-
+
orchestrator = get_orchestrator()
-
+
# Try to save to CosmosDB but don't fail if it's unavailable
try:
cosmos_service = await get_cosmos_service()
@@ -117,7 +117,7 @@ async def chat():
)
except Exception as e:
logger.warning(f"Failed to save message to CosmosDB: {e}")
-
+
async def generate():
"""Stream responses from the orchestrator."""
try:
@@ -126,7 +126,7 @@ async def generate():
conversation_id=conversation_id
):
yield f"data: {json.dumps(response)}\n\n"
-
+
# Save assistant responses when final OR when requiring user input
if response.get("is_final") or response.get("requires_user_input"):
if response.get("content"):
@@ -147,9 +147,9 @@ async def generate():
except Exception as e:
logger.exception(f"Error in orchestrator: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': str(e), 'is_final': True})}\n\n"
-
+
yield "data: [DONE]\n\n"
-
+
return Response(
generate(),
mimetype="text/event-stream",
@@ -167,14 +167,14 @@ async def parse_brief():
"""
Parse a free-text creative brief into structured format.
If critical information is missing, return clarifying questions.
-
+
Request body:
{
"brief_text": "Free-form creative brief text",
"conversation_id": "optional-uuid",
"user_id": "user identifier"
}
-
+
Returns:
Structured CreativeBrief JSON for user confirmation,
or clarifying questions if info is missing.
@@ -183,10 +183,10 @@ async def parse_brief():
brief_text = data.get("brief_text", "")
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
if not brief_text:
return jsonify({"error": "Brief text is required"}), 400
-
+
# Save the user's brief text as a message to CosmosDB
try:
cosmos_service = await get_cosmos_service()
@@ -201,10 +201,10 @@ async def parse_brief():
)
except Exception as e:
logger.warning(f"Failed to save brief message to CosmosDB: {e}")
-
+
orchestrator = get_orchestrator()
parsed_brief, clarifying_questions, rai_blocked = await orchestrator.parse_brief(brief_text)
-
+
# Check if request was blocked due to harmful content
if rai_blocked:
# Save the refusal as assistant response
@@ -222,7 +222,7 @@ async def parse_brief():
)
except Exception as e:
logger.warning(f"Failed to save RAI response to CosmosDB: {e}")
-
+
return jsonify({
"rai_blocked": True,
"requires_clarification": False,
@@ -230,7 +230,7 @@ async def parse_brief():
"conversation_id": conversation_id,
"message": clarifying_questions
})
-
+
# Check if we need clarifying questions
if clarifying_questions:
# Save the clarifying questions as assistant response
@@ -248,7 +248,7 @@ async def parse_brief():
)
except Exception as e:
logger.warning(f"Failed to save clarifying questions to CosmosDB: {e}")
-
+
return jsonify({
"brief": parsed_brief.model_dump(),
"requires_clarification": True,
@@ -257,7 +257,7 @@ async def parse_brief():
"conversation_id": conversation_id,
"message": clarifying_questions
})
-
+
# Save the assistant's parsing response
try:
cosmos_service = await get_cosmos_service()
@@ -273,7 +273,7 @@ async def parse_brief():
)
except Exception as e:
logger.warning(f"Failed to save parsing response to CosmosDB: {e}")
-
+
return jsonify({
"brief": parsed_brief.model_dump(),
"requires_clarification": False,
@@ -287,14 +287,14 @@ async def parse_brief():
async def confirm_brief():
"""
Confirm or modify a parsed creative brief.
-
+
Request body:
{
"brief": { ... CreativeBrief fields ... },
"conversation_id": "optional-uuid",
"user_id": "user identifier"
}
-
+
Returns:
Confirmation status and next steps.
"""
@@ -302,20 +302,20 @@ async def confirm_brief():
brief_data = data.get("brief", {})
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
try:
brief = CreativeBrief(**brief_data)
except Exception as e:
return jsonify({"error": f"Invalid brief format: {str(e)}"}), 400
-
+
# Try to save the confirmed brief to CosmosDB, preserving existing messages
try:
cosmos_service = await get_cosmos_service()
-
+
# Get existing conversation to preserve messages
existing = await cosmos_service.get_conversation(conversation_id, user_id)
existing_messages = existing.get("messages", []) if existing else []
-
+
# Add confirmation message
existing_messages.append({
"role": "assistant",
@@ -323,7 +323,7 @@ async def confirm_brief():
"agent": "TriageAgent",
"timestamp": datetime.now(timezone.utc).isoformat()
})
-
+
await cosmos_service.save_conversation(
conversation_id=conversation_id,
user_id=user_id,
@@ -333,7 +333,7 @@ async def confirm_brief():
)
except Exception as e:
logger.warning(f"Failed to save brief to CosmosDB: {e}")
-
+
return jsonify({
"status": "confirmed",
"conversation_id": conversation_id,
@@ -348,7 +348,7 @@ async def confirm_brief():
async def select_products():
"""
Select or modify products via natural language.
-
+
Request body:
{
"request": "User's natural language request",
@@ -356,20 +356,20 @@ async def select_products():
"conversation_id": "optional-uuid",
"user_id": "user identifier"
}
-
+
Returns:
Selected products and assistant message.
"""
data = await request.get_json()
-
+
request_text = data.get("request", "")
current_products = data.get("current_products", [])
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
if not request_text:
return jsonify({"error": "Request text is required"}), 400
-
+
# Save user message
try:
cosmos_service = await get_cosmos_service()
@@ -384,14 +384,14 @@ async def select_products():
)
except Exception as e:
logger.warning(f"Failed to save product selection request to CosmosDB: {e}")
-
+
# Get available products from catalog
try:
cosmos_service = await get_cosmos_service()
all_products = await cosmos_service.get_all_products(limit=50)
# Use mode='json' to ensure datetime objects are serialized to strings
available_products = [p.model_dump(mode='json') for p in all_products]
-
+
# Convert blob URLs to proxy URLs
for p in available_products:
if p.get("image_url"):
@@ -401,7 +401,7 @@ async def select_products():
except Exception as e:
logger.warning(f"Failed to get products from CosmosDB: {e}")
available_products = []
-
+
# Use orchestrator to process the selection request
orchestrator = get_orchestrator()
result = await orchestrator.select_products(
@@ -409,7 +409,7 @@ async def select_products():
current_products=current_products,
available_products=available_products
)
-
+
# Save assistant response
try:
cosmos_service = await get_cosmos_service()
@@ -425,7 +425,7 @@ async def select_products():
)
except Exception as e:
logger.warning(f"Failed to save product selection response to CosmosDB: {e}")
-
+
return jsonify({
"products": result.get("products", []),
"action": result.get("action", "search"),
@@ -436,23 +436,23 @@ async def select_products():
# ==================== Content Generation Endpoints ====================
-async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data: list,
- generate_images: bool, conversation_id: str, user_id: str):
+async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data: list,
+ generate_images: bool, conversation_id: str, user_id: str):
"""Background task to run content generation."""
try:
logger.info(f"Starting background generation task {task_id}")
_generation_tasks[task_id]["status"] = "running"
_generation_tasks[task_id]["started_at"] = datetime.now(timezone.utc).isoformat()
-
+
orchestrator = get_orchestrator()
response = await orchestrator.generate_content(
brief=brief,
products=products_data,
generate_images=generate_images
)
-
+
logger.info(f"Generation task {task_id} completed. Response keys: {list(response.keys()) if response else 'None'}")
-
+
# Handle image URL from orchestrator's blob save
if response.get("image_blob_url"):
blob_url = response["image_blob_url"]
@@ -479,11 +479,11 @@ async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data
del response["image_base64"]
except Exception as e:
logger.warning(f"Failed to save image to blob: {e}")
-
+
# Save to CosmosDB
try:
cosmos_service = await get_cosmos_service()
-
+
await cosmos_service.add_message_to_conversation(
conversation_id=conversation_id,
user_id=user_id,
@@ -494,7 +494,7 @@ async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data
"timestamp": datetime.now(timezone.utc).isoformat()
}
)
-
+
generated_content_to_save = {
"text_content": response.get("text_content"),
"image_url": response.get("image_url"),
@@ -511,12 +511,12 @@ async def _run_generation_task(task_id: str, brief: CreativeBrief, products_data
)
except Exception as e:
logger.warning(f"Failed to save generated content to CosmosDB: {e}")
-
+
_generation_tasks[task_id]["status"] = "completed"
_generation_tasks[task_id]["result"] = response
_generation_tasks[task_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"Task {task_id} marked as completed")
-
+
except Exception as e:
logger.exception(f"Generation task {task_id} failed: {e}")
_generation_tasks[task_id]["status"] = "failed"
@@ -529,7 +529,7 @@ async def start_generation():
"""
Start content generation and return immediately with a task ID.
Client should poll /api/generate/status/ for results.
-
+
Request body:
{
"brief": { ... CreativeBrief fields ... },
@@ -537,7 +537,7 @@ async def start_generation():
"generate_images": true/false,
"conversation_id": "uuid"
}
-
+
Returns:
{
"task_id": "uuid",
@@ -545,24 +545,23 @@ async def start_generation():
"message": "Generation started"
}
"""
- global _generation_tasks
-
+
data = await request.get_json()
-
+
brief_data = data.get("brief", {})
products_data = data.get("products", [])
generate_images = data.get("generate_images", True)
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
try:
brief = CreativeBrief(**brief_data)
except Exception as e:
return jsonify({"error": f"Invalid brief format: {str(e)}"}), 400
-
+
# Create task ID
task_id = str(uuid.uuid4())
-
+
# Initialize task state
_generation_tasks[task_id] = {
"status": "pending",
@@ -571,7 +570,7 @@ async def start_generation():
"result": None,
"error": None
}
-
+
# Save user request
try:
cosmos_service = await get_cosmos_service()
@@ -587,7 +586,7 @@ async def start_generation():
)
except Exception as e:
logger.warning(f"Failed to save generation request to CosmosDB: {e}")
-
+
# Start background task
asyncio.create_task(_run_generation_task(
task_id=task_id,
@@ -597,9 +596,9 @@ async def start_generation():
conversation_id=conversation_id,
user_id=user_id
))
-
+
logger.info(f"Started generation task {task_id} for conversation {conversation_id}")
-
+
return jsonify({
"task_id": task_id,
"status": "pending",
@@ -612,7 +611,7 @@ async def start_generation():
async def get_generation_status(task_id: str):
"""
Get the status of a generation task.
-
+
Returns:
{
"task_id": "uuid",
@@ -621,20 +620,19 @@ async def get_generation_status(task_id: str):
"error": "error message" (if failed)
}
"""
- global _generation_tasks
-
+
if task_id not in _generation_tasks:
return jsonify({"error": "Task not found"}), 404
-
+
task = _generation_tasks[task_id]
-
+
response = {
"task_id": task_id,
"status": task["status"],
"conversation_id": task.get("conversation_id"),
"created_at": task.get("created_at"),
}
-
+
if task["status"] == "completed":
response["result"] = task["result"]
response["completed_at"] = task.get("completed_at")
@@ -644,7 +642,7 @@ async def get_generation_status(task_id: str):
elif task["status"] == "running":
response["started_at"] = task.get("started_at")
response["message"] = "Generation in progress..."
-
+
return jsonify(response)
@@ -652,7 +650,7 @@ async def get_generation_status(task_id: str):
async def generate_content():
"""
Generate content from a confirmed creative brief.
-
+
Request body:
{
"brief": { ... CreativeBrief fields ... },
@@ -660,24 +658,24 @@ async def generate_content():
"generate_images": true/false,
"conversation_id": "uuid"
}
-
+
Returns streaming response with generated content.
"""
import asyncio
-
+
data = await request.get_json()
-
+
brief_data = data.get("brief", {})
products_data = data.get("products", [])
generate_images = data.get("generate_images", True)
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
try:
brief = CreativeBrief(**brief_data)
except Exception as e:
return jsonify({"error": f"Invalid brief format: {str(e)}"}), 400
-
+
# Save user request for content generation
try:
cosmos_service = await get_cosmos_service()
@@ -693,14 +691,14 @@ async def generate_content():
)
except Exception as e:
logger.warning(f"Failed to save generation request to CosmosDB: {e}")
-
+
orchestrator = get_orchestrator()
-
+
async def generate():
"""Stream content generation responses with keepalive heartbeats."""
logger.info(f"Starting SSE generator for conversation {conversation_id}")
generation_task = None
-
+
try:
# Create a task for the long-running generation
generation_task = asyncio.create_task(
@@ -711,10 +709,10 @@ async def generate():
)
)
logger.info("Generation task created")
-
+
# Send keepalive heartbeats every 15 seconds while generation is running
heartbeat_count = 0
-
+
while not generation_task.done():
# Check every 0.5 seconds (faster response to completion)
for _ in range(30): # 30 * 0.5s = 15 seconds
@@ -722,12 +720,12 @@ async def generate():
logger.info("Task completed during heartbeat wait (iteration)")
break
await asyncio.sleep(0.5)
-
+
if not generation_task.done():
heartbeat_count += 1
logger.info(f"Sending heartbeat {heartbeat_count}")
yield f"data: {json.dumps({'type': 'heartbeat', 'count': heartbeat_count, 'message': 'Generating content...'})}\n\n"
-
+
logger.info(f"Generation task completed after {heartbeat_count} heartbeats")
except asyncio.CancelledError:
logger.warning(f"SSE generator cancelled for conversation {conversation_id}")
@@ -744,7 +742,7 @@ async def generate():
if generation_task and not generation_task.done():
generation_task.cancel()
raise
-
+
# Get the result
try:
response = generation_task.result()
@@ -753,7 +751,7 @@ async def generate():
has_image_blob = bool(response.get("image_blob_url")) if response else False
image_size = len(response.get("image_base64", "")) if response else 0
logger.info(f"Has image_base64: {has_image_base64}, has_image_blob_url: {has_image_blob}, base64_size: {image_size} bytes")
-
+
# Handle image URL from orchestrator's blob save
if response.get("image_blob_url"):
blob_url = response["image_blob_url"]
@@ -787,11 +785,11 @@ async def generate():
# Keep image_base64 in response as fallback if blob storage fails
else:
logger.info("No image in response")
-
+
# Save generated content to conversation
try:
cosmos_service = await get_cosmos_service()
-
+
# Save the message
await cosmos_service.add_message_to_conversation(
conversation_id=conversation_id,
@@ -803,7 +801,7 @@ async def generate():
"timestamp": datetime.now(timezone.utc).isoformat()
}
)
-
+
# Save the full generated content for restoration
# Note: image_base64 is NOT saved to CosmosDB as it exceeds document size limits
# Images will only persist if blob storage is working
@@ -823,15 +821,15 @@ async def generate():
)
except Exception as e:
logger.warning(f"Failed to save generated content to CosmosDB: {e}")
-
+
# Format response to match what frontend expects
yield f"data: {json.dumps({'type': 'agent_response', 'content': json.dumps(response), 'is_final': True})}\n\n"
except Exception as e:
logger.exception(f"Error generating content: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': str(e), 'is_final': True})}\n\n"
-
+
yield "data: [DONE]\n\n"
-
+
return Response(
generate(),
mimetype="text/event-stream",
@@ -848,10 +846,10 @@ async def generate():
async def regenerate_content():
"""
Regenerate image based on user modification request.
-
+
This endpoint is called when the user wants to modify the generated image
after initial content generation (e.g., "show a kitchen instead of dining room").
-
+
Request body:
{
"modification_request": "User's modification request",
@@ -860,28 +858,28 @@ async def regenerate_content():
"previous_image_prompt": "Previous image prompt (optional)",
"conversation_id": "uuid"
}
-
+
Returns regenerated image with the modification applied.
"""
import asyncio
-
+
data = await request.get_json()
-
+
modification_request = data.get("modification_request", "")
brief_data = data.get("brief", {})
products_data = data.get("products", [])
previous_image_prompt = data.get("previous_image_prompt")
conversation_id = data.get("conversation_id") or str(uuid.uuid4())
user_id = data.get("user_id", "anonymous")
-
+
if not modification_request:
return jsonify({"error": "modification_request is required"}), 400
-
+
try:
brief = CreativeBrief(**brief_data)
except Exception as e:
return jsonify({"error": f"Invalid brief format: {str(e)}"}), 400
-
+
# Save user request for regeneration
try:
cosmos_service = await get_cosmos_service()
@@ -896,14 +894,14 @@ async def regenerate_content():
)
except Exception as e:
logger.warning(f"Failed to save regeneration request to CosmosDB: {e}")
-
+
orchestrator = get_orchestrator()
-
+
async def generate():
"""Stream regeneration responses with keepalive heartbeats."""
logger.info(f"Starting image regeneration for conversation {conversation_id}")
regeneration_task = None
-
+
try:
# Create a task for the regeneration
regeneration_task = asyncio.create_task(
@@ -914,7 +912,7 @@ async def generate():
previous_image_prompt=previous_image_prompt
)
)
-
+
# Send keepalive heartbeats while regeneration is running
heartbeat_count = 0
while not regeneration_task.done():
@@ -922,11 +920,11 @@ async def generate():
if regeneration_task.done():
break
await asyncio.sleep(0.5)
-
+
if not regeneration_task.done():
heartbeat_count += 1
yield f"data: {json.dumps({'type': 'heartbeat', 'count': heartbeat_count, 'message': 'Regenerating image...'})}\n\n"
-
+
except asyncio.CancelledError:
logger.warning(f"Regeneration cancelled for conversation {conversation_id}")
if regeneration_task and not regeneration_task.done():
@@ -937,18 +935,18 @@ async def generate():
if regeneration_task and not regeneration_task.done():
regeneration_task.cancel()
return
-
+
# Get the result
try:
response = regeneration_task.result()
logger.info(f"Regeneration complete. Response keys: {list(response.keys()) if response else 'None'}")
-
+
# Check for RAI block
if response.get("rai_blocked"):
yield f"data: {json.dumps({'type': 'error', 'content': response.get('error', 'Request blocked by content safety'), 'rai_blocked': True, 'is_final': True})}\n\n"
yield "data: [DONE]\n\n"
return
-
+
# Handle image URL from orchestrator's blob save
if response.get("image_blob_url"):
blob_url = response["image_blob_url"]
@@ -972,7 +970,7 @@ async def generate():
del response["image_base64"]
except Exception as e:
logger.warning(f"Failed to save regenerated image to blob: {e}")
-
+
# Save assistant response
try:
cosmos_service = await get_cosmos_service()
@@ -988,14 +986,14 @@ async def generate():
)
except Exception as e:
logger.warning(f"Failed to save regeneration response to CosmosDB: {e}")
-
+
yield f"data: {json.dumps({'type': 'agent_response', 'content': json.dumps(response), 'is_final': True})}\n\n"
except Exception as e:
logger.exception(f"Error in regeneration: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': str(e), 'is_final': True})}\n\n"
-
+
yield "data: [DONE]\n\n"
-
+
return Response(
generate(),
mimetype="text/event-stream",
@@ -1019,17 +1017,17 @@ async def proxy_generated_image(conversation_id: str, filename: str):
try:
blob_service = await get_blob_service()
await blob_service.initialize()
-
+
blob_name = f"{conversation_id}/{filename}"
blob_client = blob_service._generated_images_container.get_blob_client(blob_name)
-
+
# Download the blob
download = await blob_client.download_blob()
image_data = await download.readall()
-
+
# Determine content type from filename
content_type = "image/png" if filename.endswith(".png") else "image/jpeg"
-
+
return Response(
image_data,
mimetype=content_type,
@@ -1052,26 +1050,26 @@ async def proxy_product_image(filename: str):
try:
blob_service = await get_blob_service()
await blob_service.initialize()
-
+
blob_client = blob_service._product_images_container.get_blob_client(filename)
-
+
# Get blob properties for ETag/Last-Modified
properties = await blob_client.get_blob_properties()
etag = properties.etag.strip('"') if properties.etag else None
last_modified = properties.last_modified
-
+
# Check If-None-Match header for cache validation
if_none_match = request.headers.get("If-None-Match")
if if_none_match and etag and if_none_match.strip('"') == etag:
return Response(status=304) # Not Modified
-
+
# Download the blob
download = await blob_client.download_blob()
image_data = await download.readall()
-
+
# Determine content type from filename
content_type = "image/png" if filename.endswith(".png") else "image/jpeg"
-
+
headers = {
"Cache-Control": "public, max-age=300, must-revalidate", # Cache 5 min, revalidate
}
@@ -1079,7 +1077,7 @@ async def proxy_product_image(filename: str):
headers["ETag"] = f'"{etag}"'
if last_modified:
headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT")
-
+
return Response(
image_data,
mimetype=content_type,
@@ -1096,7 +1094,7 @@ async def proxy_product_image(filename: str):
async def list_products():
"""
List all products.
-
+
Query params:
category: Filter by category
sub_category: Filter by sub-category
@@ -1107,9 +1105,9 @@ async def list_products():
sub_category = request.args.get("sub_category")
search = request.args.get("search")
limit = int(request.args.get("limit", 20))
-
+
cosmos_service = await get_cosmos_service()
-
+
if search:
products = await cosmos_service.search_products(search, limit)
elif category:
@@ -1118,7 +1116,7 @@ async def list_products():
)
else:
products = await cosmos_service.get_all_products(limit)
-
+
# Convert blob URLs to proxy URLs for products with images
product_list = []
for p in products:
@@ -1130,7 +1128,7 @@ async def list_products():
filename = original_url.split("/")[-1] if "/" in original_url else original_url
product_dict["image_url"] = f"/api/product-images/{filename}"
product_list.append(product_dict)
-
+
return jsonify({
"products": product_list,
"count": len(product_list)
@@ -1142,17 +1140,17 @@ async def get_product(sku: str):
"""Get a product by SKU."""
cosmos_service = await get_cosmos_service()
product = await cosmos_service.get_product_by_sku(sku)
-
+
if not product:
return jsonify({"error": "Product not found"}), 404
-
+
product_dict = product.model_dump()
# Convert direct blob URL to proxy URL
if product_dict.get("image_url"):
original_url = product_dict["image_url"]
filename = original_url.split("/")[-1] if "/" in original_url else original_url
product_dict["image_url"] = f"/api/product-images/{filename}"
-
+
return jsonify(product_dict)
@@ -1160,7 +1158,7 @@ async def get_product(sku: str):
async def create_product():
"""
Create or update a product.
-
+
Request body:
{
"product_name": "...",
@@ -1173,15 +1171,15 @@ async def create_product():
}
"""
data = await request.get_json()
-
+
try:
product = Product(**data)
except Exception as e:
return jsonify({"error": f"Invalid product format: {str(e)}"}), 400
-
+
cosmos_service = await get_cosmos_service()
saved_product = await cosmos_service.upsert_product(product)
-
+
return jsonify(saved_product.model_dump()), 201
@@ -1189,38 +1187,38 @@ async def create_product():
async def upload_product_image(sku: str):
"""
Upload an image for a product.
-
+
The image will be stored and a description will be auto-generated
using GPT-5 Vision.
-
+
Request: multipart/form-data with 'image' file
"""
cosmos_service = await get_cosmos_service()
product = await cosmos_service.get_product_by_sku(sku)
-
+
if not product:
return jsonify({"error": "Product not found"}), 404
-
+
files = await request.files
if "image" not in files:
return jsonify({"error": "No image file provided"}), 400
-
+
image_file = files["image"]
image_data = image_file.read()
content_type = image_file.content_type or "image/jpeg"
-
+
blob_service = await get_blob_service()
image_url, description = await blob_service.upload_product_image(
sku=sku,
image_data=image_data,
content_type=content_type
)
-
+
# Update product with image info
product.image_url = image_url
product.image_description = description
await cosmos_service.upsert_product(product)
-
+
return jsonify({
"image_url": image_url,
"image_description": description,
@@ -1234,21 +1232,21 @@ async def upload_product_image(sku: str):
async def list_conversations():
"""
List conversations for a user.
-
+
Uses authenticated user from EasyAuth headers. In development mode
(when not authenticated), uses "anonymous" as user_id.
-
+
Query params:
limit: Max number of results (default 20)
"""
auth_user = get_authenticated_user()
user_id = auth_user["user_principal_id"]
-
+
limit = int(request.args.get("limit", 20))
-
+
cosmos_service = await get_cosmos_service()
conversations = await cosmos_service.get_user_conversations(user_id, limit)
-
+
return jsonify({
"conversations": conversations,
"count": len(conversations)
@@ -1259,18 +1257,18 @@ async def list_conversations():
async def get_conversation(conversation_id: str):
"""
Get a specific conversation.
-
+
Uses authenticated user from EasyAuth headers.
"""
auth_user = get_authenticated_user()
user_id = auth_user["user_principal_id"]
-
+
cosmos_service = await get_cosmos_service()
conversation = await cosmos_service.get_conversation(conversation_id, user_id)
-
+
if not conversation:
return jsonify({"error": "Conversation not found"}), 404
-
+
return jsonify(conversation)
@@ -1278,12 +1276,12 @@ async def get_conversation(conversation_id: str):
async def delete_conversation(conversation_id: str):
"""
Delete a specific conversation.
-
+
Uses authenticated user from EasyAuth headers.
"""
auth_user = get_authenticated_user()
user_id = auth_user["user_principal_id"]
-
+
try:
cosmos_service = await get_cosmos_service()
await cosmos_service.delete_conversation(conversation_id, user_id)
@@ -1297,9 +1295,9 @@ async def delete_conversation(conversation_id: str):
async def update_conversation(conversation_id: str):
"""
Update a conversation (rename).
-
+
Uses authenticated user from EasyAuth headers.
-
+
Request body:
{
"title": "New conversation title"
@@ -1307,13 +1305,13 @@ async def update_conversation(conversation_id: str):
"""
auth_user = get_authenticated_user()
user_id = auth_user["user_principal_id"]
-
+
data = await request.get_json()
new_title = data.get("title", "").strip()
-
+
if not new_title:
return jsonify({"error": "Title is required"}), 400
-
+
try:
cosmos_service = await get_cosmos_service()
result = await cosmos_service.rename_conversation(conversation_id, user_id, new_title)
@@ -1364,24 +1362,24 @@ async def get_ui_config():
async def startup():
"""Initialize services on application startup."""
logger.info("Starting Content Generation Solution Accelerator...")
-
+
# Initialize orchestrator
get_orchestrator()
logger.info("Orchestrator initialized with Microsoft Agent Framework")
-
+
# Try to initialize services - they may fail if CosmosDB/Blob storage is not accessible
try:
await get_cosmos_service()
logger.info("CosmosDB service initialized")
except Exception as e:
logger.warning(f"CosmosDB service initialization failed (may be firewall): {e}")
-
+
try:
await get_blob_service()
logger.info("Blob storage service initialized")
except Exception as e:
logger.warning(f"Blob storage service initialization failed: {e}")
-
+
logger.info("Application startup complete")
@@ -1389,13 +1387,13 @@ async def startup():
async def shutdown():
"""Cleanup on application shutdown."""
logger.info("Shutting down Content Generation Solution Accelerator...")
-
+
cosmos_service = await get_cosmos_service()
await cosmos_service.close()
-
+
blob_service = await get_blob_service()
await blob_service.close()
-
+
logger.info("Application shutdown complete")
diff --git a/content-gen/src/backend/models.py b/content-gen/src/backend/models.py
index cd357a226..5b9a5c79a 100644
--- a/content-gen/src/backend/models.py
+++ b/content-gen/src/backend/models.py
@@ -33,12 +33,12 @@ class ComplianceResult(BaseModel):
"""Result of compliance validation on generated content."""
is_valid: bool = Field(description="True if no error-level violations")
violations: List[ComplianceViolation] = Field(default_factory=list)
-
+
@property
def has_errors(self) -> bool:
"""Check if there are any error-level violations."""
return any(v.severity == ComplianceSeverity.ERROR for v in self.violations)
-
+
@property
def has_warnings(self) -> bool:
"""Check if there are any warning-level violations."""
@@ -48,7 +48,7 @@ def has_warnings(self) -> bool:
class CreativeBrief(BaseModel):
"""
Structured creative brief parsed from free-text input.
-
+
The PlanningAgent extracts these fields from user's natural language
creative brief description.
"""
@@ -61,7 +61,7 @@ class CreativeBrief(BaseModel):
timelines: str = Field(description="Due dates and milestones")
visual_guidelines: str = Field(description="Image requirements and visual direction")
cta: str = Field(description="Call to action text and placement")
-
+
# Metadata
raw_input: Optional[str] = Field(default=None, description="Original free-text input")
confidence_score: Optional[float] = Field(default=None, description="Extraction confidence 0-1")
@@ -70,7 +70,7 @@ class CreativeBrief(BaseModel):
class Product(BaseModel):
"""
Product information stored in CosmosDB.
-
+
Designed for paint catalog products with name, description, tags, and price.
Image URLs reference product images stored in Azure Blob Storage.
"""
@@ -81,7 +81,7 @@ class Product(BaseModel):
price: float = Field(description="Price in USD")
sku: str = Field(description="Stock keeping unit identifier (e.g., 'CP-0001')")
image_url: Optional[str] = Field(default=None, description="URL to product image in Blob Storage")
-
+
# Legacy fields for backward compatibility (optional)
category: Optional[str] = Field(default="Paint", description="Product category")
sub_category: Optional[str] = Field(default=None, description="Sub-category")
@@ -89,7 +89,7 @@ class Product(BaseModel):
detailed_spec_description: Optional[str] = Field(default=None, description="Detailed specs")
model: Optional[str] = Field(default=None, description="Model number")
image_description: Optional[str] = Field(default=None, description="Text description of image")
-
+
# Metadata
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -121,7 +121,7 @@ class ContentGenerationResponse(BaseModel):
products_used: List[str] = Field(default_factory=list, description="Product IDs used")
generation_id: str = Field(description="Unique ID for this generation")
created_at: datetime = Field(default_factory=datetime.utcnow)
-
+
@property
def requires_modification(self) -> bool:
"""Check if content has error-level violations requiring modification."""
@@ -137,7 +137,7 @@ class ConversationMessage(BaseModel):
content: str
created_at: datetime = Field(default_factory=datetime.utcnow)
feedback: Optional[str] = None
-
+
# For multimodal responses
image_base64: Optional[str] = None
compliance_warnings: Optional[List[ComplianceViolation]] = None
diff --git a/content-gen/src/backend/orchestrator.py b/content-gen/src/backend/orchestrator.py
index 3e90b0471..e1ad74ab5 100644
--- a/content-gen/src/backend/orchestrator.py
+++ b/content-gen/src/backend/orchestrator.py
@@ -21,9 +21,6 @@
import re
from typing import AsyncIterator, Optional, cast
-# Token endpoint for Azure Cognitive Services (used for Azure OpenAI)
-TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default"
-
from agent_framework import (
ChatMessage,
HandoffBuilder,
@@ -48,6 +45,9 @@
logger = logging.getLogger(__name__)
+# Token endpoint for Azure Cognitive Services (used for Azure OpenAI)
+TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default"
+
# Harmful content patterns to detect in USER INPUT before processing
# This provides proactive content safety by blocking harmful requests at the input layer
@@ -85,27 +85,27 @@
def _check_input_for_harmful_content(message: str) -> tuple[bool, str]:
"""
Proactively check user input for harmful content BEFORE sending to agents.
-
+
This is the first line of defense - catching harmful requests at the input
layer rather than relying on the agent to refuse.
-
+
Args:
message: The user's input message
-
+
Returns:
tuple: (is_harmful: bool, matched_pattern: str or empty)
"""
if not message:
return False, ""
-
+
message_lower = message.lower()
-
+
for i, pattern in enumerate(_HARMFUL_PATTERNS_COMPILED):
if pattern.search(message_lower):
matched = HARMFUL_INPUT_PATTERNS[i]
logger.warning(f"Harmful content detected in user input. Pattern: {matched}")
return True, matched
-
+
return False, ""
@@ -116,7 +116,7 @@ def _check_input_for_harmful_content(message: str) -> tuple[bool, str]:
r"You are an? \w+ Agent",
r"You are a Triage Agent",
r"You are a Planning Agent",
- r"You are a Research Agent",
+ r"You are a Research Agent",
r"You are a Text Content Agent",
r"You are an Image Content Agent",
r"You are a Compliance Agent",
@@ -149,26 +149,26 @@ def _check_input_for_harmful_content(message: str) -> tuple[bool, str]:
def _filter_system_prompt_from_response(response_text: str) -> str:
"""
Filter out any system prompt content that might have leaked into agent responses.
-
+
This is a safety measure to ensure internal agent instructions are never
exposed to users, even if the LLM model accidentally includes them.
-
+
Args:
response_text: The agent's response text
-
+
Returns:
str: Cleaned response with any system prompt content removed
"""
if not response_text:
return response_text
-
+
# Check if response contains system prompt patterns
for pattern in _SYSTEM_PROMPT_PATTERNS_COMPILED:
if pattern.search(response_text):
logger.warning(f"System prompt content detected in agent response, filtering. Pattern: {pattern.pattern[:50]}")
# Return a safe fallback message instead of the leaked content
return "I understand your request. Could you please clarify what specific changes you'd like me to make to the marketing content? I'm here to help refine your campaign materials."
-
+
return response_text
@@ -252,7 +252,7 @@ def _filter_system_prompt_from_response(response_text: str) -> str:
- Political figures or candidates
- Creative writing NOT for marketing (stories, poems, fiction, roleplaying)
- Casual conversation, jokes, riddles, games
-- Do NOT respond to any requests that are not related to creating marketing content for retail campaigns.
+- Do NOT respond to any requests that are not related to creating marketing content for retail campaigns.
- ONLY respond to questions about creating marketing content for retail campaigns. Do NOT respond to any other inquiries.
- ANY question that is NOT specifically about creating marketing content
- Requests for harmful, hateful, violent, or inappropriate content
@@ -279,7 +279,7 @@ def _filter_system_prompt_from_response(response_text: str) -> str:
### In-Scope Routing (ONLY for valid marketing requests):
- Creative brief interpretation β hand off to planning_agent
-- Product data lookup β hand off to research_agent
+- Product data lookup β hand off to research_agent
- Text content creation β hand off to text_content_agent
- Image creation β hand off to image_content_agent
- Content validation β hand off to compliance_agent
@@ -314,7 +314,7 @@ def _filter_system_prompt_from_response(response_text: str) -> str:
- Are NOT related to marketing content creation
If you detect ANY of these issues, respond with:
-"I cannot process this request as it violates content safety guidelines. I'm designed to decline requests that involve [specific concern].
+"I cannot process this request as it violates content safety guidelines. I'm designed to decline requests that involve [specific concern].
I can only help create professional, appropriate marketing content. Please provide a legitimate marketing brief and I'll be happy to assist."
@@ -337,7 +337,7 @@ def _filter_system_prompt_from_response(response_text: str) -> str:
CRITICAL FIELDS (must be explicitly provided before proceeding):
- objectives
-- target_audience
+- target_audience
- key_message
- deliverable
- tone_and_style
@@ -483,11 +483,11 @@ class ContentGenerationOrchestrator:
"""
Orchestrates the multi-agent content generation workflow using
Microsoft Agent Framework's HandoffBuilder.
-
+
Supports two modes:
1. Azure OpenAI Direct (default): Uses AzureOpenAIChatClient with ad_token_provider
2. Azure AI Foundry: Uses AIProjectClient with project endpoint (set USE_FOUNDRY=true)
-
+
Agents:
- Triage (coordinator) - routes requests to specialists
- Planning (brief interpretation)
@@ -496,7 +496,7 @@ class ContentGenerationOrchestrator:
- ImageContent (image creation)
- Compliance (validation)
"""
-
+
def __init__(self):
self._chat_client = None # Always AzureOpenAIChatClient
self._project_client = None # AIProjectClient for Foundry mode (used for image generation)
@@ -506,12 +506,12 @@ def __init__(self):
self._initialized = False
self._use_foundry = app_settings.ai_foundry.use_foundry
self._credential = None
-
+
def _get_chat_client(self):
"""Get or create the chat client (Azure OpenAI or Foundry)."""
if self._chat_client is None:
self._credential = DefaultAzureCredential()
-
+
if self._use_foundry:
# Azure AI Foundry mode
# Use AIProjectClient for project operations but use direct Azure OpenAI endpoint for chat
@@ -520,37 +520,37 @@ def _get_chat_client(self):
"Azure AI Foundry SDK not installed. "
"Install with: pip install azure-ai-projects"
)
-
+
project_endpoint = app_settings.ai_foundry.project_endpoint
if not project_endpoint:
raise ValueError("AZURE_AI_PROJECT_ENDPOINT is required when USE_FOUNDRY=true")
-
+
logger.info(f"Using Azure AI Foundry mode with project: {project_endpoint}")
-
+
# Create the AIProjectClient for project-specific operations (e.g., image generation)
project_client = AIProjectClient(
endpoint=project_endpoint,
credential=self._credential,
)
-
+
# Store the project client for image generation
self._project_client = project_client
-
+
# For chat completions, use the direct Azure OpenAI endpoint
# The Foundry project uses Azure OpenAI under the hood, and we need the direct endpoint
# to properly authenticate with Cognitive Services token
azure_endpoint = app_settings.azure_openai.endpoint
if not azure_endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT is required for Foundry mode chat completions")
-
+
def get_token() -> str:
"""Token provider callable - invoked for each request to ensure fresh tokens."""
token = self._credential.get_token(TOKEN_ENDPOINT)
return token.token
-
+
model_deployment = app_settings.ai_foundry.model_deployment or app_settings.azure_openai.gpt_model
api_version = app_settings.azure_openai.api_version
-
+
logger.info(f"Foundry mode using Azure OpenAI endpoint: {azure_endpoint}, deployment: {model_deployment}")
self._chat_client = AzureOpenAIChatClient(
endpoint=azure_endpoint,
@@ -563,12 +563,12 @@ def get_token() -> str:
endpoint = app_settings.azure_openai.endpoint
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT is not configured")
-
+
def get_token() -> str:
"""Token provider callable - invoked for each request to ensure fresh tokens."""
token = self._credential.get_token(TOKEN_ENDPOINT)
return token.token
-
+
logger.info("Using Azure OpenAI Direct mode with ad_token_provider")
self._chat_client = AzureOpenAIChatClient(
endpoint=endpoint,
@@ -577,47 +577,47 @@ def get_token() -> str:
ad_token_provider=get_token,
)
return self._chat_client
-
+
def initialize(self) -> None:
"""Initialize all agents and build the handoff workflow."""
if self._initialized:
return
-
+
mode_str = "Azure AI Foundry" if self._use_foundry else "Azure OpenAI Direct"
logger.info(f"Initializing Content Generation Orchestrator ({mode_str} mode)...")
-
+
# Get the chat client
chat_client = self._get_chat_client()
-
+
# Agent names - use underscores (AzureOpenAIChatClient works with both modes now)
name_sep = "_"
-
+
# Create all agents
triage_agent = chat_client.create_agent(
name=f"triage{name_sep}agent",
instructions=TRIAGE_INSTRUCTIONS,
)
-
+
planning_agent = chat_client.create_agent(
name=f"planning{name_sep}agent",
instructions=PLANNING_INSTRUCTIONS,
)
-
+
research_agent = chat_client.create_agent(
name=f"research{name_sep}agent",
instructions=RESEARCH_INSTRUCTIONS,
)
-
+
text_content_agent = chat_client.create_agent(
name=f"text{name_sep}content{name_sep}agent",
instructions=TEXT_CONTENT_INSTRUCTIONS,
)
-
+
image_content_agent = chat_client.create_agent(
name=f"image{name_sep}content{name_sep}agent",
instructions=IMAGE_CONTENT_INSTRUCTIONS,
)
-
+
compliance_agent = chat_client.create_agent(
name=f"compliance{name_sep}agent",
instructions=COMPLIANCE_INSTRUCTIONS,
@@ -638,7 +638,7 @@ def initialize(self) -> None:
# Workflow name - Foundry requires hyphens
workflow_name = f"content{name_sep}generation{name_sep}workflow"
-
+
# Build the handoff workflow
# Triage can route to all specialists
# Specialists hand back to triage after completing their task
@@ -658,10 +658,10 @@ def initialize(self) -> None:
.with_start_agent(triage_agent)
# Triage can hand off to all specialists
.add_handoff(triage_agent, [
- planning_agent,
- research_agent,
- text_content_agent,
- image_content_agent,
+ planning_agent,
+ research_agent,
+ text_content_agent,
+ image_content_agent,
compliance_agent
])
# All specialists can hand back to triage
@@ -678,10 +678,10 @@ def initialize(self) -> None:
)
.build()
)
-
+
self._initialized = True
logger.info(f"Content Generation Orchestrator initialized successfully ({mode_str} mode)")
-
+
async def process_message(
self,
message: str,
@@ -690,23 +690,23 @@ async def process_message(
) -> AsyncIterator[dict]:
"""
Process a user message through the orchestrated workflow.
-
+
Uses the Agent Framework's HandoffBuilder workflow to coordinate
between specialized agents.
-
+
Args:
message: The user's input message
conversation_id: Unique identifier for the conversation
context: Optional context (previous messages, user preferences)
-
+
Yields:
dict: Response chunks with agent responses and status updates
"""
if not self._initialized:
self.initialize()
-
+
logger.info(f"Processing message for conversation {conversation_id}")
-
+
# PROACTIVE CONTENT SAFETY CHECK - Block harmful content at input layer
# This is the first line of defense, before any agent processes the request
is_harmful, matched_pattern = _check_input_for_harmful_content(message)
@@ -723,18 +723,18 @@ async def process_message(
"metadata": {"conversation_id": conversation_id}
}
return # Exit immediately - do not process through agents
-
+
# Prepare the input with context
full_input = message
if context:
full_input = f"Context:\n{json.dumps(context, indent=2)}\n\nUser Message:\n{message}"
-
+
try:
# Collect events from the workflow stream
events = []
async for event in self._workflow.run_stream(full_input):
events.append(event)
-
+
# Handle different event types from the workflow
if isinstance(event, WorkflowStatusEvent):
yield {
@@ -743,7 +743,7 @@ async def process_message(
"is_final": False,
"metadata": {"conversation_id": conversation_id}
}
-
+
elif isinstance(event, RequestInfoEvent):
# Workflow is requesting user input
if isinstance(event.data, HandoffAgentUserRequest):
@@ -751,17 +751,17 @@ async def process_message(
messages = event.data.agent_response.messages if hasattr(event.data, 'agent_response') and event.data.agent_response else []
if not isinstance(messages, list):
messages = [messages] if messages else []
-
+
conversation_text = "\n".join([
f"{msg.author_name or msg.role.value}: {msg.text}"
for msg in messages
])
-
+
# Get the last message content and filter any system prompt leakage
last_msg_content = messages[-1].text if messages else (event.data.agent_response.text if hasattr(event.data, 'agent_response') and event.data.agent_response else "")
last_msg_content = _filter_system_prompt_from_response(last_msg_content)
last_msg_agent = messages[-1].author_name if messages and hasattr(messages[-1], 'author_name') else "unknown"
-
+
yield {
"type": "agent_response",
"agent": last_msg_agent,
@@ -772,14 +772,14 @@ async def process_message(
"request_id": event.request_id,
"metadata": {"conversation_id": conversation_id}
}
-
+
elif isinstance(event, WorkflowOutputEvent):
# Final output from the workflow
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list) and conversation:
# Get the last assistant message as the final response
assistant_messages = [
- msg for msg in conversation
+ msg for msg in conversation
if msg.role.value != "user"
]
if assistant_messages:
@@ -793,7 +793,7 @@ async def process_message(
"is_final": True,
"metadata": {"conversation_id": conversation_id}
}
-
+
except Exception as e:
logger.exception(f"Error processing message: {e}")
yield {
@@ -802,7 +802,7 @@ async def process_message(
"is_final": True,
"metadata": {"conversation_id": conversation_id}
}
-
+
async def send_user_response(
self,
request_id: str,
@@ -811,18 +811,18 @@ async def send_user_response(
) -> AsyncIterator[dict]:
"""
Send a user response to a pending workflow request.
-
+
Args:
request_id: The ID of the pending request
user_response: The user's response
conversation_id: Unique identifier for the conversation
-
+
Yields:
dict: Response chunks from continuing the workflow
"""
if not self._initialized:
self.initialize()
-
+
# PROACTIVE CONTENT SAFETY CHECK - Block harmful content in follow-up messages too
is_harmful, matched_pattern = _check_input_for_harmful_content(user_response)
if is_harmful:
@@ -837,7 +837,7 @@ async def send_user_response(
"metadata": {"conversation_id": conversation_id}
}
return # Exit immediately - do not continue workflow
-
+
try:
responses = {request_id: user_response}
async for event in self._workflow.send_responses_streaming(responses):
@@ -848,19 +848,19 @@ async def send_user_response(
"is_final": False,
"metadata": {"conversation_id": conversation_id}
}
-
+
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffAgentUserRequest):
# Get messages from agent_response (updated API)
messages = event.data.agent_response.messages if hasattr(event.data, 'agent_response') and event.data.agent_response else []
if not isinstance(messages, list):
messages = [messages] if messages else []
-
+
# Get the last message content and filter any system prompt leakage
last_msg_content = messages[-1].text if messages else (event.data.agent_response.text if hasattr(event.data, 'agent_response') and event.data.agent_response else "")
last_msg_content = _filter_system_prompt_from_response(last_msg_content)
last_msg_agent = messages[-1].author_name if messages and hasattr(messages[-1], 'author_name') else "unknown"
-
+
yield {
"type": "agent_response",
"agent": last_msg_agent,
@@ -870,12 +870,12 @@ async def send_user_response(
"request_id": event.request_id,
"metadata": {"conversation_id": conversation_id}
}
-
+
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list) and conversation:
assistant_messages = [
- msg for msg in conversation
+ msg for msg in conversation
if msg.role.value != "user"
]
if assistant_messages:
@@ -889,7 +889,7 @@ async def send_user_response(
"is_final": True,
"metadata": {"conversation_id": conversation_id}
}
-
+
except Exception as e:
logger.exception(f"Error sending user response: {e}")
yield {
@@ -898,7 +898,7 @@ async def send_user_response(
"is_final": True,
"metadata": {"conversation_id": conversation_id}
}
-
+
async def parse_brief(
self,
brief_text: str
@@ -906,10 +906,10 @@ async def parse_brief(
"""
Parse a free-text creative brief into structured format.
If critical information is missing, return clarifying questions.
-
+
Args:
brief_text: Free-text creative brief from user
-
+
Returns:
tuple: (CreativeBrief, clarifying_questions_or_none, is_blocked)
- If all critical fields are provided: (brief, None, False)
@@ -918,7 +918,7 @@ async def parse_brief(
"""
if not self._initialized:
self.initialize()
-
+
# PROACTIVE CONTENT SAFETY CHECK - Block harmful content at input layer
is_harmful, matched_pattern = _check_input_for_harmful_content(brief_text)
if is_harmful:
@@ -936,13 +936,13 @@ async def parse_brief(
cta=""
)
return empty_brief, RAI_HARMFUL_CONTENT_RESPONSE, True
-
+
# SECONDARY RAI CHECK - Use LLM-based classifier for comprehensive safety/scope validation
try:
rai_response = await self._rai_agent.run(brief_text)
rai_result = str(rai_response).strip().upper()
logger.info(f"RAI agent response for parse_brief: {rai_result}")
-
+
if rai_result == "TRUE":
logger.warning(f"RAI agent blocked content in parse_brief: {brief_text[:100]}...")
empty_brief = CreativeBrief(
@@ -961,7 +961,7 @@ async def parse_brief(
# Log the error but continue - don't block legitimate requests due to RAI agent failures
logger.warning(f"RAI agent check failed in parse_brief, continuing: {rai_error}")
planning_agent = self._agents["planning"]
-
+
# First, analyze the brief and check for missing critical fields
analysis_prompt = f"""
Analyze this creative brief request and determine if all critical information is provided.
@@ -1006,10 +1006,10 @@ async def parse_brief(
- Do NOT invent or assume information that wasn't explicitly stated
- Make clarifying questions specific to the user's context (reference their product/campaign)
"""
-
+
# Use the agent's run method
response = await planning_agent.run(analysis_prompt)
-
+
# Parse the analysis response
try:
response_text = str(response)
@@ -1021,10 +1021,10 @@ async def parse_brief(
json_start = response_text.index("```") + 3
json_end = response_text.index("```", json_start)
response_text = response_text[json_start:json_end].strip()
-
+
analysis = json.loads(response_text)
brief_data = analysis.get("extracted_fields", {})
-
+
# Ensure all fields are strings
for key in brief_data:
if isinstance(brief_data[key], dict):
@@ -1035,26 +1035,26 @@ async def parse_brief(
brief_data[key] = ""
elif not isinstance(brief_data[key], str):
brief_data[key] = str(brief_data[key])
-
+
# Ensure all required fields exist
- for field in ['overview', 'objectives', 'target_audience', 'key_message',
+ for field in ['overview', 'objectives', 'target_audience', 'key_message',
'tone_and_style', 'deliverable', 'timelines', 'visual_guidelines', 'cta']:
if field not in brief_data:
brief_data[field] = ""
-
+
brief = CreativeBrief(**brief_data)
-
+
# Check if we need clarifying questions
if analysis.get("status") == "incomplete" and analysis.get("clarifying_message"):
return (brief, analysis["clarifying_message"], False)
-
+
return (brief, None, False)
-
+
except Exception as e:
logger.error(f"Failed to parse brief analysis response: {e}")
# Fallback to basic extraction
return (self._extract_brief_from_text(brief_text), None, False)
-
+
def _extract_brief_from_text(self, text: str) -> CreativeBrief:
"""Extract brief fields from labeled text like 'Overview: ...'"""
fields = {
@@ -1068,7 +1068,7 @@ def _extract_brief_from_text(self, text: str) -> CreativeBrief:
'visual_guidelines': '',
'cta': ''
}
-
+
# Common label variations
label_map = {
'overview': ['overview'],
@@ -1081,15 +1081,15 @@ def _extract_brief_from_text(self, text: str) -> CreativeBrief:
'visual_guidelines': ['visual guidelines', 'visual_guidelines', 'visuals'],
'cta': ['call to action', 'cta', 'call-to-action']
}
-
+
lines = text.strip().split('\n')
current_field = None
-
+
for line in lines:
line = line.strip()
if not line:
continue
-
+
# Check if line starts with a label
found_label = False
for field, labels in label_map.items():
@@ -1103,17 +1103,17 @@ def _extract_brief_from_text(self, text: str) -> CreativeBrief:
break
if found_label:
break
-
+
# If no label found and we have a current field, append to it
if not found_label and current_field:
fields[current_field] += ' ' + line
-
+
# If no fields were extracted, put everything in overview
if not any(fields.values()):
fields['overview'] = text
-
+
return CreativeBrief(**fields)
-
+
async def select_products(
self,
request_text: str,
@@ -1122,20 +1122,20 @@ async def select_products(
) -> dict:
"""
Select or modify product selection via natural language.
-
+
Args:
request_text: User's natural language request for product selection
current_products: Currently selected products (for modifications)
available_products: List of available products to choose from
-
+
Returns:
dict: Selected products and assistant message
"""
if not self._initialized:
self.initialize()
-
+
research_agent = self._agents["research"]
-
+
select_prompt = f"""
You are helping a user select products for a marketing campaign.
@@ -1167,11 +1167,11 @@ async def select_products(
- For "search" action: include products matching the search criteria
- Return complete product objects from the available catalog, not just names
"""
-
+
try:
response = await research_agent.run(select_prompt)
response_text = str(response)
-
+
# Extract JSON from response
if "```json" in response_text:
json_start = response_text.index("```json") + 7
@@ -1181,7 +1181,7 @@ async def select_products(
json_start = response_text.index("```") + 3
json_end = response_text.index("```", json_start)
response_text = response_text[json_start:json_end].strip()
-
+
result = json.loads(response_text)
return {
"products": result.get("selected_products", []),
@@ -1199,11 +1199,11 @@ async def select_products(
async def _generate_foundry_image(self, image_prompt: str, results: dict) -> None:
"""Generate image using direct REST API call to Azure OpenAI endpoint.
-
+
Azure AI Foundry's agent-based image generation (Responses API) returns
text descriptions instead of actual image data. This method uses a direct
REST API call to the images/generations endpoint instead.
-
+
Args:
image_prompt: The prompt for image generation
results: The results dict to update with image data
@@ -1214,35 +1214,35 @@ async def _generate_foundry_image(self, image_prompt: str, results: dict) -> Non
logger.error("httpx package not installed - required for Foundry image generation")
results["image_error"] = "httpx package required for Foundry image generation"
return
-
+
try:
if not self._credential:
logger.error("Azure credential not available")
results["image_error"] = "Azure credential not configured"
return
-
+
# Get token for Azure Cognitive Services
token = self._credential.get_token(TOKEN_ENDPOINT)
-
+
# Use the direct Azure OpenAI endpoint for image generation
# This is different from the project endpoint - it goes directly to Azure OpenAI
image_endpoint = app_settings.azure_openai.image_endpoint
if not image_endpoint:
# Fallback: try to derive from regular OpenAI endpoint
image_endpoint = app_settings.azure_openai.endpoint
-
+
if not image_endpoint:
logger.error("No Azure OpenAI image endpoint configured")
results["image_error"] = "Image endpoint not configured"
return
-
+
# Ensure endpoint doesn't end with /
image_endpoint = image_endpoint.rstrip('/')
-
+
image_deployment = app_settings.ai_foundry.image_deployment
if not image_deployment:
image_deployment = app_settings.azure_openai.image_model
-
+
# The direct image API endpoint
image_api_url = f"{image_endpoint}/openai/deployments/{image_deployment}/images/generations"
@@ -1256,7 +1256,7 @@ async def _generate_foundry_image(self, image_prompt: str, results: dict) -> Non
logger.info(f"Calling Foundry direct image API: {image_api_url}")
logger.info(f"Prompt: {image_prompt[:200]}...")
-
+
headers = {
"Authorization": f"Bearer {token.token}",
"Content-Type": "application/json",
@@ -1287,27 +1287,27 @@ async def _generate_foundry_image(self, image_prompt: str, results: dict) -> Non
headers=headers,
json=payload,
)
-
+
if response.status_code != 200:
error_text = response.text
logger.error(f"Foundry image API error {response.status_code}: {error_text[:500]}")
results["image_error"] = f"API error {response.status_code}: {error_text[:200]}"
return
-
+
response_data = response.json()
-
+
# Extract image data from response
data = response_data.get("data", [])
if not data:
logger.error("No image data in Foundry API response")
results["image_error"] = "No image data in API response"
return
-
+
image_item = data[0]
-
+
# Try to get base64 data (check both 'b64_json' and 'b64' fields)
image_base64 = image_item.get("b64_json") or image_item.get("b64")
-
+
if not image_base64:
# If URL is provided instead, fetch the image
image_url = image_item.get("url")
@@ -1324,15 +1324,15 @@ async def _generate_foundry_image(self, image_prompt: str, results: dict) -> Non
logger.error(f"No base64 or URL in response. Keys: {list(image_item.keys())}")
results["image_error"] = f"No image data in response. Keys: {list(image_item.keys())}"
return
-
+
# Store revised prompt if available
revised_prompt = image_item.get("revised_prompt")
if revised_prompt:
results["image_revised_prompt"] = revised_prompt
logger.info(f"Revised prompt: {revised_prompt[:100]}...")
-
+
logger.info(f"Received image data ({len(image_base64)} chars)")
-
+
# Validate base64 data
try:
decoded = base64.b64decode(image_base64)
@@ -1341,10 +1341,10 @@ async def _generate_foundry_image(self, image_prompt: str, results: dict) -> Non
logger.error(f"Failed to decode image data: {e}")
results["image_error"] = f"Failed to decode image: {e}"
return
-
+
# Save to blob storage
await self._save_image_to_blob(image_base64, results)
-
+
except httpx.TimeoutException:
logger.error("Foundry image generation request timed out")
results["image_error"] = "Image generation timed out after 120 seconds"
@@ -1354,7 +1354,7 @@ async def _generate_foundry_image(self, image_prompt: str, results: dict) -> Non
async def _save_image_to_blob(self, image_base64: str, results: dict) -> None:
"""Save generated image to blob storage.
-
+
Args:
image_base64: Base64-encoded image data
results: The results dict to update with blob URL or base64 fallback
@@ -1362,16 +1362,16 @@ async def _save_image_to_blob(self, image_base64: str, results: dict) -> None:
try:
from services.blob_service import BlobStorageService
from datetime import datetime
-
+
blob_service = BlobStorageService()
gen_id = datetime.utcnow().strftime("%Y%m%d%H%M%S")
logger.info(f"Saving image to blob storage (size: {len(image_base64)} bytes)...")
-
+
blob_url = await blob_service.save_generated_image(
conversation_id=f"gen_{gen_id}",
image_base64=image_base64
)
-
+
if blob_url:
results["image_blob_url"] = blob_url
logger.info(f"Image saved to blob: {blob_url}")
@@ -1390,18 +1390,18 @@ async def generate_content(
) -> dict:
"""
Generate complete content package from a confirmed creative brief.
-
+
Args:
brief: Confirmed creative brief
products: List of products to feature
generate_images: Whether to generate images
-
+
Returns:
dict: Generated content with compliance results
"""
if not self._initialized:
self.initialize()
-
+
results = {
"text_content": None,
"image_prompt": None,
@@ -1409,7 +1409,7 @@ async def generate_content(
"violations": [],
"requires_modification": False
}
-
+
# Build the generation request for text content
text_request = f"""
Generate marketing content based on this creative brief:
@@ -1424,12 +1424,12 @@ async def generate_content(
Products to feature: {json.dumps(products or [])}
"""
-
+
try:
# Generate text content
text_response = await self._agents["text_content"].run(text_request)
results["text_content"] = str(text_response)
-
+
# Generate image prompt if requested
if generate_images:
# Build product context for image generation
@@ -1444,16 +1444,16 @@ async def generate_content(
desc = p.get('description', p.get('marketing_description', ''))
tags = p.get('tags', '')
product_details.append(f"- {name}: {desc} (Tags: {tags})")
-
+
# Include detailed image description if available
img_desc = p.get('image_description')
if img_desc:
image_descriptions.append(f"### {name} - Detailed Visual Description:\n{img_desc}")
-
+
product_context = "\n".join(product_details)
if image_descriptions:
detailed_image_context = "\n\n".join(image_descriptions)
-
+
image_request = f"""
Create an image generation prompt for this marketing campaign:
@@ -1473,34 +1473,34 @@ async def generate_content(
For paint products, show the paint colors in context (on walls, swatches, or room settings).
Use the detailed visual descriptions above to ensure accurate color reproduction in the generated image.
"""
-
+
# In Foundry mode, build the image prompt directly and use direct API
# In Direct mode, use the image agent to create the prompt
if self._use_foundry:
# Build a direct image prompt for Foundry
image_prompt_parts = ["Generate a professional marketing image:"]
-
+
if brief.visual_guidelines:
image_prompt_parts.append(f"Visual style: {brief.visual_guidelines}")
-
+
if brief.tone_and_style:
image_prompt_parts.append(f"Mood and tone: {brief.tone_and_style}")
-
+
if product_context:
image_prompt_parts.append(f"Products to feature: {product_context}")
-
+
if detailed_image_context:
image_prompt_parts.append(f"Product details: {detailed_image_context[:500]}")
-
+
if brief.key_message:
image_prompt_parts.append(f"Key message to convey: {brief.key_message}")
-
+
image_prompt_parts.append("Style: High-quality, photorealistic marketing photography with professional lighting.")
-
+
image_prompt = " ".join(image_prompt_parts)
results["image_prompt"] = image_prompt
logger.info(f"Created Foundry image prompt: {image_prompt[:200]}...")
-
+
# Generate image using direct Foundry API
logger.info("Generating image via Foundry direct API...")
await self._generate_foundry_image(image_prompt, results)
@@ -1508,14 +1508,14 @@ async def generate_content(
# Direct mode: use image agent to create prompt, then generate via DALL-E
image_response = await self._agents["image_content"].run(image_request)
results["image_prompt"] = str(image_response)
-
+
# Extract clean prompt from the response and generate actual image
try:
from agents.image_content_agent import generate_dalle_image
-
+
# Try to extract a clean prompt from the agent response
prompt_text = str(image_response)
-
+
# If response is JSON, extract the prompt field
if '{' in prompt_text:
try:
@@ -1529,13 +1529,17 @@ async def generate_content(
try:
prompt_data = json.loads(json_match.group(1))
prompt_text = prompt_data.get('prompt', prompt_data.get('image_prompt', prompt_text))
- except:
- pass
-
+ except Exception:
+ logger.debug(
+ "Failed to parse JSON image prompt from markdown code block; "
+ "continuing with original prompt_text.",
+ exc_info=True
+ )
+
# Build product description for DALL-E context
# Include detailed image descriptions if available for better color accuracy
product_description = detailed_image_context if detailed_image_context else product_context
-
+
# Generate the actual image using DALL-E
logger.info(f"Generating DALL-E image with prompt: {prompt_text[:200]}...")
image_result = await generate_dalle_image(
@@ -1543,22 +1547,22 @@ async def generate_content(
product_description=product_description,
scene_description=brief.visual_guidelines
)
-
+
if image_result.get("success"):
image_base64 = image_result.get("image_base64")
results["image_revised_prompt"] = image_result.get("revised_prompt")
logger.info("DALL-E image generated successfully")
-
+
# Save to blob storage
await self._save_image_to_blob(image_base64, results)
else:
logger.warning(f"DALL-E image generation failed: {image_result.get('error')}")
results["image_error"] = image_result.get("error")
-
+
except Exception as img_error:
logger.exception(f"Error generating DALL-E image: {img_error}")
results["image_error"] = str(img_error)
-
+
# Run compliance check
compliance_request = f"""
Review this marketing content for compliance:
@@ -1573,7 +1577,7 @@ async def generate_content(
"""
compliance_response = await self._agents["compliance"].run(compliance_request)
results["compliance"] = str(compliance_response)
-
+
# Try to parse compliance violations
try:
compliance_data = json.loads(str(compliance_response))
@@ -1594,18 +1598,19 @@ async def generate_content(
for v in results["violations"]
)
except (json.JSONDecodeError, KeyError):
- pass
-
+ # Failed to parse compliance response JSON; violations will remain empty
+ logger.debug("Could not parse compliance violations from response", exc_info=True)
+
except Exception as e:
logger.exception(f"Error generating content: {e}")
results["error"] = str(e)
-
+
# Log results summary before returning
logger.info(f"Orchestrator returning results with keys: {list(results.keys())}")
has_image = bool(results.get("image_base64"))
image_size = len(results.get("image_base64", "")) if has_image else 0
logger.info(f"Orchestrator results: has_image={has_image}, image_size={image_size}, has_error={bool(results.get('error'))}")
-
+
return results
async def regenerate_image(
@@ -1617,24 +1622,24 @@ async def regenerate_image(
) -> dict:
"""
Regenerate just the image based on a user modification request.
-
+
This method is called when the user wants to modify the generated image
after initial content generation (e.g., "show a kitchen instead of dining room").
-
+
Args:
modification_request: User's request for how to modify the image
brief: The confirmed creative brief
products: List of products to feature
previous_image_prompt: The previous image prompt (if available)
-
+
Returns:
dict: Regenerated image with updated prompt
"""
if not self._initialized:
self.initialize()
-
+
logger.info(f"Regenerating image with modification: {modification_request[:100]}...")
-
+
# PROACTIVE CONTENT SAFETY CHECK
is_harmful, matched_pattern = _check_input_for_harmful_content(modification_request)
if is_harmful:
@@ -1644,7 +1649,7 @@ async def regenerate_image(
"rai_blocked": True,
"blocked_reason": "harmful_content_detected"
}
-
+
results = {
"image_prompt": None,
"image_base64": None,
@@ -1652,7 +1657,7 @@ async def regenerate_image(
"image_revised_prompt": None,
"message": None
}
-
+
# Build product context
product_context = ""
detailed_image_context = ""
@@ -1664,24 +1669,24 @@ async def regenerate_image(
desc = p.get('description', p.get('marketing_description', ''))
tags = p.get('tags', '')
product_details.append(f"- {name}: {desc} (Tags: {tags})")
-
+
img_desc = p.get('image_description')
if img_desc:
image_descriptions.append(f"### {name} - Detailed Visual Description:\n{img_desc}")
-
+
product_context = "\n".join(product_details)
if image_descriptions:
detailed_image_context = "\n\n".join(image_descriptions)
-
+
# Prepare optional sections for the prompt
detailed_product_section = ""
if detailed_image_context:
detailed_product_section = f"DETAILED PRODUCT DESCRIPTIONS:\n{detailed_image_context}"
-
+
previous_prompt_section = ""
if previous_image_prompt:
previous_prompt_section = f"PREVIOUS IMAGE PROMPT:\n{previous_image_prompt}"
-
+
try:
# Use the image content agent to create a modified prompt
modification_prompt = f"""
@@ -1712,41 +1717,41 @@ async def regenerate_image(
- "style": Visual style description
- "change_summary": Brief summary of what was changed
"""
-
+
if self._use_foundry:
# Foundry mode: build prompt directly and call image API
# Combine original brief context with modification
new_prompt_parts = ["Generate a professional marketing image:"]
-
+
# Apply the modification to visual guidelines
if brief.visual_guidelines:
new_prompt_parts.append(f"Visual style: {brief.visual_guidelines}")
-
+
if brief.tone_and_style:
new_prompt_parts.append(f"Mood and tone: {brief.tone_and_style}")
-
+
if product_context:
new_prompt_parts.append(f"Products to feature: {product_context}")
-
+
# The key modification - incorporate user's change
new_prompt_parts.append(f"IMPORTANT MODIFICATION: {modification_request}")
-
+
if brief.key_message:
new_prompt_parts.append(f"Key message to convey: {brief.key_message}")
-
+
new_prompt_parts.append("Style: High-quality, photorealistic marketing photography with professional lighting.")
-
+
image_prompt = " ".join(new_prompt_parts)
results["image_prompt"] = image_prompt
results["message"] = f"Regenerating image with your requested changes: {modification_request}"
-
+
logger.info(f"Created modified Foundry image prompt: {image_prompt[:200]}...")
await self._generate_foundry_image(image_prompt, results)
else:
# Direct mode: use image agent to interpret the modification
image_response = await self._agents["image_content"].run(modification_prompt)
prompt_text = str(image_response)
-
+
# Extract the prompt from JSON response
change_summary = modification_request
if '{' in prompt_text:
@@ -1762,25 +1767,31 @@ async def regenerate_image(
prompt_data = json.loads(json_match.group(1))
prompt_text = prompt_data.get('prompt', prompt_text)
change_summary = prompt_data.get('change_summary', modification_request)
- except:
- pass
-
+ except Exception:
+ # If JSON extraction fails here, fall back to the original
+ # prompt_text and change_summary values set earlier.
+ logger.debug(
+ "Failed to parse JSON from markdown in regenerate_image; "
+ "using original prompt_text and modification_request.",
+ exc_info=True
+ )
+
results["image_prompt"] = prompt_text
results["message"] = f"Regenerating image: {change_summary}"
-
+
# Generate the actual image
try:
from agents.image_content_agent import generate_dalle_image
-
+
product_description = detailed_image_context if detailed_image_context else product_context
-
+
logger.info(f"Generating modified DALL-E image: {prompt_text[:200]}...")
image_result = await generate_dalle_image(
prompt=prompt_text,
product_description=product_description,
scene_description=brief.visual_guidelines
)
-
+
if image_result.get("success"):
image_base64 = image_result.get("image_base64")
results["image_revised_prompt"] = image_result.get("revised_prompt")
@@ -1789,17 +1800,17 @@ async def regenerate_image(
else:
logger.warning(f"Modified DALL-E image generation failed: {image_result.get('error')}")
results["image_error"] = image_result.get("error")
-
+
except Exception as img_error:
logger.exception(f"Error generating modified DALL-E image: {img_error}")
results["image_error"] = str(img_error)
-
+
logger.info(f"Image regeneration complete. Has image: {bool(results.get('image_base64') or results.get('image_blob_url'))}")
-
+
except Exception as e:
logger.exception(f"Error regenerating image: {e}")
results["error"] = str(e)
-
+
return results
diff --git a/content-gen/src/backend/services/blob_service.py b/content-gen/src/backend/services/blob_service.py
index b175e90a7..ae91c53e9 100644
--- a/content-gen/src/backend/services/blob_service.py
+++ b/content-gen/src/backend/services/blob_service.py
@@ -23,49 +23,49 @@
class BlobStorageService:
"""Service for interacting with Azure Blob Storage."""
-
+
def __init__(self):
self._client: Optional[BlobServiceClient] = None
self._product_images_container: Optional[ContainerClient] = None
self._generated_images_container: Optional[ContainerClient] = None
-
+
async def _get_credential(self):
"""Get Azure credential for authentication."""
client_id = app_settings.base_settings.azure_client_id
if client_id:
return ManagedIdentityCredential(client_id=client_id)
return DefaultAzureCredential()
-
+
async def initialize(self) -> None:
"""Initialize Blob Storage client and containers."""
if self._client:
return
-
+
credential = await self._get_credential()
-
+
self._client = BlobServiceClient(
account_url=f"https://{app_settings.blob.account_name}.blob.core.windows.net",
credential=credential
)
-
+
self._product_images_container = self._client.get_container_client(
app_settings.blob.product_images_container
)
-
+
self._generated_images_container = self._client.get_container_client(
app_settings.blob.generated_images_container
)
-
+
logger.info("Blob Storage service initialized")
-
+
async def close(self) -> None:
"""Close the Blob Storage client."""
if self._client:
await self._client.close()
self._client = None
-
+
# ==================== Product Image Operations ====================
-
+
async def upload_product_image(
self,
sku: str,
@@ -74,22 +74,22 @@ async def upload_product_image(
) -> Tuple[str, str]:
"""
Upload a product image and generate its description.
-
+
Args:
sku: Product SKU (used as blob name prefix)
image_data: Raw image bytes
content_type: MIME type of the image
-
+
Returns:
Tuple of (blob_url, generated_description)
"""
await self.initialize()
-
+
# Generate unique blob name
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
extension = content_type.split("/")[-1]
blob_name = f"{sku}/{timestamp}.{extension}"
-
+
# Upload the image
blob_client = self._product_images_container.get_blob_client(blob_name)
await blob_client.upload_blob(
@@ -97,45 +97,45 @@ async def upload_product_image(
content_type=content_type,
overwrite=True
)
-
+
blob_url = blob_client.url
-
+
# Generate description using GPT-5 Vision
description = await self.generate_image_description(image_data)
-
+
logger.info(f"Uploaded product image: {blob_name}")
return blob_url, description
-
+
async def get_product_image_url(self, sku: str) -> Optional[str]:
"""
Get the URL of the latest product image.
-
+
Args:
sku: Product SKU
-
+
Returns:
URL of the latest image, or None if not found
"""
await self.initialize()
-
+
# List blobs with the SKU prefix
blobs = []
async for blob in self._product_images_container.list_blobs(
name_starts_with=f"{sku}/"
):
blobs.append(blob)
-
+
if not blobs:
return None
-
+
# Get the most recent blob
latest_blob = sorted(blobs, key=lambda b: b.name, reverse=True)[0]
blob_client = self._product_images_container.get_blob_client(latest_blob.name)
-
+
return blob_client.url
-
+
# ==================== Generated Image Operations ====================
-
+
async def save_generated_image(
self,
conversation_id: str,
@@ -144,25 +144,25 @@ async def save_generated_image(
) -> str:
"""
Save a DALL-E generated image to blob storage.
-
+
Args:
conversation_id: ID of the conversation that generated the image
image_base64: Base64-encoded image data
content_type: MIME type of the image
-
+
Returns:
URL of the saved image
"""
await self.initialize()
-
+
# Decode base64 image
image_data = base64.b64decode(image_base64)
-
+
# Generate unique blob name
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
extension = content_type.split("/")[-1]
blob_name = f"{conversation_id}/{timestamp}.{extension}"
-
+
# Upload the image
blob_client = self._generated_images_container.get_blob_client(blob_name)
await blob_client.upload_blob(
@@ -170,63 +170,63 @@ async def save_generated_image(
content_type=content_type,
overwrite=True
)
-
+
logger.info(f"Saved generated image: {blob_name}")
return blob_client.url
-
+
async def get_generated_images(
self,
conversation_id: str
) -> list[str]:
"""
Get all generated images for a conversation.
-
+
Args:
conversation_id: ID of the conversation
-
+
Returns:
List of image URLs
"""
await self.initialize()
-
+
urls = []
async for blob in self._generated_images_container.list_blobs(
name_starts_with=f"{conversation_id}/"
):
blob_client = self._generated_images_container.get_blob_client(blob.name)
urls.append(blob_client.url)
-
+
return urls
-
+
# ==================== Image Description Generation ====================
-
+
async def generate_image_description(self, image_data: bytes) -> str:
"""
Generate a detailed text description of an image using GPT-5 Vision.
-
+
This is used to create descriptions of product images that can be
used as context for DALL-E 3 image generation (since DALL-E 3
cannot accept image inputs directly).
-
+
Args:
image_data: Raw image bytes
-
+
Returns:
Detailed text description of the image
"""
# Encode image to base64
image_base64 = base64.b64encode(image_data).decode("utf-8")
-
+
try:
credential = await self._get_credential()
token = await credential.get_token("https://cognitiveservices.azure.com/.default")
-
+
client = AsyncAzureOpenAI(
azure_endpoint=app_settings.azure_openai.endpoint,
azure_ad_token=token.token,
api_version=app_settings.azure_openai.api_version,
)
-
+
response = await client.chat.completions.create(
model=app_settings.azure_openai.gpt_model,
messages=[
@@ -260,11 +260,11 @@ async def generate_image_description(self, image_data: bytes) -> str:
],
max_tokens=500
)
-
+
description = response.choices[0].message.content
logger.info(f"Generated image description: {description[:100]}...")
return description
-
+
except Exception as e:
logger.exception(f"Error generating image description: {e}")
return "Product image - description unavailable"
diff --git a/content-gen/src/backend/services/cosmos_service.py b/content-gen/src/backend/services/cosmos_service.py
index 11f55400d..6e452db2d 100644
--- a/content-gen/src/backend/services/cosmos_service.py
+++ b/content-gen/src/backend/services/cosmos_service.py
@@ -22,79 +22,79 @@
class CosmosDBService:
"""Service for interacting with Azure Cosmos DB."""
-
+
def __init__(self):
self._client: Optional[CosmosClient] = None
self._products_container: Optional[ContainerProxy] = None
self._conversations_container: Optional[ContainerProxy] = None
-
+
async def _get_credential(self):
"""Get Azure credential for authentication."""
client_id = app_settings.base_settings.azure_client_id
if client_id:
return ManagedIdentityCredential(client_id=client_id)
return DefaultAzureCredential()
-
+
async def initialize(self) -> None:
"""Initialize CosmosDB client and containers."""
if self._client:
return
-
+
credential = await self._get_credential()
-
+
self._client = CosmosClient(
url=app_settings.cosmos.endpoint,
credential=credential
)
-
+
database = self._client.get_database_client(
app_settings.cosmos.database_name
)
-
+
self._products_container = database.get_container_client(
app_settings.cosmos.products_container
)
-
+
self._conversations_container = database.get_container_client(
app_settings.cosmos.conversations_container
)
-
+
logger.info("CosmosDB service initialized")
-
+
async def close(self) -> None:
"""Close the CosmosDB client."""
if self._client:
await self._client.close()
self._client = None
-
+
# ==================== Product Operations ====================
-
+
async def get_product_by_sku(self, sku: str) -> Optional[Product]:
"""
Retrieve a product by its SKU.
-
+
Args:
sku: Product SKU identifier
-
+
Returns:
Product if found, None otherwise
"""
await self.initialize()
-
+
query = "SELECT * FROM c WHERE c.sku = @sku"
params = [{"name": "@sku", "value": sku}]
-
+
items = []
async for item in self._products_container.query_items(
query=query,
parameters=params
):
items.append(item)
-
+
if items:
return Product(**items[0])
return None
-
+
async def get_products_by_category(
self,
category: str,
@@ -103,20 +103,20 @@ async def get_products_by_category(
) -> List[Product]:
"""
Retrieve products by category.
-
+
Args:
category: Product category
sub_category: Optional sub-category filter
limit: Maximum number of products to return
-
+
Returns:
List of matching products
"""
await self.initialize()
-
+
if sub_category:
query = """
- SELECT TOP @limit * FROM c
+ SELECT TOP @limit * FROM c
WHERE c.category = @category AND c.sub_category = @sub_category
"""
params = [
@@ -130,16 +130,16 @@ async def get_products_by_category(
{"name": "@category", "value": category},
{"name": "@limit", "value": limit}
]
-
+
products = []
async for item in self._products_container.query_items(
query=query,
parameters=params
):
products.append(Product(**item))
-
+
return products
-
+
async def search_products(
self,
search_term: str,
@@ -147,20 +147,20 @@ async def search_products(
) -> List[Product]:
"""
Search products by name or description.
-
+
Args:
search_term: Text to search for
limit: Maximum number of products to return
-
+
Returns:
List of matching products
"""
await self.initialize()
-
+
search_lower = search_term.lower()
query = """
- SELECT TOP @limit * FROM c
- WHERE CONTAINS(LOWER(c.product_name), @search)
+ SELECT TOP @limit * FROM c
+ WHERE CONTAINS(LOWER(c.product_name), @search)
OR CONTAINS(LOWER(c.marketing_description), @search)
OR CONTAINS(LOWER(c.detailed_spec_description), @search)
"""
@@ -168,47 +168,47 @@ async def search_products(
{"name": "@search", "value": search_lower},
{"name": "@limit", "value": limit}
]
-
+
products = []
async for item in self._products_container.query_items(
query=query,
parameters=params
):
products.append(Product(**item))
-
+
return products
-
+
async def upsert_product(self, product: Product) -> Product:
"""
Create or update a product.
-
+
Args:
product: Product to upsert
-
+
Returns:
The upserted product
"""
await self.initialize()
-
+
item = product.model_dump()
item["id"] = product.sku # Use SKU as document ID
item["updated_at"] = datetime.now(timezone.utc).isoformat()
-
+
result = await self._products_container.upsert_item(item)
return Product(**result)
-
+
async def delete_product(self, sku: str) -> bool:
"""
Delete a product by SKU.
-
+
Args:
sku: Product SKU (also used as document ID)
-
+
Returns:
True if deleted successfully
"""
await self.initialize()
-
+
try:
await self._products_container.delete_item(
item=sku,
@@ -218,19 +218,19 @@ async def delete_product(self, sku: str) -> bool:
except Exception as e:
logger.warning(f"Failed to delete product {sku}: {e}")
return False
-
+
async def delete_all_products(self) -> int:
"""
Delete all products from the container.
-
+
Returns:
Number of products deleted
"""
await self.initialize()
-
+
deleted_count = 0
query = "SELECT c.id FROM c"
-
+
async for item in self._products_container.query_items(query=query):
try:
await self._products_container.delete_item(
@@ -240,35 +240,35 @@ async def delete_all_products(self) -> int:
deleted_count += 1
except Exception as e:
logger.warning(f"Failed to delete product {item['id']}: {e}")
-
+
return deleted_count
-
+
async def get_all_products(self, limit: int = 100) -> List[Product]:
"""
Retrieve all products.
-
+
Args:
limit: Maximum number of products to return
-
+
Returns:
List of all products
"""
await self.initialize()
-
+
query = "SELECT TOP @limit * FROM c"
params = [{"name": "@limit", "value": limit}]
-
+
products = []
async for item in self._products_container.query_items(
query=query,
parameters=params
):
products.append(Product(**item))
-
+
return products
-
+
# ==================== Conversation Operations ====================
-
+
async def get_conversation(
self,
conversation_id: str,
@@ -276,16 +276,16 @@ async def get_conversation(
) -> Optional[dict]:
"""
Retrieve a conversation by ID.
-
+
Args:
conversation_id: Unique conversation identifier
user_id: User ID for partition key (may not match if conversation was created by different user)
-
+
Returns:
Conversation data if found
"""
await self.initialize()
-
+
try:
# First try direct read with provided user_id (fast path)
item = await self._conversations_container.read_item(
@@ -295,13 +295,13 @@ async def get_conversation(
return item
except Exception:
pass
-
+
# Fallback: cross-partition query to find conversation by ID
# This handles cases where the conversation was created with a different user_id
try:
query = "SELECT * FROM c WHERE c.id = @id"
params = [{"name": "@id", "value": conversation_id}]
-
+
async for item in self._conversations_container.query_items(
query=query,
parameters=params,
@@ -310,9 +310,9 @@ async def get_conversation(
return item
except Exception:
pass
-
+
return None
-
+
async def save_conversation(
self,
conversation_id: str,
@@ -324,7 +324,7 @@ async def save_conversation(
) -> dict:
"""
Save or update a conversation.
-
+
Args:
conversation_id: Unique conversation identifier
user_id: User ID for partition key
@@ -332,12 +332,12 @@ async def save_conversation(
brief: Associated creative brief
metadata: Additional metadata
generated_content: Generated marketing content
-
+
Returns:
The saved conversation document
"""
await self.initialize()
-
+
item = {
"id": conversation_id,
"userId": user_id, # Partition key field (matches container definition /userId)
@@ -348,10 +348,10 @@ async def save_conversation(
"generated_content": generated_content,
"updated_at": datetime.now(timezone.utc).isoformat()
}
-
+
result = await self._conversations_container.upsert_item(item)
return result
-
+
async def save_generated_content(
self,
conversation_id: str,
@@ -360,19 +360,19 @@ async def save_generated_content(
) -> dict:
"""
Save generated content to an existing conversation.
-
+
Args:
conversation_id: Unique conversation identifier
user_id: User ID for partition key
generated_content: The generated content to save
-
+
Returns:
Updated conversation document
"""
await self.initialize()
-
+
conversation = await self.get_conversation(conversation_id, user_id)
-
+
if conversation:
# Ensure userId is set (for partition key) - migrate old documents
if not conversation.get("userId"):
@@ -388,10 +388,10 @@ async def save_generated_content(
"generated_content": generated_content,
"updated_at": datetime.now(timezone.utc).isoformat()
}
-
+
result = await self._conversations_container.upsert_item(conversation)
return result
-
+
async def add_message_to_conversation(
self,
conversation_id: str,
@@ -400,19 +400,19 @@ async def add_message_to_conversation(
) -> dict:
"""
Add a message to an existing conversation.
-
+
Args:
conversation_id: Unique conversation identifier
user_id: User ID for partition key
message: Message to add
-
+
Returns:
Updated conversation document
"""
await self.initialize()
-
+
conversation = await self.get_conversation(conversation_id, user_id)
-
+
if conversation:
# Ensure userId is set (for partition key) - migrate old documents
if not conversation.get("userId"):
@@ -427,10 +427,10 @@ async def add_message_to_conversation(
"messages": [message],
"updated_at": datetime.now(timezone.utc).isoformat()
}
-
+
result = await self._conversations_container.upsert_item(conversation)
return result
-
+
async def get_user_conversations(
self,
user_id: str,
@@ -438,26 +438,26 @@ async def get_user_conversations(
) -> List[dict]:
"""
Get all conversations for a user with summary data.
-
+
Args:
user_id: User ID ("anonymous" for unauthenticated users)
limit: Maximum number of conversations
-
+
Returns:
List of conversation summaries
"""
await self.initialize()
-
+
# For anonymous users, also include conversations with empty/null/undefined user_id
# This handles legacy data before "anonymous" was used as the default
if user_id == "anonymous":
query = """
SELECT TOP @limit c.id, c.userId, c.user_id, c.updated_at, c.messages, c.brief, c.metadata
- FROM c
+ FROM c
WHERE c.userId = @user_id
- OR c.user_id = @user_id
- OR c.user_id = ""
- OR c.user_id = null
+ OR c.user_id = @user_id
+ OR c.user_id = ""
+ OR c.user_id = null
OR NOT IS_DEFINED(c.user_id)
ORDER BY c.updated_at DESC
"""
@@ -468,7 +468,7 @@ async def get_user_conversations(
else:
query = """
SELECT TOP @limit c.id, c.userId, c.user_id, c.updated_at, c.messages, c.brief, c.metadata
- FROM c
+ FROM c
WHERE c.userId = @user_id OR c.user_id = @user_id
ORDER BY c.updated_at DESC
"""
@@ -476,7 +476,7 @@ async def get_user_conversations(
{"name": "@user_id", "value": user_id},
{"name": "@limit", "value": limit}
]
-
+
conversations = []
async for item in self._conversations_container.query_items(
query=query,
@@ -485,7 +485,7 @@ async def get_user_conversations(
messages = item.get("messages", [])
brief = item.get("brief", {})
metadata = item.get("metadata", {})
-
+
custom_title = metadata.get("custom_title") if metadata else None
if custom_title:
title = custom_title
@@ -499,13 +499,13 @@ async def get_user_conversations(
break
else:
title = "Untitled Conversation"
-
+
# Get last message preview
last_message = ""
if messages:
last_msg = messages[-1]
last_message = last_msg.get("content", "")[:100]
-
+
conversations.append({
"id": item["id"],
"title": title,
@@ -513,9 +513,9 @@ async def get_user_conversations(
"timestamp": item.get("updated_at", ""),
"messageCount": len(messages)
})
-
+
return conversations
-
+
async def delete_conversation(
self,
conversation_id: str,
@@ -523,25 +523,25 @@ async def delete_conversation(
) -> bool:
"""
Delete a conversation.
-
+
Args:
conversation_id: Unique conversation identifier
user_id: User ID for partition key
-
+
Returns:
True if deleted successfully
"""
await self.initialize()
-
+
# Get the conversation to find its partition key value
conversation = await self.get_conversation(conversation_id, user_id)
if not conversation:
# Already doesn't exist, consider it deleted
return True
-
+
# Use userId (camelCase) as partition key, fallback to user_id for old documents
partition_key = conversation.get("userId") or conversation.get("user_id") or user_id
-
+
try:
await self._conversations_container.delete_item(
item=conversation_id,
@@ -552,7 +552,7 @@ async def delete_conversation(
except Exception as e:
logger.warning(f"Failed to delete conversation {conversation_id}: {e}")
raise
-
+
async def rename_conversation(
self,
conversation_id: str,
@@ -561,28 +561,28 @@ async def rename_conversation(
) -> Optional[dict]:
"""
Rename a conversation by setting a custom title in metadata.
-
+
Args:
conversation_id: Unique conversation identifier
user_id: User ID for partition key
new_title: New title for the conversation
-
+
Returns:
Updated conversation document or None if not found
"""
await self.initialize()
-
+
conversation = await self.get_conversation(conversation_id, user_id)
if not conversation:
return None
-
+
conversation["metadata"] = conversation.get("metadata", {})
conversation["metadata"]["custom_title"] = new_title
# Ensure userId is set (for partition key) - migrate old documents
if not conversation.get("userId"):
conversation["userId"] = conversation.get("user_id") or user_id
# Don't update updated_at - renaming shouldn't change sort order
-
+
result = await self._conversations_container.upsert_item(conversation)
return result
diff --git a/content-gen/src/backend/services/search_service.py b/content-gen/src/backend/services/search_service.py
index cd6729186..16c92f171 100644
--- a/content-gen/src/backend/services/search_service.py
+++ b/content-gen/src/backend/services/search_service.py
@@ -19,57 +19,57 @@
class SearchService:
"""Service for searching products and images in Azure AI Search."""
-
+
def __init__(self):
self._products_client: Optional[SearchClient] = None
self._images_client: Optional[SearchClient] = None
self._credential = None
-
+
def _get_credential(self):
"""Get search credential - prefer RBAC, fall back to API key."""
if self._credential:
return self._credential
-
+
# Try RBAC first
try:
self._credential = DefaultAzureCredential()
return self._credential
except Exception:
pass
-
+
# Fall back to API key
if app_settings.search and app_settings.search.admin_key:
self._credential = AzureKeyCredential(app_settings.search.admin_key)
return self._credential
-
+
raise ValueError("No valid search credentials available")
-
+
def _get_products_client(self) -> SearchClient:
"""Get or create the products search client."""
if self._products_client is None:
if not app_settings.search or not app_settings.search.endpoint:
raise ValueError("Azure AI Search endpoint not configured")
-
+
self._products_client = SearchClient(
endpoint=app_settings.search.endpoint,
index_name=app_settings.search.products_index,
credential=self._get_credential()
)
return self._products_client
-
+
def _get_images_client(self) -> SearchClient:
"""Get or create the images search client."""
if self._images_client is None:
if not app_settings.search or not app_settings.search.endpoint:
raise ValueError("Azure AI Search endpoint not configured")
-
+
self._images_client = SearchClient(
endpoint=app_settings.search.endpoint,
index_name=app_settings.search.images_index,
credential=self._get_credential()
)
return self._images_client
-
+
async def search_products(
self,
query: str,
@@ -79,37 +79,37 @@ async def search_products(
) -> List[Dict[str, Any]]:
"""
Search for products using Azure AI Search.
-
+
Args:
query: Search query text
category: Optional category filter
sub_category: Optional sub-category filter
top: Maximum number of results
-
+
Returns:
List of matching products
"""
try:
client = self._get_products_client()
-
+
# Build filter
filters = []
if category:
filters.append(f"category eq '{category}'")
if sub_category:
filters.append(f"sub_category eq '{sub_category}'")
-
+
filter_str = " and ".join(filters) if filters else None
-
+
# Execute search
results = client.search(
search_text=query,
filter=filter_str,
top=top,
select=["id", "product_name", "sku", "model", "category", "sub_category",
- "marketing_description", "detailed_spec_description", "image_description"]
+ "marketing_description", "detailed_spec_description", "image_description"]
)
-
+
products = []
for result in results:
products.append({
@@ -124,14 +124,14 @@ async def search_products(
"image_description": result.get("image_description"),
"search_score": result.get("@search.score")
})
-
+
logger.info(f"Product search for '{query}' returned {len(products)} results")
return products
-
+
except Exception as e:
logger.error(f"Product search failed: {e}")
return []
-
+
async def search_images(
self,
query: str,
@@ -141,36 +141,36 @@ async def search_images(
) -> List[Dict[str, Any]]:
"""
Search for images/color palettes using Azure AI Search.
-
+
Args:
query: Search query (color, mood, style keywords)
color_family: Optional color family filter (Cool, Warm, Neutral, etc.)
mood: Optional mood filter
top: Maximum number of results
-
+
Returns:
List of matching images with metadata
"""
try:
client = self._get_images_client()
-
+
# Build filter
filters = []
if color_family:
filters.append(f"color_family eq '{color_family}'")
-
+
filter_str = " and ".join(filters) if filters else None
-
+
# Execute search
results = client.search(
search_text=query,
filter=filter_str,
top=top,
select=["id", "name", "filename", "primary_color", "secondary_color",
- "color_family", "mood", "style", "description", "use_cases",
- "blob_url", "keywords"]
+ "color_family", "mood", "style", "description", "use_cases",
+ "blob_url", "keywords"]
)
-
+
images = []
for result in results:
images.append({
@@ -188,14 +188,14 @@ async def search_images(
"keywords": result.get("keywords"),
"search_score": result.get("@search.score")
})
-
+
logger.info(f"Image search for '{query}' returned {len(images)} results")
return images
-
+
except Exception as e:
logger.error(f"Image search failed: {e}")
return []
-
+
async def get_grounding_context(
self,
product_query: str,
@@ -205,16 +205,16 @@ async def get_grounding_context(
) -> Dict[str, Any]:
"""
Get combined grounding context for content generation.
-
+
Searches both products and images to provide comprehensive
context for AI content generation.
-
+
Args:
product_query: Query for product search
image_query: Optional query for image/style search
category: Optional product category filter
mood: Optional mood/style filter for images
-
+
Returns:
Combined grounding context with products and images
"""
@@ -224,7 +224,7 @@ async def get_grounding_context(
category=category,
top=5
)
-
+
# Search images if query provided
images = []
if image_query:
@@ -233,7 +233,7 @@ async def get_grounding_context(
mood=mood,
top=3
)
-
+
# Build grounding context
context = {
"products": products,
@@ -242,9 +242,9 @@ async def get_grounding_context(
"image_count": len(images),
"grounding_summary": self._build_grounding_summary(products, images)
}
-
+
return context
-
+
def _build_grounding_summary(
self,
products: List[Dict[str, Any]],
@@ -252,7 +252,7 @@ def _build_grounding_summary(
) -> str:
"""Build a text summary of grounding context for agents."""
parts = []
-
+
if products:
parts.append("## Available Products\n")
for p in products[:5]:
@@ -262,7 +262,7 @@ def _build_grounding_summary(
if p.get('image_description'):
parts.append(f" Visual: {p.get('image_description', '')[:100]}...")
parts.append("")
-
+
if images:
parts.append("\n## Available Visual Styles\n")
for img in images[:3]:
@@ -272,7 +272,7 @@ def _build_grounding_summary(
parts.append(f" Style: {img.get('style')}")
parts.append(f" Best for: {img.get('use_cases', '')[:100]}")
parts.append("")
-
+
return "\n".join(parts)
diff --git a/content-gen/src/backend/settings.py b/content-gen/src/backend/settings.py
index d84ad99f1..08680ede8 100644
--- a/content-gen/src/backend/settings.py
+++ b/content-gen/src/backend/settings.py
@@ -62,16 +62,16 @@ class _AzureOpenAISettings(BaseSettings):
gpt_model: str = Field(default="gpt-5", alias="AZURE_OPENAI_GPT_MODEL")
model: str = "gpt-5"
-
+
# Image generation model settings
# Supported models: "dall-e-3" or "gpt-image-1" or "gpt-image-1.5"
image_model: str = Field(default="dall-e-3", alias="AZURE_OPENAI_IMAGE_MODEL")
dalle_model: str = Field(default="dall-e-3", alias="AZURE_OPENAI_DALLE_MODEL") # Legacy alias
dalle_endpoint: Optional[str] = Field(default=None, alias="AZURE_OPENAI_DALLE_ENDPOINT")
-
+
# gpt-image-1 or gpt-image-1.5 specific endpoint (if different from DALL-E endpoint)
gpt_image_endpoint: Optional[str] = Field(default=None, alias="AZURE_OPENAI_GPT_IMAGE_ENDPOINT")
-
+
resource: Optional[str] = None
endpoint: Optional[str] = None
temperature: float = 0.7
@@ -81,20 +81,20 @@ class _AzureOpenAISettings(BaseSettings):
api_version: str = "2024-06-01"
preview_api_version: str = "2024-02-01"
image_api_version: str = Field(default="2025-04-01-preview", alias="AZURE_OPENAI_IMAGE_API_VERSION")
-
+
# Image generation settings
# For dall-e-3: 1024x1024, 1024x1792, 1792x1024
# For gpt-image-1: 1024x1024, 1536x1024, 1024x1536, auto
image_size: str = "1024x1024"
image_quality: str = "hd" # dall-e-3: standard/hd, gpt-image-1: low/medium/high/auto
-
+
@property
def effective_image_model(self) -> str:
"""Get the effective image model, preferring image_model over dalle_model."""
# If image_model is explicitly set and not the default, use it
# Otherwise fall back to dalle_model for backwards compatibility
return self.image_model if self.image_model else self.dalle_model
-
+
@property
def image_endpoint(self) -> Optional[str]:
"""Get the appropriate endpoint for the configured image model."""
@@ -105,22 +105,22 @@ def image_endpoint(self) -> Optional[str]:
@property
def image_generation_enabled(self) -> bool:
"""Check if image generation is available.
-
+
Image generation requires either:
- A DALL-E endpoint configured, OR
- A gpt-image-1 or gpt-image-1.5 endpoint configured, OR
- Using the main OpenAI endpoint with an image model configured
-
+
Returns False if image_model is explicitly set to empty string or "none".
"""
# Check if image generation is explicitly disabled
if not self.image_model or self.image_model.lower() in ("none", "disabled", ""):
return False
-
+
# Check if we have an endpoint that can handle image generation
# Either a dedicated image endpoint or the main OpenAI endpoint
has_image_endpoint = bool(self.dalle_endpoint or self.gpt_image_endpoint or self.endpoint)
-
+
return has_image_endpoint
@model_validator(mode="after")
@@ -164,7 +164,7 @@ class _CosmosSettings(BaseSettings):
class _AIFoundrySettings(BaseSettings):
"""Azure AI Foundry configuration for agent-based workflows.
-
+
When USE_FOUNDRY=true, the orchestrator uses Azure AI Foundry's
project endpoint instead of direct Azure OpenAI endpoints.
"""
@@ -177,7 +177,7 @@ class _AIFoundrySettings(BaseSettings):
use_foundry: bool = Field(default=False, alias="USE_FOUNDRY")
project_endpoint: Optional[str] = Field(default=None, alias="AZURE_AI_PROJECT_ENDPOINT")
project_name: Optional[str] = Field(default=None, alias="AZURE_AI_PROJECT_NAME")
-
+
# Model deployment names in Foundry
model_deployment: Optional[str] = Field(default=None, alias="AZURE_AI_MODEL_DEPLOYMENT_NAME")
image_deployment: str = Field(default="gpt-image-1", alias="AZURE_AI_IMAGE_MODEL_DEPLOYMENT")
@@ -201,7 +201,7 @@ class _SearchSettings(BaseSettings):
class _BrandGuidelinesSettings(BaseSettings):
"""
Brand guidelines stored as solution parameters.
-
+
These are injected into all agent instructions for content strategy
and compliance validation.
"""
@@ -215,32 +215,32 @@ class _BrandGuidelinesSettings(BaseSettings):
# Voice and tone
tone: str = "Professional yet approachable"
voice: str = "Innovative, trustworthy, customer-focused"
-
+
# Content restrictions (stored as comma-separated strings)
prohibited_words_str: str = Field(default="", alias="BRAND_PROHIBITED_WORDS")
required_disclosures_str: str = Field(default="", alias="BRAND_REQUIRED_DISCLOSURES")
-
+
# Visual guidelines
primary_color: str = "#0078D4"
secondary_color: str = "#107C10"
image_style: str = "Modern, clean, minimalist with bright lighting"
typography: str = "Sans-serif, bold headlines, readable body text"
-
+
# Compliance rules
max_headline_length: int = 60
max_body_length: int = 500
require_cta: bool = True
-
+
@property
def prohibited_words(self) -> List[str]:
"""Parse prohibited words from comma-separated string."""
return parse_comma_separated(self.prohibited_words_str)
-
+
@property
def required_disclosures(self) -> List[str]:
"""Parse required disclosures from comma-separated string."""
return parse_comma_separated(self.required_disclosures_str)
-
+
def get_compliance_prompt(self) -> str:
"""Generate compliance rules text for agent instructions."""
return f"""
@@ -319,8 +319,8 @@ def get_compliance_prompt(self) -> str:
- Avoid culturally insensitive or appropriative imagery
**IMPORTANT - Photorealistic Product Images Are ACCEPTABLE:**
-Photorealistic style for PRODUCT photography (e.g., paint cans, products, room scenes, textures)
-is our standard marketing style and should NOT be flagged as a violation. Only flag photorealistic
+Photorealistic style for PRODUCT photography (e.g., paint cans, products, room scenes, textures)
+is our standard marketing style and should NOT be flagged as a violation. Only flag photorealistic
content when it involves:
- Fake/deepfake identifiable real people (SEVERITY: ERROR)
- Misleading contexts designed to deceive consumers (SEVERITY: ERROR)
@@ -444,7 +444,7 @@ class _AppSettings(BaseModel):
ai_foundry: _AIFoundrySettings = _AIFoundrySettings()
brand_guidelines: _BrandGuidelinesSettings = _BrandGuidelinesSettings()
ui: Optional[_UiSettings] = _UiSettings()
-
+
# Constructed properties
chat_history: Optional[_ChatHistorySettings] = None
blob: Optional[_StorageSettings] = None
diff --git a/docs/LocalDevelopmentSetup.md b/docs/LocalDevelopmentSetup.md
deleted file mode 100644
index 4635b89e8..000000000
--- a/docs/LocalDevelopmentSetup.md
+++ /dev/null
@@ -1,506 +0,0 @@
-# Local Development Setup Guide
-
-This guide provides comprehensive instructions for setting up the Document Generation Solution Accelerator for local development across Windows and Linux platforms.
-
-## Important Setup Notes
-
-### Multi-Service Architecture
-
-This application consists of **two separate services** that run independently:
-
-1. **Backend API** - REST API server for the frontend
-2. **Frontend** - React-based user interface
-
-> **β οΈ Critical: Each service must run in its own terminal/console window**
->
-> - **Do NOT close terminals** while services are running
-> - Open **2 separate terminal windows** for local development
-> - Each service will occupy its terminal and show live logs
-
-
-### Path Conventions
-
-**All paths in this guide are relative to the repository root directory:**
-
-```bash
-document-generation-solution-accelerator/ β Repository root (start here)
-βββ src/
-β βββ backend/
-β β βββ api/ β API endpoints and routes
-β β βββ auth/ β Authentication modules
-β β βββ helpers/ β Utility and helper functions
-β β βββ history/ β Chat/session history management
-β β βββ security/ β Security-related modules
-β β βββ settings.py β Backend configuration
-β βββ frontend/
-β β βββ src/ β React/TypeScript source
-β β βββ package.json β Frontend dependencies
-β βββ static/ β Static web assets
-β βββ tests/ β Unit and integration tests
-β βββ app.py β Main Flask application entry point
-β βββ .env β Main application config file
-β βββ requirements.txt β Python dependencies
-βββ scripts/
-β βββ prepdocs.py β Document processing script
-β βββ auth_init.py β Authentication setup
-β βββ data_preparation.py β Data pipeline scripts
-β βββ config.json β Scripts configuration
-βββ infra/
-β βββ main.bicep β Main infrastructure template
-β βββ scripts/ β Infrastructure scripts
-β βββ main.parameters.json β Deployment parameters
-βββ docs/ β Documentation (you are here)
-βββ tests/ β End-to-end tests
- βββ e2e-test/
-```
-
-**Before starting any step, ensure you are in the repository root directory:**
-
-```bash
-# Verify you're in the correct location
-pwd # Linux/macOS - should show: .../document-generation-solution-accelerator
-Get-Location # Windows PowerShell - should show: ...\document-generation-solution-accelerator
-
-# If not, navigate to repository root
-cd path/to/document-generation-solution-accelerator
-```
-
-## Step 1: Prerequisites - Install Required Tools
-
-Install these tools before you start:
-- [Visual Studio Code](https://code.visualstudio.com/) with the following extensions:
- - [Azure Tools](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-node-azure-pack)
- - [Bicep](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep)
- - [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
-- [Python 3.11](https://www.python.org/downloads/). **Important:** Check "Add Python to PATH" during installation.
-- [PowerShell 7.0+](https://github.com/PowerShell/PowerShell#get-powershell).
-- [Node.js (LTS)](https://nodejs.org/en).
-- [Git](https://git-scm.com/downloads).
-- [Azure Developer CLI (azd) v1.18.0+](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd).
-- [Microsoft ODBC Driver 17](https://learn.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server?view=sql-server-ver16) for SQL Server.
-
-
-### Windows Development
-
-#### Option 1: Native Windows (PowerShell)
-
-```powershell
-# Install Python 3.11+ and Git
-winget install Python.Python.3.11
-winget install Git.Git
-
-# Install Node.js for frontend
-winget install OpenJS.NodeJS.LTS
-
-# Install uv package manager
-py -3.11 -m pip install uv
-```
-
-**Note**: On Windows, use `py -3.11 -m uv` instead of `uv` for all commands to ensure you're using Python 3.11.
-
-#### Option 2: Windows with WSL2 (Recommended)
-
-```bash
-# Install WSL2 first (run in PowerShell as Administrator):
-# wsl --install -d Ubuntu
-
-# Then in WSL2 Ubuntu terminal:
-sudo apt update && sudo apt install python3.11 python3.11-venv git curl nodejs npm -y
-
-# Install uv
-curl -LsSf https://astral.sh/uv/install.sh | sh
-source ~/.bashrc
-```
-
-### Linux Development
-
-#### Ubuntu/Debian
-
-```bash
-# Install prerequisites
-sudo apt update && sudo apt install python3.11 python3.11-venv git curl nodejs npm -y
-
-# Install uv package manager
-curl -LsSf https://astral.sh/uv/install.sh | sh
-source ~/.bashrc
-```
-
-#### RHEL/CentOS/Fedora
-
-```bash
-# Install prerequisites
-sudo dnf install python3.11 python3.11-devel git curl gcc nodejs npm -y
-
-# Install uv
-curl -LsSf https://astral.sh/uv/install.sh | sh
-source ~/.bashrc
-```
-
-
-## Step 2: Clone the Repository
-
-Choose a location on your local machine where you want to store the project files. We recommend creating a dedicated folder for your development projects.
-
-#### Using Command Line/Terminal
-
-1. **Open your terminal or command prompt. Navigate to your desired directory and Clone the repository:**
- ```bash
- git clone https://github.com/microsoft/document-generation-solution-accelerator.git
- ```
-
-2. **Navigate to the project directory:**
- ```bash
- cd document-generation-solution-accelerator
- ```
-
-3. **Open the project in Visual Studio Code:**
- ```bash
- code .
- ```
-
-
-## Step 3: Development Tools Setup
-
-### Visual Studio Code (Recommended)
-
-#### Required Extensions
-
-Create `.vscode/extensions.json` in the workspace root and copy the following JSON:
-
-```json
-{
- "recommendations": [
- "ms-python.python",
- "ms-python.pylint",
- "ms-python.black-formatter",
- "ms-python.isort",
- "ms-vscode-remote.remote-wsl",
- "ms-vscode-remote.remote-containers",
- "redhat.vscode-yaml",
- "ms-vscode.azure-account",
- "ms-python.mypy-type-checker"
- ]
-}
-```
-
-VS Code will prompt you to install these recommended extensions when you open the workspace.
-
-#### Settings Configuration
-
-Create `.vscode/settings.json` and copy the following JSON:
-
-```json
-{
- "python.defaultInterpreterPath": "./.venv/bin/python",
- "python.terminal.activateEnvironment": true,
- "python.formatting.provider": "black",
- "python.linting.enabled": true,
- "python.linting.pylintEnabled": true,
- "python.testing.pytestEnabled": true,
- "python.testing.unittestEnabled": false,
- "files.associations": {
- "*.yaml": "yaml",
- "*.yml": "yaml"
- }
-}
-```
-
-## Step 4: Azure Authentication Setup
-
-Before configuring services, authenticate with Azure:
-
-```bash
-# Login to Azure CLI
-az login
-
-# Set your subscription
-az account set --subscription "your-subscription-id"
-
-# Verify authentication
-az account show
-```
-
-## Step 5: Local Setup/Deployment
-
-Follow these steps to set up and run the application locally:
-
-## Local Deployment:
-
-You can refer the local deployment guide here: [Local Deployment Guide](https://github.com/microsoft/document-generation-solution-accelerator/blob/main/docs/DeploymentGuide.md)
-
-### 5.1. Open the App Folder
-Navigate to the `src` directory of the repository using Visual Studio Code.
-
-### 5.2. Configure Environment Variables
-- Copy the `.env.sample` file to a new file named `.env`.
-- Update the `.env` file with the required values from your Azure resource group in Azure Portal App Service environment variables.
-- You can get all env value in your deployed resource group under App Service:
-
-- Alternatively, if resources were
-provisioned using `azd provision` or `azd up`, a `.env` file is automatically generated in the `.azure//.env`
-file. To get your `` run `azd env list` to see which env is default.
-
-> **Note**: After adding all environment variables to the .env file, update the value of **'APP_ENV'** from:
-```
-APP_ENV="Prod"
-```
-**to:**
-```
-APP_ENV="Dev"
-```
-
-This change is required for running the application in local development mode.
-
-
-### 5.3. Required Azure RBAC Permissions
-
-To run the application locally, your Azure account needs the following role assignments on the deployed resources:
-
-#### 5.3.1. App Configuration Access
-```bash
-# Get your principal ID
-PRINCIPAL_ID=$(az ad signed-in-user show --query id -o tsv)
-
-# Assign App Configuration Data Reader role
-az role assignment create \
- --assignee $PRINCIPAL_ID \
- --role "App Configuration Data Reader" \
- --scope "/subscriptions//resourceGroups//providers/Microsoft.AppConfiguration/configurationStores/"
-```
-
-#### 5.3.2. Cosmos DB Access
-```bash
-# Assign Cosmos DB Built-in Data Contributor role
-az cosmosdb sql role assignment create \
- --account-name \
- --resource-group \
- --role-definition-name "Cosmos DB Built-in Data Contributor" \
- --principal-id $PRINCIPAL_ID \
- --scope "/"
-```
-> **Note**: After local deployment is complete, you need to execute the post-deployment script so that all the required roles will be assigned automatically.
-
-### 5.4. Running with Automated Script
-
-For convenience, you can use the provided startup scripts that handle environment setup and start both services:
-
-**Windows:**
-```cmd
-cd src
-.\start.cmd
-```
-
-**macOS/Linux:**
-```bash
-cd src
-chmod +x start.sh
-./start.sh
-```
-### 5.5. Start the Application
-- Run `start.cmd` (Windows) or `start.sh` (Linux/Mac) to:
- - Install backend dependencies.
- - Install frontend dependencies.
- - Build the frontend.
- - Start the backend server.
-- Alternatively, you can run the backend in debug mode using the VS Code debug configuration defined in `.vscode/launch.json`.
-
-
-## Step 6: Running Backend and Frontend Separately
-
-> **π Terminal Reminder**: This section requires **two separate terminal windows** - one for the Backend API and one for the Frontend. Keep both terminals open while running. All commands assume you start from the **repository root directory**.
-
-### 6.1. Create Virtual Environment (Recommended)
-
-Open your terminal and navigate to the root folder of the project, then create the virtual environment:
-
-```bash
-# Navigate to the project root folder
-cd document-generation-solution-accelerator
-
-# Create virtual environment in the root folder
-python -m venv .venv
-
-# Activate virtual environment (Windows)
-.venv/Scripts/activate
-
-# Activate virtual environment (macOS/Linux)
-source .venv/bin/activate
-```
-
-> **Note**: After activation, you should see `(.venv)` in your terminal prompt indicating the virtual environment is active.
-
-### 6.2. Install Dependencies and Run
-
-To develop and run the backend API locally:
-
-```bash
-# Navigate to the API folder (while virtual environment is activated)
-cd src/
-
-# Upgrade pip
-python -m pip install --upgrade pip
-
-# Install Python dependencies
-pip install -r requirements.txt
-
-# Install Frontend Packages
-cd frontend
-
-npm install
-npm run build
-
-# Run the backend API (Windows)
-cd ..
-
-start http://127.0.0.1:50505
-call python -m uvicorn app:app --port 50505 --reload
-
-# Run the backend API (MacOs)
-cd ..
-
-open http://127.0.0.1:50505
-python -m uvicorn app:app --port 50505 --reload
-
-# Run the backend API (Linux)
-cd ..
-
-xdg-open http://127.0.0.1:50505
-python -m uvicorn app:app --port 50505 --reload
-
-```
-
-> **Note**: Make sure your virtual environment is activated before running these commands. You should see `(.venv)` in your terminal prompt when the virtual environment is active.
-
-The App will run on `http://127.0.0.1:50505/#/` by default.
-
-## Step 7: Verify All Services Are Running
-
-Before using the application, confirm all services are running correctly:
-
-### 7.1. Terminal Status Checklist
-
-| Terminal | Service | Command | Expected Output | URL |
-|----------|---------|---------|-----------------|-----|
-| **Terminal 1** | Backend API | `python -m uvicorn app:app --port 50505 --reload` | `INFO: Application startup complete` | http://127.0.0.1:50505 |
-| **Terminal 2** | Frontend (Dev) | `npm run dev` | `Local: http://localhost:5173/` | http://localhost:5173 |
-
-### 7.2. Quick Verification
-
-**1. Check Backend API:**
-```bash
-# In a new terminal
-curl http://127.0.0.1:50505/health
-# Expected: {"status":"healthy"} or similar JSON response
-```
-
-**2. Check Frontend:**
-- Open browser to http://127.0.0.1:50505 (production build) or http://localhost:5173 (dev server)
-- Should see the Document Generation UI
-- If authentication is configured, you'll be redirected to Azure AD login
-
-### 7.3. Common Issues
-
-**Service not starting?**
-- Ensure you're in the correct directory (`src/` for backend)
-- Verify virtual environment is activated (you should see `(.venv)` in prompt)
-- Check that port is not already in use (50505 for API, 5173 for frontend dev)
-- Review error messages in the terminal
-
-**Can't access services?**
-- Verify firewall isn't blocking ports 50505 or 5173
-- Try `http://localhost:port` instead of `http://127.0.0.1:port`
-- Ensure services show "startup complete" messages
-
-## Step 8: Next Steps
-
-Once all services are running (as confirmed in Step 7), you can:
-
-1. **Access the Application**: Open `http://127.0.0.1:50505` in your browser to explore the Document Generation UI
-2. **Explore Sample Questions**: Follow [SampleQuestions.md](SampleQuestions.md) for example prompts and use cases
-3. **Understand the Architecture**: Review the codebase starting with `src/backend/` directory
-
-## Troubleshooting
-
-### Common Issues
-
-#### Python Version Issues
-
-```bash
-# Check available Python versions
-python3 --version
-python3.11 --version
-
-# If python3.11 not found, install it:
-# Ubuntu: sudo apt install python3.11
-# macOS: brew install python@3.11
-# Windows: winget install Python.Python.3.11
-```
-
-#### Virtual Environment Issues
-
-```bash
-# Recreate virtual environment
-rm -rf .venv # Linux/macOS
-# or Remove-Item -Recurse .venv # Windows PowerShell
-
-uv venv .venv
-# Activate and reinstall
-source .venv/bin/activate # Linux/macOS
-# or .\.venv\Scripts\Activate.ps1 # Windows
-uv sync --python 3.11
-```
-
-#### Permission Issues (Linux/macOS)
-
-```bash
-# Fix ownership of files
-sudo chown -R $USER:$USER .
-
-# Fix uv permissions
-chmod +x ~/.local/bin/uv
-```
-
-#### Windows-Specific Issues
-
-```powershell
-# PowerShell execution policy
-Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
-
-# Long path support (Windows 10 1607+, run as Administrator)
-New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
-
-# SSL certificate issues
-python -m pip install uv
-```
-
-### Azure Authentication Issues
-
-```bash
-# Login to Azure CLI
-az login
-
-# Set subscription
-az account set --subscription "your-subscription-id"
-
-# Test authentication
-az account show
-```
-
-### Environment Variable Issues
-
-```bash
-# Check environment variables are loaded
-env | grep AZURE # Linux/macOS
-Get-ChildItem Env:AZURE* # Windows PowerShell
-
-# Validate .env file format
-cat .env | grep -v '^#' | grep '=' # Should show key=value pairs
-```
-
-## Related Documentation
-
-- [Deployment Guide](DeploymentGuide.md) - Instructions for production deployment.
-- [Delete Resource Group](DeleteResourceGroup.md) - Steps to safely delete the Azure resource group created for the solution.
-- [App Authentication Setup](AppAuthentication.md) - Guide to configure application authentication and add support for additional platforms.
-- [Powershell Setup](PowershellSetup.md) - Instructions for setting up PowerShell and required scripts.
-- [Quota Check](QuotaCheck.md) - Steps to verify Azure quotas and ensure required limits before deployment.
diff --git a/docs/LogAnalyticsReplicationDisable.md b/docs/LogAnalyticsReplicationDisable.md
index f4379a84a..1f62aa441 100644
--- a/docs/LogAnalyticsReplicationDisable.md
+++ b/docs/LogAnalyticsReplicationDisable.md
@@ -25,4 +25,4 @@ You can safely delete:
- The resource group (manual), or
- All provisioned resources via `azd down`
-Return to: [Deployment Guide](./DeploymentGuide.md)
+Return to: [Deployment Guide](../content-gen/docs/DEPLOYMENT.md)