From 640d6f5dd8af1650bed9f4910a06472e08a06922 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 05:53:53 -0400 Subject: [PATCH 01/28] search api and search plugin --- application/single_app/app.py | 4 + application/single_app/config.py | 2 +- application/single_app/functions_documents.py | 242 +++--- application/single_app/functions_search.py | 124 ++- .../single_app/functions_search_service.py | 741 ++++++++++++++++++ application/single_app/route_backend_chats.py | 24 +- .../single_app/route_backend_search.py | 136 ++++ .../single_app/semantic_kernel_loader.py | 20 + .../document_search_plugin.py | 167 ++++ docs/explanation/features/index.md | 6 +- .../CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md | 123 +++ docs/explanation/release_notes.md | 10 + .../test_document_search_api_and_plugin.py | 231 ++++++ 13 files changed, 1696 insertions(+), 134 deletions(-) create mode 100644 application/single_app/functions_search_service.py create mode 100644 application/single_app/route_backend_search.py create mode 100644 application/single_app/semantic_kernel_plugins/document_search_plugin.py create mode 100644 docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md create mode 100644 functional_tests/test_document_search_api_and_plugin.py diff --git a/application/single_app/app.py b/application/single_app/app.py index fdbaee55..0ab62afb 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -54,6 +54,7 @@ from route_frontend_notifications import * from route_backend_chats import * +from route_backend_search import * from route_backend_conversations import * from route_backend_documents import * from route_backend_groups import * @@ -873,6 +874,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Chat Routes -------------------- register_route_backend_chats(app) +# ------------------- API Search Routes ------------------ +register_route_backend_search(app) + # ------------------- API Conversation Routes ------------ register_route_backend_conversations(app) diff --git a/application/single_app/config.py b/application/single_app/config.py index 7196cfe8..3ccb6ca9 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.006" +VERSION = "0.241.007" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 7c6e4a27..85be6ffc 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -2573,49 +2573,13 @@ def get_document_metadata_for_citations(document_id, user_id=None, group_id=None return None def get_all_chunks(document_id, user_id, group_id=None, public_workspace_id=None): - is_group = group_id is not None - is_public_workspace = public_workspace_id is not None - - # For personal documents, first check if user has access (owner or shared) - if not is_group and not is_public_workspace: - # Check if user has access to this document - if not is_document_shared_with_user(document_id, user_id): - print(f"User {user_id} does not have access to document {document_id}") - return [] - elif is_group: - # For group documents, check if group has access (owner or shared) - if not is_document_shared_with_group(document_id, group_id): - print(f"Group {group_id} does not have access to document {document_id}") - return [] - - search_client = CLIENTS["search_client_public"] if is_public_workspace else CLIENTS["search_client_group"] if is_group else CLIENTS["search_client_user"] - filter_expr = ( - f"document_id eq '{document_id}' and public_workspace_id eq '{public_workspace_id}'" - if is_public_workspace else - f"document_id eq '{document_id}' and (group_id eq '{group_id}' or shared_group_ids/any(g: g eq '{group_id}'))" - if is_group else - f"document_id eq '{document_id}'" # For personal documents, just filter by document_id since access is already verified - ) - - select_fields = [ - "id", - "chunk_text", - "chunk_id", - "file_name", - "public_workspace_id" if is_public_workspace else ("group_id" if is_group else "user_id"), - "version", - "chunk_sequence", - "upload_date" - ] - try: - results = search_client.search( - search_text="*", - filter=filter_expr, - select=",".join(select_fields) + return get_ordered_document_chunks( + document_id=document_id, + user_id=user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, ) - return results - except Exception as e: print(f"Error retrieving chunks for document {document_id}: {e}") raise @@ -2723,6 +2687,134 @@ def chunk_pdf(input_pdf_path: str, max_pages: int = 500) -> list: return chunks + +def get_document_record(user_id, document_id, group_id=None, public_workspace_id=None): + """Return a document record when the caller has access to it, otherwise None.""" + is_group = group_id is not None + is_public_workspace = public_workspace_id is not None + + cosmos_container = _get_documents_container( + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + try: + document_item = cosmos_container.read_item( + item=document_id, + partition_key=document_id, + ) + except CosmosResourceNotFoundError: + return None + except Exception as e: + print(f"Error retrieving document record {document_id}: {e}") + return None + + if is_public_workspace: + if document_item.get('public_workspace_id') != public_workspace_id: + return None + return _normalize_document_enhanced_citations(document_item) + + if is_group: + shared_group_ids = document_item.get('shared_group_ids', []) + if ( + document_item.get('group_id') != group_id + and not any(str(entry).startswith(f"{group_id},") for entry in shared_group_ids) + ): + return None + return _normalize_document_enhanced_citations(document_item) + + shared_user_ids = document_item.get('shared_user_ids', []) + if ( + document_item.get('user_id') != user_id + and not any(str(entry).startswith(f"{user_id},") for entry in shared_user_ids) + ): + return None + + return _normalize_document_enhanced_citations(document_item) + + +def get_ordered_document_chunks(document_id, user_id, group_id=None, public_workspace_id=None, max_chunks=None): + """Return ordered chunk records for a document after access has been verified.""" + document_item = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + if not document_item: + return [] + + search_client = _get_search_client( + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + select_fields = [ + 'id', + 'document_id', + 'chunk_text', + 'chunk_id', + 'file_name', + 'user_id', + 'group_id', + 'public_workspace_id', + 'version', + 'chunk_sequence', + 'page_number', + 'upload_date', + 'document_classification', + 'document_tags', + 'author', + 'chunk_keywords', + 'title', + 'chunk_summary', + ] + search_kwargs = { + 'search_text': '*', + 'filter': f"document_id eq '{document_id}'", + 'select': ','.join(select_fields), + } + if max_chunks is not None: + search_kwargs['top'] = max(1, int(max_chunks)) + + try: + results = list(search_client.search(**search_kwargs)) + except Exception as e: + print(f"Error retrieving chunks for document {document_id}: {e}") + raise + + ordered_chunks = [] + for result in results: + ordered_chunks.append({ + 'id': result.get('id'), + 'document_id': result.get('document_id'), + 'chunk_text': result.get('chunk_text', ''), + 'chunk_id': result.get('chunk_id'), + 'file_name': result.get('file_name'), + 'user_id': result.get('user_id'), + 'group_id': result.get('group_id'), + 'public_workspace_id': result.get('public_workspace_id'), + 'version': result.get('version'), + 'chunk_sequence': result.get('chunk_sequence', 0), + 'page_number': result.get('page_number'), + 'upload_date': result.get('upload_date'), + 'document_classification': result.get('document_classification'), + 'document_tags': result.get('document_tags', []), + 'author': result.get('author'), + 'chunk_keywords': result.get('chunk_keywords'), + 'title': result.get('title'), + 'chunk_summary': result.get('chunk_summary'), + }) + + ordered_chunks.sort( + key=lambda chunk: ( + _safe_int(chunk.get('page_number')) if chunk.get('page_number') is not None else 10**9, + _safe_int(chunk.get('chunk_sequence')), + str(chunk.get('id') or ''), + ) + ) + return ordered_chunks + def get_documents(user_id, group_id=None, public_workspace_id=None): try: documents = _query_accessible_documents( @@ -2736,72 +2828,18 @@ def get_documents(user_id, group_id=None, public_workspace_id=None): return jsonify({'error': f'Error retrieving documents: {str(e)}'}), 500 def get_document(user_id, document_id, group_id=None, public_workspace_id=None): - is_group = group_id is not None - is_public_workspace = public_workspace_id is not None - - # Choose the correct cosmos_container and query parameters - if is_public_workspace: - cosmos_container = cosmos_public_documents_container - elif is_group: - cosmos_container = cosmos_group_documents_container - else: - cosmos_container = cosmos_user_documents_container - - if is_public_workspace: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND c.public_workspace_id = @public_workspace_id - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@public_workspace_id", "value": public_workspace_id} - ] - elif is_group: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND (c.group_id = @group_id OR ARRAY_CONTAINS(c.shared_group_ids, @group_id)) - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@group_id", "value": group_id} - ] - else: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND ( - c.user_id = @user_id - OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) - OR EXISTS(SELECT VALUE s FROM s IN c.shared_user_ids WHERE STARTSWITH(s, @user_id_prefix)) - ) - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@user_id", "value": user_id}, - {"name": "@user_id_prefix", "value": f"{user_id},"} - ] - try: - document_results = list( - cosmos_container.query_items( - query=query, - parameters=parameters, - enable_cross_partition_query=True - ) + document_record = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + public_workspace_id=public_workspace_id, ) - if not document_results: + if not document_record: return jsonify({'error': 'Document not found or access denied'}), 404 - return jsonify(_normalize_document_enhanced_citations(document_results[0])), 200 + return jsonify(document_record), 200 except Exception as e: return jsonify({'error': f'Error retrieving document: {str(e)}'}), 500 diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 6851778f..e7d117a6 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -16,6 +16,97 @@ logger = logging.getLogger(__name__) +SEARCH_DEFAULT_TOP_N = 12 +SEARCH_MAX_TOP_N = 500 +VALID_SEARCH_SCOPES = {"all", "personal", "group", "public"} +BASE_SEARCH_SELECT_FIELDS = [ + "id", + "document_id", + "chunk_text", + "chunk_id", + "file_name", + "version", + "chunk_sequence", + "upload_date", + "document_classification", + "document_tags", + "page_number", + "author", + "chunk_keywords", + "title", + "chunk_summary", +] +SEARCH_SELECT_FIELDS_BY_SCOPE = { + "personal": BASE_SEARCH_SELECT_FIELDS + ["user_id"], + "group": BASE_SEARCH_SELECT_FIELDS + ["group_id"], + "public": BASE_SEARCH_SELECT_FIELDS + ["public_workspace_id"], +} + + +def normalize_search_top_n(top_n, default_top_n=SEARCH_DEFAULT_TOP_N, max_top_n=SEARCH_MAX_TOP_N): + """Return a bounded integer top-N value for search-style operations.""" + try: + normalized_top_n = int(top_n) + except (TypeError, ValueError): + return default_top_n + + if normalized_top_n < 1: + return default_top_n + + return min(normalized_top_n, max_top_n) + + +def normalize_search_scope(doc_scope, default_scope="all"): + """Normalize search scope values to the supported set.""" + normalized_scope = str(doc_scope or default_scope).strip().lower() + if normalized_scope not in VALID_SEARCH_SCOPES: + return default_scope + return normalized_scope + + +def normalize_search_id_list(raw_ids): + """Normalize an optional list or comma-separated string of ids.""" + if raw_ids is None: + return [] + + if isinstance(raw_ids, str): + candidate_ids = [value.strip() for value in raw_ids.split(",") if value.strip()] + elif isinstance(raw_ids, list): + candidate_ids = [str(value).strip() for value in raw_ids if str(value).strip()] + else: + candidate_ids = [str(raw_ids).strip()] if str(raw_ids).strip() else [] + + normalized_ids = [] + seen_ids = set() + for candidate_id in candidate_ids: + if candidate_id in seen_ids: + continue + seen_ids.add(candidate_id) + normalized_ids.append(candidate_id) + + return normalized_ids + + +def get_search_select_fields(scope_name): + return SEARCH_SELECT_FIELDS_BY_SCOPE.get(scope_name, SEARCH_SELECT_FIELDS_BY_SCOPE["personal"]) + + +def get_search_result_scope(result_item): + if result_item.get("public_workspace_id"): + return "public" + if result_item.get("group_id"): + return "group" + return "personal" + + +def get_search_result_scope_id(result_item): + return ( + result_item.get("public_workspace_id") + or result_item.get("group_id") + or result_item.get("user_id") + ) + + def normalize_scores(results: List[Dict[str, Any]], index_name: str = "unknown") -> List[Dict[str, Any]]: """ Normalize search scores to [0, 1] range using min-max normalization. @@ -109,6 +200,10 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, across identical queries against the same document set. """ + top_n = normalize_search_top_n(top_n) + doc_scope = normalize_search_scope(doc_scope) + document_ids = normalize_search_id_list(document_ids) + # Backwards compat: wrap single group ID into list if not active_group_ids and active_group_id: active_group_ids = [active_group_id] @@ -253,7 +348,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) # Only search group index if active_group_ids is provided @@ -271,7 +366,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) else: group_results = [] @@ -298,7 +393,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) else: # Build user filter with optional tags @@ -317,7 +412,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) # Only search group index if active_group_ids is provided @@ -335,7 +430,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) else: group_results = [] @@ -362,7 +457,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) # Extract results from each index @@ -412,7 +507,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) results = extract_search_results(user_results, top_n) else: @@ -431,7 +526,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) results = extract_search_results(user_results, top_n) @@ -452,7 +547,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) results = extract_search_results(group_results, top_n) else: @@ -469,7 +564,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) results = extract_search_results(group_results, top_n) @@ -497,7 +592,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) results = extract_search_results(public_results, top_n) else: @@ -523,7 +618,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) results = extract_search_results(public_results, top_n) @@ -613,13 +708,18 @@ def extract_search_results(paged_results, top_n): for i, r in enumerate(paged_results): if i >= top_n: break + result_scope = get_search_result_scope(r) extracted.append({ "id": r["id"], + "document_id": r.get("document_id"), "chunk_text": r["chunk_text"], "chunk_id": r["chunk_id"], "file_name": r["file_name"], + "user_id": r.get("user_id"), "group_id": r.get("group_id"), "public_workspace_id": r.get("public_workspace_id"), + "scope": result_scope, + "scope_id": get_search_result_scope_id(r), "version": r["version"], "chunk_sequence": r["chunk_sequence"], "upload_date": r["upload_date"], diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py new file mode 100644 index 00000000..f00fe0ca --- /dev/null +++ b/application/single_app/functions_search_service.py @@ -0,0 +1,741 @@ +# functions_search_service.py +"""Shared search, retrieval, and summarization services for documents.""" + +import logging +import math +from typing import Any, Dict, List, Optional + +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from openai import AzureOpenAI + +from config import cognitive_services_scope +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_documents import get_document_record, get_ordered_document_chunks +from functions_group import get_user_groups +from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings +from functions_search import ( + SEARCH_DEFAULT_TOP_N, + SEARCH_MAX_TOP_N, + hybrid_search, + normalize_search_id_list, + normalize_search_scope, + normalize_search_top_n, +) +from functions_settings import get_settings, get_user_settings + + +SUMMARY_DEFAULT_WINDOW_UNIT = "pages" +SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET = "2 pages" +SUMMARY_DEFAULT_FINAL_TARGET = "2 pages" +SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE = 4 +SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS = 4 +SUMMARY_DEFAULT_MIN_PAGE_WINDOW = 5 +SUMMARY_DEFAULT_MAX_PAGE_WINDOW = 25 +SUMMARY_DEFAULT_CHUNK_WINDOW = 20 +SUMMARY_MAX_WINDOW_SIZE = 50 + + +def _coerce_positive_int(value, default_value, min_value=1, max_value=None): + try: + normalized_value = int(value) + except (TypeError, ValueError): + normalized_value = default_value + + if normalized_value < min_value: + normalized_value = default_value + if max_value is not None: + normalized_value = min(normalized_value, max_value) + return normalized_value + + +def _normalize_window_unit(window_unit, chunks): + normalized_window_unit = str(window_unit or SUMMARY_DEFAULT_WINDOW_UNIT).strip().lower() + if normalized_window_unit == "pages": + has_page_numbers = any(chunk.get("page_number") is not None for chunk in chunks or []) + if has_page_numbers: + return "pages" + return "chunks" + + +def _resolve_active_group_ids(user_id, active_group_ids=None, fallback_to_memberships=False): + normalized_group_ids = normalize_search_id_list(active_group_ids) + if normalized_group_ids: + return normalized_group_ids + + user_settings = get_user_settings(user_id) + active_group_id = str(user_settings.get("settings", {}).get("activeGroupOid") or "").strip() + if active_group_id: + return [active_group_id] + + if not fallback_to_memberships: + return [] + + try: + user_groups = get_user_groups(user_id) + except Exception: + return [] + + return normalize_search_id_list([group.get("id") for group in user_groups if group.get("id")]) + + +def _resolve_public_workspace_ids(user_id, active_public_workspace_id=None): + normalized_workspace_ids = normalize_search_id_list(active_public_workspace_id) + if normalized_workspace_ids: + return normalized_workspace_ids + + try: + return normalize_search_id_list(get_user_visible_public_workspace_ids_from_settings(user_id)) + except Exception: + return [] + + +def _serialize_document(document_item, scope_name): + return { + "id": document_item.get("id"), + "file_name": document_item.get("file_name"), + "title": document_item.get("title"), + "abstract": document_item.get("abstract"), + "version": document_item.get("version"), + "revision_family_id": document_item.get("revision_family_id"), + "document_classification": document_item.get("document_classification"), + "tags": document_item.get("tags", []), + "scope": scope_name, + "scope_id": ( + document_item.get("public_workspace_id") + or document_item.get("group_id") + or document_item.get("user_id") + ), + "group_id": document_item.get("group_id"), + "public_workspace_id": document_item.get("public_workspace_id"), + "user_id": document_item.get("user_id"), + } + + +def resolve_document_context( + document_id, + user_id, + doc_scope="all", + active_group_ids=None, + active_public_workspace_id=None, +): + normalized_scope = normalize_search_scope(doc_scope) + + if normalized_scope in ("all", "personal"): + personal_document = get_document_record(user_id=user_id, document_id=document_id) + if personal_document: + return { + "scope": "personal", + "group_id": None, + "public_workspace_id": None, + "document": personal_document, + } + + if normalized_scope in ("all", "group"): + for group_id in _resolve_active_group_ids( + user_id, + active_group_ids=active_group_ids, + fallback_to_memberships=True, + ): + group_document = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + ) + if group_document: + return { + "scope": "group", + "group_id": group_id, + "public_workspace_id": None, + "document": group_document, + } + + if normalized_scope in ("all", "public"): + for public_workspace_id in _resolve_public_workspace_ids( + user_id, + active_public_workspace_id=active_public_workspace_id, + ): + public_document = get_document_record( + user_id=user_id, + document_id=document_id, + public_workspace_id=public_workspace_id, + ) + if public_document: + return { + "scope": "public", + "group_id": None, + "public_workspace_id": public_workspace_id, + "document": public_document, + } + + return None + + +def build_search_request( + query, + user_id, + top_n=None, + doc_scope="all", + document_id=None, + document_ids=None, + tags_filter=None, + active_group_ids=None, + active_public_workspace_id=None, + enable_file_sharing=True, +): + normalized_query = str(query or "").strip() + if not normalized_query: + raise ValueError("Query is required") + + normalized_scope = normalize_search_scope(doc_scope) + normalized_top_n = normalize_search_top_n(top_n, SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N) + normalized_document_ids = normalize_search_id_list(document_ids) + if document_id and not normalized_document_ids: + normalized_document_ids = [str(document_id).strip()] + + search_request = { + "query": normalized_query, + "user_id": user_id, + "top_n": normalized_top_n, + "doc_scope": normalized_scope, + "enable_file_sharing": bool(enable_file_sharing), + } + + if normalized_document_ids: + search_request["document_ids"] = normalized_document_ids + + normalized_tags = normalize_search_id_list(tags_filter) + if normalized_tags: + search_request["tags_filter"] = normalized_tags + + resolved_group_ids = _resolve_active_group_ids( + user_id, + active_group_ids=active_group_ids, + fallback_to_memberships=False, + ) + if resolved_group_ids and normalized_scope in ("all", "group"): + search_request["active_group_ids"] = resolved_group_ids + + resolved_public_workspace_ids = _resolve_public_workspace_ids( + user_id, + active_public_workspace_id=active_public_workspace_id, + ) + if resolved_public_workspace_ids and normalized_scope in ("all", "public"): + search_request["active_public_workspace_id"] = resolved_public_workspace_ids[0] + + return search_request + + +def search_documents( + query, + user_id, + top_n=None, + doc_scope="all", + document_id=None, + document_ids=None, + tags_filter=None, + active_group_ids=None, + active_public_workspace_id=None, + enable_file_sharing=True, +): + search_request = build_search_request( + query=query, + user_id=user_id, + top_n=top_n, + doc_scope=doc_scope, + document_id=document_id, + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + enable_file_sharing=enable_file_sharing, + ) + results = hybrid_search(**search_request) or [] + unique_document_ids = { + result.get("document_id") + for result in results + if result.get("document_id") + } + + return { + "query": search_request.get("query"), + "scope": search_request.get("doc_scope"), + "top_n": search_request.get("top_n"), + "document_ids": search_request.get("document_ids", []), + "tags_filter": search_request.get("tags_filter", []), + "group_ids": search_request.get("active_group_ids", []), + "active_public_workspace_id": search_request.get("active_public_workspace_id"), + "result_count": len(results), + "document_count": len(unique_document_ids), + "results": results, + } + + +def _derive_window_size(chunks, window_unit, window_size=None, window_percent=None): + if not chunks: + return 0 + + if window_unit == "pages": + total_units = len({chunk.get("page_number") for chunk in chunks if chunk.get("page_number") is not None}) + if total_units <= 0: + return 0 + + if window_size is not None and str(window_size).strip() != "": + return _coerce_positive_int( + window_size, + default_value=min(total_units, SUMMARY_DEFAULT_MAX_PAGE_WINDOW), + min_value=1, + max_value=min(total_units, SUMMARY_MAX_WINDOW_SIZE), + ) + + if window_percent: + computed_size = int(math.ceil(total_units * (float(window_percent) / 100.0))) + else: + computed_size = int(math.ceil(total_units / 4.0)) + + computed_size = max(SUMMARY_DEFAULT_MIN_PAGE_WINDOW, computed_size) + computed_size = min(SUMMARY_DEFAULT_MAX_PAGE_WINDOW, computed_size) + return min(total_units, computed_size) + + total_units = len(chunks) + if total_units <= 0: + return 0 + + default_chunk_window = min(total_units, SUMMARY_DEFAULT_CHUNK_WINDOW) + if window_size is not None and str(window_size).strip() != "": + return _coerce_positive_int( + window_size, + default_value=default_chunk_window, + min_value=1, + max_value=min(total_units, SUMMARY_MAX_WINDOW_SIZE), + ) + + if window_percent: + computed_size = int(math.ceil(total_units * (float(window_percent) / 100.0))) + return min(total_units, max(1, computed_size)) + + return default_chunk_window + + +def build_document_chunk_windows(chunks, window_unit="pages", window_size=None, window_percent=None): + if not chunks: + return [] + + normalized_window_unit = _normalize_window_unit(window_unit, chunks) + resolved_window_size = _derive_window_size( + chunks, + normalized_window_unit, + window_size=window_size, + window_percent=window_percent, + ) + if resolved_window_size <= 0: + return [] + + windows = [] + if normalized_window_unit == "pages": + ordered_pages = sorted({chunk.get("page_number") for chunk in chunks if chunk.get("page_number") is not None}) + for window_index, page_offset in enumerate(range(0, len(ordered_pages), resolved_window_size), start=1): + window_pages = ordered_pages[page_offset:page_offset + resolved_window_size] + window_chunks = [ + chunk for chunk in chunks + if chunk.get("page_number") in window_pages + ] + windows.append({ + "window_number": window_index, + "window_unit": normalized_window_unit, + "window_size": resolved_window_size, + "chunk_count": len(window_chunks), + "page_count": len(window_pages), + "start_page": window_pages[0], + "end_page": window_pages[-1], + "start_chunk_sequence": window_chunks[0].get("chunk_sequence") if window_chunks else None, + "end_chunk_sequence": window_chunks[-1].get("chunk_sequence") if window_chunks else None, + "chunks": window_chunks, + }) + else: + for window_index, chunk_offset in enumerate(range(0, len(chunks), resolved_window_size), start=1): + window_chunks = chunks[chunk_offset:chunk_offset + resolved_window_size] + page_numbers = [chunk.get("page_number") for chunk in window_chunks if chunk.get("page_number") is not None] + windows.append({ + "window_number": window_index, + "window_unit": normalized_window_unit, + "window_size": resolved_window_size, + "chunk_count": len(window_chunks), + "page_count": len(set(page_numbers)) if page_numbers else 0, + "start_page": min(page_numbers) if page_numbers else None, + "end_page": max(page_numbers) if page_numbers else None, + "start_chunk_sequence": window_chunks[0].get("chunk_sequence") if window_chunks else None, + "end_chunk_sequence": window_chunks[-1].get("chunk_sequence") if window_chunks else None, + "chunks": window_chunks, + }) + + return windows + + +def get_document_chunks_payload( + document_id, + user_id, + doc_scope="all", + active_group_ids=None, + active_public_workspace_id=None, + window_unit="pages", + window_size=None, + window_percent=None, + window_number=None, +): + document_context = resolve_document_context( + document_id=document_id, + user_id=user_id, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + ) + if not document_context: + raise LookupError("Document not found or access denied") + + chunks = get_ordered_document_chunks( + document_id=document_id, + user_id=user_id, + group_id=document_context.get("group_id"), + public_workspace_id=document_context.get("public_workspace_id"), + ) + windows = build_document_chunk_windows( + chunks, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + selected_window = None + selected_chunks = chunks + + if window_number not in (None, ""): + resolved_window_number = _coerce_positive_int(window_number, default_value=1) + selected_window = next( + (window for window in windows if window.get("window_number") == resolved_window_number), + None, + ) + if not selected_window: + raise LookupError(f"Window {resolved_window_number} was not found for this document") + selected_chunks = selected_window.get("chunks", []) + + return { + "document": _serialize_document(document_context.get("document"), document_context.get("scope")), + "scope": document_context.get("scope"), + "scope_id": ( + document_context.get("public_workspace_id") + or document_context.get("group_id") + or document_context.get("document", {}).get("user_id") + ), + "chunk_count": len(chunks), + "returned_chunk_count": len(selected_chunks), + "window_count": len(windows), + "windowing": { + "window_unit": windows[0].get("window_unit") if windows else _normalize_window_unit(window_unit, chunks), + "window_size": windows[0].get("window_size") if windows else None, + "window_percent": window_percent, + "selected_window_number": selected_window.get("window_number") if selected_window else None, + }, + "windows": [ + { + "window_number": window.get("window_number"), + "window_unit": window.get("window_unit"), + "window_size": window.get("window_size"), + "chunk_count": window.get("chunk_count"), + "page_count": window.get("page_count"), + "start_page": window.get("start_page"), + "end_page": window.get("end_page"), + "start_chunk_sequence": window.get("start_chunk_sequence"), + "end_chunk_sequence": window.get("end_chunk_sequence"), + } + for window in windows + ], + "chunks": selected_chunks, + } + + +def _render_window_source_text(window_payload): + source_parts = [] + for chunk in window_payload.get("chunks", []): + chunk_text = str(chunk.get("chunk_text") or "").strip() + if not chunk_text: + continue + + chunk_labels = [] + if chunk.get("page_number") is not None: + chunk_labels.append(f"Page {chunk.get('page_number')}") + if chunk.get("chunk_sequence") is not None: + chunk_labels.append(f"Chunk {chunk.get('chunk_sequence')}") + prefix = f"[{', '.join(chunk_labels)}] " if chunk_labels else "" + source_parts.append(f"{prefix}{chunk_text}") + + return "\n\n".join(source_parts) + + +def _create_summary_client(settings): + if settings.get('enable_gpt_apim', False): + return AzureOpenAI( + api_version=settings.get('azure_apim_gpt_api_version'), + azure_endpoint=settings.get('azure_apim_gpt_endpoint'), + api_key=settings.get('azure_apim_gpt_subscription_key'), + ) + + auth_type = settings.get('azure_openai_gpt_authentication_type', 'key') + if auth_type == 'managed_identity': + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), + cognitive_services_scope, + ) + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + azure_ad_token_provider=token_provider, + ) + + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + api_key=settings.get('azure_openai_gpt_key'), + ) + + +def _resolve_summary_model(settings): + selected_model = settings.get('gpt_model', {}).get('selected', [{}]) + selected_model = selected_model[0] if selected_model else {} + model_name = ( + settings.get('metadata_extraction_model') + or settings.get('azure_openai_gpt_deployment') + or selected_model.get('deploymentName') + ) + if not model_name: + raise RuntimeError('No GPT deployment is configured for document summarization') + return model_name + + +def _build_summary_api_params(model_name, messages, max_output_tokens=1600): + uses_completion_tokens = any( + marker in model_name.lower() + for marker in ('o1', 'o3', 'gpt-5') + ) + api_params = { + 'model': model_name, + 'messages': messages, + } + if uses_completion_tokens: + api_params['max_completion_tokens'] = max_output_tokens + else: + api_params['temperature'] = 0.2 + api_params['max_tokens'] = max_output_tokens + return api_params + + +def _summarize_text_block( + gpt_client, + model_name, + file_name, + stage_label, + target_length, + focus_instructions, + coverage_note, + source_text, +): + messages = [ + { + 'role': 'system', + 'content': ( + 'You summarize document content accurately and conservatively. ' + 'Do not invent details. Preserve factual meaning, decisions, risks, dates, and action items when present.' + ), + }, + { + 'role': 'user', + 'content': ( + f'Document: {file_name}\n' + f'Stage: {stage_label}\n' + f'Coverage: {coverage_note}\n' + f'Target length: {target_length}\n' + f'Focus instructions: {focus_instructions or "Summarize the most important facts, decisions, risks, dependencies, and open questions."}\n\n' + 'Write a clear summary with short section headers when useful. ' + 'Call out important caveats or ambiguities explicitly.\n\n' + f'\n{source_text}\n' + ), + }, + ] + response = gpt_client.chat.completions.create( + **_build_summary_api_params(model_name, messages) + ) + return str(response.choices[0].message.content or '').strip() + + +def _build_reduction_windows(summary_items, batch_size): + reduction_windows = [] + for window_number, index in enumerate(range(0, len(summary_items), batch_size), start=1): + batch_items = summary_items[index:index + batch_size] + source_text = [] + for batch_item in batch_items: + source_text.append( + f"[Section {batch_item.get('source_window_numbers')}]\n{batch_item.get('summary', '')}" + ) + reduction_windows.append({ + 'window_number': window_number, + 'window_unit': 'summaries', + 'window_size': batch_size, + 'chunk_count': sum(item.get('chunk_count', 0) for item in batch_items), + 'page_count': sum(item.get('page_count', 0) for item in batch_items), + 'start_page': batch_items[0].get('start_page') if batch_items else None, + 'end_page': batch_items[-1].get('end_page') if batch_items else None, + 'source_text': '\n\n'.join(source_text), + 'source_window_numbers': [item.get('source_window_numbers') for item in batch_items], + }) + return reduction_windows + + +def summarize_document_content( + document_id, + user_id, + doc_scope='all', + active_group_ids=None, + active_public_workspace_id=None, + focus_instructions='', + final_target_length=SUMMARY_DEFAULT_FINAL_TARGET, + window_target_length=SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET, + window_unit=SUMMARY_DEFAULT_WINDOW_UNIT, + window_size=None, + window_percent=None, + reduction_batch_size=SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, + max_reduction_rounds=SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS, +): + chunk_payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + windows = build_document_chunk_windows( + chunk_payload.get('chunks', []), + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + if not windows: + raise LookupError('No document chunks were available for summarization') + + settings = get_settings() + model_name = _resolve_summary_model(settings) + gpt_client = _create_summary_client(settings) + reduction_batch_size = _coerce_positive_int( + reduction_batch_size, + SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, + min_value=1, + max_value=8, + ) + max_reduction_rounds = _coerce_positive_int( + max_reduction_rounds, + SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS, + min_value=1, + max_value=8, + ) + file_name = chunk_payload.get('document', {}).get('file_name') or document_id + + stage_records = [] + current_stage_inputs = windows + stage_number = 1 + final_summary = '' + + while current_stage_inputs and stage_number <= max_reduction_rounds: + debug_print( + f"[SearchService] Summarization stage {stage_number} for {file_name} with {len(current_stage_inputs)} input windows" + ) + output_items = [] + + for stage_input in current_stage_inputs: + if stage_number == 1: + coverage_note = ( + f"pages {stage_input.get('start_page')} to {stage_input.get('end_page')}" + if stage_input.get('start_page') is not None else + f"chunks {stage_input.get('start_chunk_sequence')} to {stage_input.get('end_chunk_sequence')}" + ) + source_text = _render_window_source_text(stage_input) + source_window_numbers = [stage_input.get('window_number')] + target_length = window_target_length + page_count = stage_input.get('page_count', 0) + chunk_count = stage_input.get('chunk_count', 0) + start_page = stage_input.get('start_page') + end_page = stage_input.get('end_page') + else: + coverage_note = f"summary windows {stage_input.get('source_window_numbers')}" + source_text = stage_input.get('source_text', '') + source_window_numbers = stage_input.get('source_window_numbers', []) + target_length = final_target_length + page_count = stage_input.get('page_count', 0) + chunk_count = stage_input.get('chunk_count', 0) + start_page = stage_input.get('start_page') + end_page = stage_input.get('end_page') + + if not source_text.strip(): + continue + + summary_text = _summarize_text_block( + gpt_client=gpt_client, + model_name=model_name, + file_name=file_name, + stage_label=f'stage-{stage_number}', + target_length=target_length, + focus_instructions=focus_instructions, + coverage_note=coverage_note, + source_text=source_text, + ) + output_items.append({ + 'window_number': stage_input.get('window_number'), + 'source_window_numbers': source_window_numbers, + 'chunk_count': chunk_count, + 'page_count': page_count, + 'start_page': start_page, + 'end_page': end_page, + 'summary': summary_text, + }) + + stage_records.append({ + 'stage_number': stage_number, + 'input_count': len(current_stage_inputs), + 'output_count': len(output_items), + 'target_length': window_target_length if stage_number == 1 else final_target_length, + 'outputs': output_items, + }) + + if len(output_items) <= 1: + final_summary = output_items[0].get('summary', '') if output_items else '' + break + + current_stage_inputs = _build_reduction_windows(output_items, reduction_batch_size) + stage_number += 1 + + log_event( + '[SearchService] Document summarization completed', + extra={ + 'document_id': document_id, + 'file_name': file_name, + 'stage_count': len(stage_records), + 'window_count': len(windows), + 'scope': chunk_payload.get('scope'), + }, + level=logging.INFO, + ) + + return { + 'document': chunk_payload.get('document'), + 'scope': chunk_payload.get('scope'), + 'scope_id': chunk_payload.get('scope_id'), + 'chunk_count': chunk_payload.get('chunk_count'), + 'window_count': len(windows), + 'windowing': chunk_payload.get('windowing'), + 'focus_instructions': focus_instructions, + 'window_target_length': window_target_length, + 'final_target_length': final_target_length, + 'stage_count': len(stage_records), + 'stages': stage_records, + 'summary': final_summary, + } \ No newline at end of file diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index e16d7242..d698e008 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -6935,24 +6935,12 @@ def result_requires_message_reload(result: Any) -> bool: ) try: # Prepare search arguments - # Set default and maximum values for top_n - default_top_n = 12 - max_top_n = 500 # Reasonable cap to prevent excessive resource usage - - # Process top_n_results if provided - if top_n_results is not None: - try: - top_n = int(top_n_results) - # Ensure top_n is within reasonable bounds - if top_n < 1: - top_n = default_top_n - elif top_n > max_top_n: - top_n = max_top_n - except (ValueError, TypeError): - # If conversion fails, use default - top_n = default_top_n - else: - top_n = default_top_n + default_top_n = SEARCH_DEFAULT_TOP_N + top_n = normalize_search_top_n( + top_n_results, + default_top_n=SEARCH_DEFAULT_TOP_N, + max_top_n=SEARCH_MAX_TOP_N, + ) search_args = { "query": search_query, diff --git a/application/single_app/route_backend_search.py b/application/single_app/route_backend_search.py new file mode 100644 index 00000000..5a5a98bc --- /dev/null +++ b/application/single_app/route_backend_search.py @@ -0,0 +1,136 @@ +# route_backend_search.py + +from config import * +from functions_appinsights import log_event +from functions_authentication import get_current_user_id, login_required, user_required +from functions_search_service import ( + get_document_chunks_payload, + search_documents as run_document_search, + summarize_document_content, +) +from swagger_wrapper import swagger_route, get_auth_security + + +def register_route_backend_search(app): + @app.route('/api/search/documents', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_search_documents(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + + try: + payload = run_document_search( + query=data.get('query'), + user_id=user_id, + top_n=data.get('top_n'), + doc_scope=data.get('doc_scope', 'all'), + document_id=data.get('document_id'), + document_ids=data.get('document_ids'), + tags_filter=data.get('tags_filter', data.get('tags')), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + enable_file_sharing=data.get('enable_file_sharing', True), + ) + return jsonify(payload), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[Backend Search] Document search failed.', + extra={'user_id': user_id, 'error_message': str(e)}, + level=logging.ERROR, + ) + return jsonify({'error': 'Document search failed'}), 500 + + @app.route('/api/search/document-chunks', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_get_document_chunks(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + document_id = str(data.get('document_id') or '').strip() + if not document_id: + return jsonify({'error': 'document_id is required'}), 400 + + try: + payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=data.get('doc_scope', 'all'), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + window_unit=data.get('window_unit', 'pages'), + window_size=data.get('window_size'), + window_percent=data.get('window_percent'), + window_number=data.get('window_number'), + ) + return jsonify(payload), 200 + except LookupError as e: + return jsonify({'error': str(e)}), 404 + except Exception as e: + log_event( + '[Backend Search] Document chunk retrieval failed.', + extra={ + 'user_id': user_id, + 'document_id': document_id, + 'error_message': str(e), + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Document chunk retrieval failed'}), 500 + + @app.route('/api/search/document-summary', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_summarize_document(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + document_id = str(data.get('document_id') or '').strip() + if not document_id: + return jsonify({'error': 'document_id is required'}), 400 + + try: + payload = summarize_document_content( + document_id=document_id, + user_id=user_id, + doc_scope=data.get('doc_scope', 'all'), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + focus_instructions=data.get('focus_instructions', ''), + final_target_length=data.get('final_target_length', data.get('target_length', '2 pages')), + window_target_length=data.get('window_target_length', '2 pages'), + window_unit=data.get('window_unit', 'pages'), + window_size=data.get('window_size'), + window_percent=data.get('window_percent'), + reduction_batch_size=data.get('reduction_batch_size'), + max_reduction_rounds=data.get('max_reduction_rounds'), + ) + return jsonify(payload), 200 + except LookupError as e: + return jsonify({'error': str(e)}), 404 + except RuntimeError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[Backend Search] Document summarization failed.', + extra={ + 'user_id': user_id, + 'document_id': document_id, + 'error_message': str(e), + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Document summarization failed'}), 500 \ No newline at end of file diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 3a2ca4b5..0942baf3 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -22,6 +22,7 @@ from semantic_kernel.functions.kernel_plugin import KernelPlugin from semantic_kernel_plugins.embedding_model_plugin import EmbeddingModelPlugin from semantic_kernel_plugins.fact_memory_plugin import FactMemoryPlugin +from semantic_kernel_plugins.document_search_plugin import DocumentSearchPlugin from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin from functions_settings import get_settings, get_user_settings, is_tabular_processing_enabled from foundry_agent_runtime import ( @@ -792,6 +793,13 @@ def load_fact_memory_plugin(kernel: Kernel): description="Provides functions for managing persistent facts." ) +def load_document_search_plugin(kernel: Kernel): + kernel.add_plugin( + DocumentSearchPlugin(), + plugin_name="document_search", + description="Provides hybrid document search, exhaustive chunk retrieval, and hierarchical document summarization." + ) + def load_embedding_model_plugin(kernel: Kernel, settings): embedding_endpoint = settings.get('azure_openai_embedding_endpoint') embedding_key = settings.get('azure_openai_embedding_key') @@ -824,6 +832,12 @@ def load_core_plugins_only(kernel: Kernel, settings): load_fact_memory_plugin(kernel) log_event("[SK Loader] Loaded Fact Memory plugin.", level=logging.INFO) + try: + load_document_search_plugin(kernel) + log_event("[SK Loader] Loaded Document Search plugin.", level=logging.INFO) + except Exception as e: + log_event(f"[SK Loader] Failed to load Document Search plugin: {e}", level=logging.WARNING) + if settings.get('enable_math_plugin', True): load_math_plugin(kernel) log_event("[SK Loader] Loaded Math plugin.", level=logging.INFO) @@ -1692,6 +1706,12 @@ def load_plugins_for_kernel(kernel, plugin_manifests, settings, mode_label="glob except Exception as e: log_event(f"[SK Loader] Failed to load Fact Memory Plugin: {e}", level=logging.WARNING) + try: + load_document_search_plugin(kernel) + log_event("[SK Loader] Loaded Document Search Plugin.", level=logging.INFO) + except Exception as e: + log_event(f"[SK Loader] Failed to load Document Search Plugin: {e}", level=logging.WARNING) + # Register Tabular Processing Plugin if enabled (requires enhanced citations) if is_tabular_processing_enabled(settings): try: diff --git a/application/single_app/semantic_kernel_plugins/document_search_plugin.py b/application/single_app/semantic_kernel_plugins/document_search_plugin.py new file mode 100644 index 00000000..e391d95e --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/document_search_plugin.py @@ -0,0 +1,167 @@ +# document_search_plugin.py + +from typing import Annotated, Any, Dict + +from semantic_kernel.functions import kernel_function + +from functions_authentication import get_current_user_id +from functions_search_service import ( + get_document_chunks_payload, + search_documents as run_document_search, + summarize_document_content, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class DocumentSearchPlugin(BasePlugin): + def __init__(self, manifest: Dict[str, Any] = None): + super().__init__(manifest) + + @property + def display_name(self) -> str: + return 'Document Search' + + @property + def metadata(self) -> Dict[str, Any]: + return { + 'name': 'document_search_plugin', + 'type': 'search', + 'description': ( + 'Hybrid document search, exhaustive chunk retrieval, and hierarchical document summarization ' + 'for personal, group, and public workspaces.' + ), + 'methods': [ + { + 'name': 'search_documents', + 'description': 'Run relevance-ranked hybrid search and return chunk-level results with document ids.', + 'parameters': [ + {'name': 'query', 'type': 'str', 'description': 'Natural-language search query.', 'required': True}, + {'name': 'doc_scope', 'type': 'str', 'description': 'all, personal, group, or public.', 'required': False}, + {'name': 'top_n', 'type': 'int', 'description': 'Maximum number of results to return.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Search results with scope and document metadata.'}, + }, + { + 'name': 'retrieve_document_chunks', + 'description': 'Retrieve ordered chunks for one accessible document, optionally in windows.', + 'parameters': [ + {'name': 'document_id', 'type': 'str', 'description': 'Document id to retrieve.', 'required': True}, + {'name': 'doc_scope', 'type': 'str', 'description': 'all, personal, group, or public.', 'required': False}, + {'name': 'window_number', 'type': 'int', 'description': 'Optional 1-based window number to return.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Ordered chunks and window metadata.'}, + }, + { + 'name': 'summarize_document', + 'description': 'Summarize a document hierarchically across ordered chunk windows.', + 'parameters': [ + {'name': 'document_id', 'type': 'str', 'description': 'Document id to summarize.', 'required': True}, + {'name': 'focus_instructions', 'type': 'str', 'description': 'Optional focus areas to emphasize.', 'required': False}, + {'name': 'final_target_length', 'type': 'str', 'description': 'Desired final summary length.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Summary text plus stage and window metadata.'}, + }, + ], + } + + def _get_user_id(self): + user_id = get_current_user_id() + if not user_id: + raise RuntimeError('User context is unavailable for document search') + return user_id + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='search_documents', + description='Run hybrid document search over accessible workspaces and return chunk-level results with document ids.', + ) + def search_documents( + self, + query: Annotated[str, 'Natural-language query to run against accessible documents.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = 'all', + top_n: Annotated[int, 'Maximum number of chunk results to return.'] = 12, + document_ids: Annotated[str, 'Optional comma-separated document ids to restrict the search.'] = '', + tags_filter: Annotated[str, 'Optional comma-separated document tags that must all match.'] = '', + active_group_ids: Annotated[str, 'Optional comma-separated group ids when searching group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when searching public content.'] = '', + ) -> Annotated[dict, 'Search results and request metadata.']: + try: + return run_document_search( + query=query, + user_id=self._get_user_id(), + top_n=top_n, + doc_scope=doc_scope, + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + ) + except Exception as e: + return {'error': str(e)} + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='retrieve_document_chunks', + description='Retrieve ordered chunks for one accessible document, optionally selecting one window of chunks.', + ) + def retrieve_document_chunks( + self, + document_id: Annotated[str, 'Document id to retrieve chunk content from.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = 'all', + window_unit: Annotated[str, 'pages or chunks for chunk windowing.'] = 'pages', + window_size: Annotated[int, 'Optional explicit number of pages or chunks per window.'] = 0, + window_percent: Annotated[int, 'Optional percentage of the document to include per window.'] = 0, + window_number: Annotated[int, 'Optional 1-based window number to return instead of the full document.'] = 0, + active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', + ) -> Annotated[dict, 'Ordered chunks and window metadata for one document.']: + try: + return get_document_chunks_payload( + document_id=document_id, + user_id=self._get_user_id(), + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=window_unit, + window_size=window_size if int(window_size or 0) > 0 else None, + window_percent=window_percent if int(window_percent or 0) > 0 else None, + window_number=window_number if int(window_number or 0) > 0 else None, + ) + except Exception as e: + return {'error': str(e)} + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='summarize_document', + description='Summarize one accessible document hierarchically across ordered chunk windows, with optional focus guidance.', + ) + def summarize_document( + self, + document_id: Annotated[str, 'Document id to summarize.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = 'all', + focus_instructions: Annotated[str, 'Optional focus areas such as risks, deadlines, or architectural decisions.'] = '', + final_target_length: Annotated[str, 'Desired final summary length, for example 2 pages or 500 words.'] = '2 pages', + window_target_length: Annotated[str, 'Target length for each first-pass window summary.'] = '2 pages', + window_unit: Annotated[str, 'pages or chunks for chunk windowing.'] = 'pages', + window_size: Annotated[int, 'Optional explicit number of pages or chunks per window.'] = 0, + window_percent: Annotated[int, 'Optional percentage of the document to include per first-pass window.'] = 0, + active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', + ) -> Annotated[dict, 'Final summary text plus stage and window metadata.']: + try: + return summarize_document_content( + document_id=document_id, + user_id=self._get_user_id(), + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + focus_instructions=focus_instructions, + final_target_length=final_target_length, + window_target_length=window_target_length, + window_unit=window_unit, + window_size=window_size if int(window_size or 0) > 0 else None, + window_percent=window_percent if int(window_percent or 0) > 0 else None, + ) + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/docs/explanation/features/index.md b/docs/explanation/features/index.md index 800ebdae..208a69f1 100644 --- a/docs/explanation/features/index.md +++ b/docs/explanation/features/index.md @@ -13,4 +13,8 @@ category: Version History ## Admin Experience Features -- [AI Voice Conversations Setup Guide](AI_VOICE_CONVERSATIONS_SETUP_GUIDE.md) \ No newline at end of file +- [AI Voice Conversations Setup Guide](AI_VOICE_CONVERSATIONS_SETUP_GUIDE.md) + +## Versioned Features + +- [Core Document Search And Summarization](v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md) \ No newline at end of file diff --git a/docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md b/docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md new file mode 100644 index 00000000..7879fd0a --- /dev/null +++ b/docs/explanation/features/v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md @@ -0,0 +1,123 @@ +# Core Document Search And Summarization + +Version implemented: **0.241.007** + +## Overview and Purpose + +This feature adds a shared backend document-search service, a dedicated authenticated search API, and an always-loaded Semantic Kernel core plugin for search and summarization. + +The goal is to give agents and backend callers a common way to: + +- Run relevance-ranked hybrid search with a bounded, caller-overridable top-N. +- Retrieve ordered chunks for a specific document instead of relying only on distilled search hits. +- Generate hierarchical summaries across the full document by summarizing chunk windows and then reducing the intermediate summaries. + +Dependencies: + +- Azure AI Search chunk indexes for personal, group, and public workspaces +- Azure OpenAI chat model configuration for summary generation +- Semantic Kernel core plugin loading in the shared kernel loader + +## Technical Specifications + +### Architecture Overview + +The feature is split into three thin layers over one shared service: + +1. `functions_search.py` keeps hybrid search and now exposes shared top-N and scope normalization helpers. +2. `functions_search_service.py` resolves document scope, retrieves ordered chunks, builds chunk windows, and performs hierarchical summarization. +3. Adapters expose the shared service through: + - `route_backend_search.py` for authenticated backend APIs + - `semantic_kernel_plugins/document_search_plugin.py` for always-loaded Semantic Kernel access + +This keeps the HTTP API and the Semantic Kernel plugin behavior aligned and makes future comparison workflows easier to add. + +### API Endpoints + +The backend route module adds three authenticated endpoints: + +- `POST /api/search/documents` + - Runs hybrid search. + - Supports `query`, `doc_scope`, `top_n`, `document_id` or `document_ids`, optional tags, and optional scope hints. +- `POST /api/search/document-chunks` + - Resolves an accessible document and returns ordered chunks. + - Supports optional windowing by pages or chunks. +- `POST /api/search/document-summary` + - Resolves a document, retrieves its ordered chunks, windows the content, summarizes each window, and reduces the intermediate summaries into a final result. + - Supports optional focus instructions and target-length overrides. + +### Semantic Kernel Plugin Functions + +The core plugin is loaded for model-only and agent-backed kernel sessions and exposes three functions: + +- `search_documents` +- `retrieve_document_chunks` +- `summarize_document` + +The plugin resolves the current signed-in user at invocation time and calls the shared service directly rather than going through HTTP. + +### Summary Workflow + +The summarization workflow is hierarchical: + +1. Resolve an accessible document in personal, group, or public scope. +2. Retrieve ordered chunks for the document. +3. Build chunk windows by pages when page numbers are available, otherwise by chunk count. +4. Summarize each window to a configurable intermediate target. +5. Reduce intermediate summaries in batches until a single final summary remains or the configured reduction limit is reached. + +Default behavior keeps the first-pass window summaries and final summary at two pages unless the caller overrides those values. + +### File Structure + +- `application/single_app/functions_search.py` +- `application/single_app/functions_documents.py` +- `application/single_app/functions_search_service.py` +- `application/single_app/route_backend_search.py` +- `application/single_app/semantic_kernel_plugins/document_search_plugin.py` +- `application/single_app/semantic_kernel_loader.py` +- `functional_tests/test_document_search_api_and_plugin.py` + +## Usage Instructions + +### Backend API Usage + +Use the backend endpoints when server-side code or future UI features need direct access to search, chunk retrieval, or summarization without invoking an agent. + +Typical request patterns: + +- Search a workspace with default distilled behavior and an explicit `top_n`. +- Retrieve all ordered chunks for a document before downstream processing. +- Summarize a document with focus instructions such as risks, deadlines, implementation details, or policy requirements. + +### Agent Usage + +Agents can use the core plugin to: + +- Search for relevant chunks across accessible documents. +- Pull the full ordered chunk stream for one document when distilled search is not enough. +- Generate a hierarchical summary with caller-specified focus and length guidance. + +This is intended to improve document-grounded summarization today and to support future document-to-document or version-to-version comparison workflows without changing the core retrieval contract. + +## Testing and Validation + +### Test Coverage + +Functional coverage validates: + +- Shared search helper exposure and document-id propagation +- Shared search service entry points for search, retrieval, and summarization +- Backend route registration and authentication decorators +- Core plugin kernel-function contract +- Loader and Flask app registration + +Related test: + +- `functional_tests/test_document_search_api_and_plugin.py` + +### Known Limitations + +- Summaries are generated on demand and are not persisted or cached in this implementation. +- Very large documents can require multiple summarization stages and multiple model calls. +- Comparison workflows are not included yet, but the retrieval and scope-resolution shapes are designed to support them. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index da34cbad..18196cf5 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -4,6 +4,16 @@ This page tracks notable Simple Chat releases and organizes the detailed change For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.241.007)** + +#### New Features + +* **Core Document Search And Summarization** + * Added a shared backend document search service with a dedicated authenticated API for hybrid search, ordered document-chunk retrieval, and on-demand document summarization. + * Added an always-loaded Semantic Kernel core plugin so every agent and model-only kernel session can search accessible workspace documents, pull full ordered chunk windows for a document, and run hierarchical summarization with optional focus and target-length guidance. + * The new summarization flow can now work across the whole document instead of relying only on distilled top search hits, which improves long-document summarization and creates a reusable foundation for future document comparison workflows. + * (Ref: `functions_search.py`, `functions_search_service.py`, `functions_documents.py`, `route_backend_search.py`, `document_search_plugin.py`, `semantic_kernel_loader.py`) + ### **(v0.241.006)** #### Bug Fixes diff --git a/functional_tests/test_document_search_api_and_plugin.py b/functional_tests/test_document_search_api_and_plugin.py new file mode 100644 index 00000000..e6fe84a0 --- /dev/null +++ b/functional_tests/test_document_search_api_and_plugin.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# test_document_search_api_and_plugin.py +""" +Functional test for document search API and core Semantic Kernel plugin. +Version: 0.241.007 +Implemented in: 0.241.007 + +This test ensures that the shared search service, backend API, and always-loaded +Semantic Kernel document search plugin expose the expected contract for hybrid +search, ordered chunk retrieval, and hierarchical summarization. +""" + +import ast +import os +import sys + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APP_ROOT = os.path.join(REPO_ROOT, 'application', 'single_app') + + +def read_source(*relative_parts): + file_path = os.path.join(REPO_ROOT, *relative_parts) + with open(file_path, 'r', encoding='utf-8') as source_file: + return source_file.read() + + +def parse_module(*relative_parts): + source = read_source(*relative_parts) + return source, ast.parse(source) + + +def get_function_names(module_ast): + return { + node.name + for node in module_ast.body + if isinstance(node, ast.FunctionDef) + } + + +def get_class_method_names(module_ast, class_name): + for node in module_ast.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + return { + child.name + for child in node.body + if isinstance(child, ast.FunctionDef) + } + return set() + + +def test_functions_search_contract(): + print('🔍 Validating shared search helper contract...') + source, module_ast = parse_module('application', 'single_app', 'functions_search.py') + function_names = get_function_names(module_ast) + + required_functions = { + 'normalize_search_top_n', + 'normalize_search_scope', + 'normalize_search_id_list', + 'extract_search_results', + } + missing_functions = sorted(required_functions - function_names) + if missing_functions: + print(f'❌ Missing search helper functions: {missing_functions}') + return False + + required_snippets = [ + 'SEARCH_DEFAULT_TOP_N = 12', + 'SEARCH_MAX_TOP_N = 500', + '"document_id": r.get("document_id")', + 'select=get_search_select_fields("personal")', + 'select=get_search_select_fields("group")', + 'select=get_search_select_fields("public")', + ] + missing_snippets = [snippet for snippet in required_snippets if snippet not in source] + if missing_snippets: + print(f'❌ Missing search helper snippets: {missing_snippets}') + return False + + print('✅ Shared search helpers expose normalized top-n, scope, and document ids') + return True + + +def test_search_service_contract(): + print('\n🔍 Validating shared search service contract...') + source, module_ast = parse_module('application', 'single_app', 'functions_search_service.py') + function_names = get_function_names(module_ast) + + required_functions = { + 'resolve_document_context', + 'build_search_request', + 'search_documents', + 'build_document_chunk_windows', + 'get_document_chunks_payload', + 'summarize_document_content', + } + missing_functions = sorted(required_functions - function_names) + if missing_functions: + print(f'❌ Missing search service functions: {missing_functions}') + return False + + if 'SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET = "2 pages"' not in source: + print('❌ Missing hierarchical summary window target default') + return False + + if 'SUMMARY_DEFAULT_FINAL_TARGET = "2 pages"' not in source: + print('❌ Missing hierarchical summary final target default') + return False + + print('✅ Shared search service exposes retrieval and summarization entry points') + return True + + +def test_backend_search_routes(): + print('\n🔍 Validating backend search route contract...') + source, _ = parse_module('application', 'single_app', 'route_backend_search.py') + + required_routes = [ + "/api/search/documents", + "/api/search/document-chunks", + "/api/search/document-summary", + ] + missing_routes = [route for route in required_routes if route not in source] + if missing_routes: + print(f'❌ Missing backend search routes: {missing_routes}') + return False + + required_decorators = [ + '@swagger_route(security=get_auth_security())', + '@login_required', + '@user_required', + ] + missing_decorators = [decorator for decorator in required_decorators if decorator not in source] + if missing_decorators: + print(f'❌ Missing backend route decorators: {missing_decorators}') + return False + + print('✅ Backend search routes expose authenticated search, chunk retrieval, and summary endpoints') + return True + + +def test_document_search_plugin_contract(): + print('\n🔍 Validating document search plugin contract...') + source, module_ast = parse_module('application', 'single_app', 'semantic_kernel_plugins', 'document_search_plugin.py') + method_names = get_class_method_names(module_ast, 'DocumentSearchPlugin') + + required_methods = { + 'search_documents', + 'retrieve_document_chunks', + 'summarize_document', + } + missing_methods = sorted(required_methods - method_names) + if missing_methods: + print(f'❌ Missing plugin methods: {missing_methods}') + return False + + required_kernel_names = [ + "name='search_documents'", + "name='retrieve_document_chunks'", + "name='summarize_document'", + ] + missing_kernel_names = [value for value in required_kernel_names if value not in source] + if missing_kernel_names: + print(f'❌ Missing kernel function names: {missing_kernel_names}') + return False + + print('✅ Document search plugin exposes the expected kernel functions') + return True + + +def test_loader_and_app_registration(): + print('\n🔍 Validating loader and app registration...') + loader_source = read_source('application', 'single_app', 'semantic_kernel_loader.py') + app_source = read_source('application', 'single_app', 'app.py') + + if 'from semantic_kernel_plugins.document_search_plugin import DocumentSearchPlugin' not in loader_source: + print('❌ Semantic Kernel loader does not import DocumentSearchPlugin') + return False + + loader_registration_count = loader_source.count('load_document_search_plugin(kernel)') + if loader_registration_count < 2: + print(f'❌ Expected document search plugin to be loaded in both SK paths, found {loader_registration_count}') + return False + + if 'from route_backend_search import *' not in app_source: + print('❌ Flask app does not import route_backend_search') + return False + + if 'register_route_backend_search(app)' not in app_source: + print('❌ Flask app does not register route_backend_search') + return False + + print('✅ Loader and app register the core plugin and backend routes') + return True + + +def main(): + print('🧪 Running Document Search API And Plugin Tests') + print('=' * 64) + + tests = [ + test_functions_search_contract, + test_search_service_contract, + test_backend_search_routes, + test_document_search_plugin_contract, + test_loader_and_app_registration, + ] + + results = [] + for test in tests: + try: + results.append(test()) + except Exception as e: + print(f'❌ {test.__name__} raised an exception: {e}') + results.append(False) + + passed_count = sum(results) + total_count = len(results) + + print('\n' + '=' * 64) + print(f'📊 Results: {passed_count}/{total_count} tests passed') + + return passed_count == total_count + + +if __name__ == '__main__': + if APP_ROOT not in sys.path: + sys.path.insert(0, APP_ROOT) + success = main() + sys.exit(0 if success else 1) \ No newline at end of file From 9bded1e913b87421d681f9420381c65449e65775 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 10:17:16 -0400 Subject: [PATCH 02/28] initial --- application/single_app/app.py | 4 + .../single_app/collaboration_models.py | 619 +++++++++ application/single_app/config.py | 20 +- .../single_app/functions_collaboration.py | 1019 ++++++++++++++ application/single_app/functions_settings.py | 3 + .../single_app/route_backend_collaboration.py | 1021 ++++++++++++++ .../route_backend_conversation_export.py | 66 +- .../single_app/route_backend_thoughts.py | 39 + application/single_app/route_backend_users.py | 82 ++ .../route_frontend_conversations.py | 24 +- application/single_app/static/css/chats.css | 49 + .../static/js/chat/chat-collaboration.js | 1181 +++++++++++++++++ .../js/chat/chat-conversation-details.js | 378 +++++- .../static/js/chat/chat-conversations.js | 557 +++++++- .../single_app/static/js/chat/chat-export.js | 23 +- .../static/js/chat/chat-messages.js | 52 +- .../js/chat/chat-sidebar-conversations.js | 141 +- application/single_app/templates/chats.html | 98 +- .../COLLABORATIVE_CONVERSATIONS_FOUNDATION.md | 108 ++ .../COLLABORATIVE_CONVERSATION_UI_UPGRADE.md | 88 ++ ...ABORATION_INVITE_ACCESS_AND_ACTIONS_FIX.md | 60 + ...ABORATION_PARTICIPANT_PICKER_HELPER_FIX.md | 54 + ...TION_SHARED_CONVERSATION_MANAGEMENT_FIX.md | 78 ++ .../COLLABORATION_TYPING_DEACTIVATION_FIX.md | 55 + docs/explanation/release_notes.md | 6 + ...aboration_invite_access_and_actions_fix.py | 57 + ...collaboration_legacy_message_conversion.py | 139 ++ ...tion_shared_conversation_management_fix.py | 87 ++ ...t_collaborative_conversation_foundation.py | 158 +++ .../test_chat_collaboration_ui_scaffolding.py | 214 +++ 30 files changed, 6339 insertions(+), 141 deletions(-) create mode 100644 application/single_app/collaboration_models.py create mode 100644 application/single_app/functions_collaboration.py create mode 100644 application/single_app/route_backend_collaboration.py create mode 100644 application/single_app/static/js/chat/chat-collaboration.js create mode 100644 docs/explanation/features/COLLABORATIVE_CONVERSATIONS_FOUNDATION.md create mode 100644 docs/explanation/features/COLLABORATIVE_CONVERSATION_UI_UPGRADE.md create mode 100644 docs/explanation/fixes/COLLABORATION_INVITE_ACCESS_AND_ACTIONS_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_PARTICIPANT_PICKER_HELPER_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_SHARED_CONVERSATION_MANAGEMENT_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_TYPING_DEACTIVATION_FIX.md create mode 100644 functional_tests/test_collaboration_invite_access_and_actions_fix.py create mode 100644 functional_tests/test_collaboration_legacy_message_conversion.py create mode 100644 functional_tests/test_collaboration_shared_conversation_management_fix.py create mode 100644 functional_tests/test_collaborative_conversation_foundation.py create mode 100644 ui_tests/test_chat_collaboration_ui_scaffolding.py diff --git a/application/single_app/app.py b/application/single_app/app.py index 0ab62afb..ac1c7626 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -80,6 +80,7 @@ from route_backend_thoughts import register_route_backend_thoughts from route_backend_speech import register_route_backend_speech from route_backend_tts import register_route_backend_tts +from route_backend_collaboration import register_route_backend_collaboration from route_enhanced_citations import register_enhanced_citations_routes from plugin_validation_endpoint import plugin_validation_bp from route_openapi import register_openapi_routes @@ -880,6 +881,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Conversation Routes ------------ register_route_backend_conversations(app) +# ------------------- API Collaboration Routes ----------- +register_route_backend_collaboration(app) + # ------------------- API Documents Routes --------------- register_route_backend_documents(app) diff --git a/application/single_app/collaboration_models.py b/application/single_app/collaboration_models.py new file mode 100644 index 00000000..ecc4aacb --- /dev/null +++ b/application/single_app/collaboration_models.py @@ -0,0 +1,619 @@ +# collaboration_models.py + +"""Pure collaboration data-model helpers for multi-user conversations.""" + +from datetime import datetime, timedelta +import uuid + + +COLLABORATION_KIND = 'collaborative' + +PERSONAL_MULTI_USER_CHAT_TYPE = 'personal_multi_user' +GROUP_MULTI_USER_CHAT_TYPE = 'group_multi_user' + +MEMBERSHIP_STATUS_ACCEPTED = 'accepted' +MEMBERSHIP_STATUS_PENDING = 'pending' +MEMBERSHIP_STATUS_DECLINED = 'declined' +MEMBERSHIP_STATUS_REMOVED = 'removed' + +MEMBERSHIP_ROLE_OWNER = 'owner' +MEMBERSHIP_ROLE_ADMIN = 'admin' +MEMBERSHIP_ROLE_MEMBER = 'member' + +MESSAGE_KIND_HUMAN = 'human_message' +MESSAGE_KIND_AI_REQUEST = 'ai_request' +MESSAGE_KIND_ASSISTANT = 'assistant_response' + +DEFAULT_PERSONAL_COLLABORATION_TITLE = 'New Collaborative Conversation' +DEFAULT_GROUP_COLLABORATION_TITLE = 'New Group Collaborative Conversation' + + +def utc_now_iso(): + return datetime.utcnow().isoformat() + + +def add_seconds_to_iso(timestamp, seconds): + base_timestamp = datetime.fromisoformat(str(timestamp or utc_now_iso())) + return (base_timestamp + timedelta(seconds=int(seconds or 0))).isoformat() + + +def _clean_string(value): + return str(value or '').strip() + + +def normalize_collaboration_user(raw_user, fallback_user_id=None): + raw_user = raw_user or {} + if not isinstance(raw_user, dict): + raw_user = {} + + user_id = _clean_string( + raw_user.get('user_id') + or raw_user.get('userId') + or raw_user.get('id') + or fallback_user_id + ) + if not user_id: + return None + + display_name = _clean_string( + raw_user.get('display_name') + or raw_user.get('displayName') + or raw_user.get('name') + or raw_user.get('username') + ) + email = _clean_string( + raw_user.get('email') + or raw_user.get('mail') + or raw_user.get('userPrincipalName') + ) + + return { + 'user_id': user_id, + 'display_name': display_name or email or 'Unknown User', + 'email': email, + } + + +def build_collaboration_context(scope_type, scope_id, scope_name): + return [ + { + 'type': 'primary', + 'scope': _clean_string(scope_type), + 'id': _clean_string(scope_id), + 'name': _clean_string(scope_name), + } + ] + + +def build_default_collaboration_title(conversation_type, group_name=''): + normalized_type = _clean_string(conversation_type).lower() + normalized_group_name = _clean_string(group_name) + if normalized_type == 'group': + if normalized_group_name: + return f'{normalized_group_name} collaborative conversation' + return DEFAULT_GROUP_COLLABORATION_TITLE + return DEFAULT_PERSONAL_COLLABORATION_TITLE + + +def refresh_personal_participant_indexes(conversation_doc): + participants = conversation_doc.setdefault('participants', []) + accepted_participant_ids = [] + pending_participant_ids = [] + owner_user_ids = [] + admin_user_ids = [] + + for participant in participants: + participant_user_id = _clean_string(participant.get('user_id')) + participant_status = _clean_string(participant.get('status')) + participant_role = _clean_string(participant.get('role')) + + if not participant_user_id: + continue + + if participant_status == MEMBERSHIP_STATUS_ACCEPTED: + if participant_user_id not in accepted_participant_ids: + accepted_participant_ids.append(participant_user_id) + if participant_role == MEMBERSHIP_ROLE_OWNER and participant_user_id not in owner_user_ids: + owner_user_ids.append(participant_user_id) + if participant_role == MEMBERSHIP_ROLE_ADMIN and participant_user_id not in admin_user_ids: + admin_user_ids.append(participant_user_id) + elif participant_status == MEMBERSHIP_STATUS_PENDING: + if participant_user_id not in pending_participant_ids: + pending_participant_ids.append(participant_user_id) + + conversation_doc['accepted_participant_ids'] = accepted_participant_ids + conversation_doc['pending_participant_ids'] = pending_participant_ids + conversation_doc['owner_user_ids'] = owner_user_ids + conversation_doc['admin_user_ids'] = admin_user_ids + conversation_doc['participant_count'] = len(accepted_participant_ids) + conversation_doc['pending_invite_count'] = len(pending_participant_ids) + return conversation_doc + + +def build_personal_collaboration_conversation( + title, + creator_user, + invited_participants=None, + conversation_id=None, + created_at=None, +): + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_id) or str(uuid.uuid4()) + creator_summary = normalize_collaboration_user(creator_user) + if not creator_summary: + raise ValueError('creator_user is required') + + conversation_title = _clean_string(title) or build_default_collaboration_title('personal') + + participants = [ + { + 'user_id': creator_summary['user_id'], + 'display_name': creator_summary['display_name'], + 'email': creator_summary['email'], + 'role': MEMBERSHIP_ROLE_OWNER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': created_at, + 'joined_at': created_at, + } + ] + existing_user_ids = {creator_summary['user_id']} + + for raw_participant in invited_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + if participant_summary['user_id'] in existing_user_ids: + continue + + existing_user_ids.add(participant_summary['user_id']) + participants.append({ + 'user_id': participant_summary['user_id'], + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': created_at, + }) + + conversation_doc = { + 'id': conversation_id, + 'conversation_kind': COLLABORATION_KIND, + 'chat_type': PERSONAL_MULTI_USER_CHAT_TYPE, + 'title': conversation_title, + 'created_at': created_at, + 'updated_at': created_at, + 'last_message_at': None, + 'last_message_preview': '', + 'status': 'active', + 'created_by_user_id': creator_summary['user_id'], + 'created_by_display_name': creator_summary['display_name'], + 'scope': { + 'type': 'personal', + 'group_id': None, + 'group_name': None, + 'visibility_mode': 'invited_members', + 'allowed_scope_types': ['personal', 'public'], + }, + 'context': build_collaboration_context( + 'personal', + creator_summary['user_id'], + creator_summary['display_name'], + ), + 'scope_locked': True, + 'locked_contexts': [{'scope': 'personal', 'id': creator_summary['user_id']}], + 'participants': participants, + 'conversation_settings': { + 'ai_invocation_mode': 'explicit_only', + 'reply_mode_enabled': True, + }, + 'message_count': 0, + 'tags': [], + } + return refresh_personal_participant_indexes(conversation_doc) + + +def build_group_collaboration_conversation( + title, + creator_user, + group_id, + group_name, + conversation_id=None, + created_at=None, +): + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_id) or str(uuid.uuid4()) + creator_summary = normalize_collaboration_user(creator_user) + if not creator_summary: + raise ValueError('creator_user is required') + + normalized_group_id = _clean_string(group_id) + if not normalized_group_id: + raise ValueError('group_id is required') + + normalized_group_name = _clean_string(group_name) or 'Group Workspace' + conversation_title = _clean_string(title) or build_default_collaboration_title( + 'group', + normalized_group_name, + ) + + return { + 'id': conversation_id, + 'conversation_kind': COLLABORATION_KIND, + 'chat_type': GROUP_MULTI_USER_CHAT_TYPE, + 'title': conversation_title, + 'created_at': created_at, + 'updated_at': created_at, + 'last_message_at': None, + 'last_message_preview': '', + 'status': 'active', + 'created_by_user_id': creator_summary['user_id'], + 'created_by_display_name': creator_summary['display_name'], + 'owner_user_ids': [creator_summary['user_id']], + 'accepted_participant_ids': [creator_summary['user_id']], + 'pending_participant_ids': [], + 'participant_count': 1, + 'pending_invite_count': 0, + 'scope': { + 'type': 'group', + 'group_id': normalized_group_id, + 'group_name': normalized_group_name, + 'visibility_mode': 'group_membership', + 'allowed_scope_types': ['group', 'public'], + }, + 'context': build_collaboration_context('group', normalized_group_id, normalized_group_name), + 'scope_locked': True, + 'locked_contexts': [{'scope': 'group', 'id': normalized_group_id}], + 'participants': [ + { + 'user_id': creator_summary['user_id'], + 'display_name': creator_summary['display_name'], + 'email': creator_summary['email'], + 'role': MEMBERSHIP_ROLE_OWNER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': created_at, + 'joined_at': created_at, + } + ], + 'conversation_settings': { + 'ai_invocation_mode': 'explicit_only', + 'reply_mode_enabled': True, + }, + 'message_count': 0, + 'tags': [], + } + + +def get_collaboration_user_state_doc_id(user_id, conversation_id): + return f'{_clean_string(user_id)}:{_clean_string(conversation_id)}' + + +def build_collaboration_user_state( + conversation_doc, + user_summary, + role, + membership_status, + invited_by_user_id=None, + created_at=None, +): + normalized_user = normalize_collaboration_user(user_summary) + if not normalized_user: + raise ValueError('user_summary is required') + + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_doc.get('id')) + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + + state_doc = { + 'id': get_collaboration_user_state_doc_id(normalized_user['user_id'], conversation_id), + 'conversation_kind': COLLABORATION_KIND, + 'conversation_id': conversation_id, + 'user_id': normalized_user['user_id'], + 'user_display_name': normalized_user['display_name'], + 'user_email': normalized_user['email'], + 'chat_type': conversation_doc.get('chat_type'), + 'scope_type': scope.get('type'), + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'title_snapshot': conversation_doc.get('title'), + 'role': _clean_string(role) or MEMBERSHIP_ROLE_MEMBER, + 'membership_status': _clean_string(membership_status) or MEMBERSHIP_STATUS_PENDING, + 'invited_by_user_id': _clean_string(invited_by_user_id), + 'created_at': created_at, + 'updated_at': created_at, + 'last_read_message_id': None, + 'last_read_at': None, + 'last_seen_at': None, + 'is_hidden': False, + 'is_pinned': False, + } + + if state_doc['membership_status'] == MEMBERSHIP_STATUS_ACCEPTED: + state_doc['joined_at'] = created_at + + return state_doc + + +def apply_personal_invite_response(conversation_doc, invited_user_id, action, responded_at=None): + normalized_user_id = _clean_string(invited_user_id) + normalized_action = _clean_string(action).lower() + if normalized_action not in ('accept', 'decline'): + raise ValueError('action must be accept or decline') + + responded_at = _clean_string(responded_at) or utc_now_iso() + + participant_record = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user_id: + continue + + if _clean_string(participant.get('status')) != MEMBERSHIP_STATUS_PENDING: + raise ValueError('participant invite is not pending') + + participant_record = participant + if normalized_action == 'accept': + participant['status'] = MEMBERSHIP_STATUS_ACCEPTED + participant['joined_at'] = responded_at + else: + participant['status'] = MEMBERSHIP_STATUS_DECLINED + participant['responded_at'] = responded_at + break + + if participant_record is None: + raise LookupError('pending participant not found') + + conversation_doc['updated_at'] = responded_at + refresh_personal_participant_indexes(conversation_doc) + return participant_record + + +def add_personal_pending_participants(conversation_doc, new_participants, invited_at=None): + invited_at = _clean_string(invited_at) or utc_now_iso() + existing_by_user_id = { + _clean_string(participant.get('user_id')): participant + for participant in conversation_doc.get('participants', []) + if _clean_string(participant.get('user_id')) + } + + added_participants = [] + for raw_participant in new_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + + participant_user_id = participant_summary['user_id'] + existing_participant = existing_by_user_id.get(participant_user_id) + if existing_participant: + existing_status = _clean_string(existing_participant.get('status')) + if existing_status in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + + existing_participant['display_name'] = participant_summary['display_name'] + existing_participant['email'] = participant_summary['email'] + existing_participant['status'] = MEMBERSHIP_STATUS_PENDING + existing_participant['role'] = MEMBERSHIP_ROLE_MEMBER + existing_participant['invited_at'] = invited_at + existing_participant.pop('joined_at', None) + existing_participant.pop('removed_at', None) + existing_participant.pop('responded_at', None) + added_participants.append(existing_participant) + continue + + participant_record = { + 'user_id': participant_user_id, + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': invited_at, + } + conversation_doc.setdefault('participants', []).append(participant_record) + existing_by_user_id[participant_user_id] = participant_record + added_participants.append(participant_record) + + if added_participants: + conversation_doc['updated_at'] = invited_at + refresh_personal_participant_indexes(conversation_doc) + + return added_participants + + +def remove_personal_participant(conversation_doc, participant_user_id, removed_at=None): + normalized_user_id = _clean_string(participant_user_id) + removed_at = _clean_string(removed_at) or utc_now_iso() + owner_ids = set(conversation_doc.get('owner_user_ids', []) or []) + if normalized_user_id in owner_ids: + raise ValueError('owners cannot be removed from personal collaborative conversations') + + removed_participant = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user_id: + continue + + participant['status'] = MEMBERSHIP_STATUS_REMOVED + participant['removed_at'] = removed_at + removed_participant = participant + break + + if removed_participant is None: + raise LookupError('participant not found') + + conversation_doc['updated_at'] = removed_at + refresh_personal_participant_indexes(conversation_doc) + return removed_participant + + +def ensure_group_participant_record(conversation_doc, user_summary, joined_at=None): + joined_at = _clean_string(joined_at) or utc_now_iso() + normalized_user = normalize_collaboration_user(user_summary) + if not normalized_user: + raise ValueError('user_summary is required') + + participant_record = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user['user_id']: + continue + + participant['display_name'] = normalized_user['display_name'] + participant['email'] = normalized_user['email'] + participant['status'] = MEMBERSHIP_STATUS_ACCEPTED + participant.setdefault('joined_at', joined_at) + participant_record = participant + break + + if participant_record is None: + participant_record = { + 'user_id': normalized_user['user_id'], + 'display_name': normalized_user['display_name'], + 'email': normalized_user['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': joined_at, + 'joined_at': joined_at, + } + conversation_doc.setdefault('participants', []).append(participant_record) + + accepted_participant_ids = list(conversation_doc.get('accepted_participant_ids', []) or []) + if normalized_user['user_id'] not in accepted_participant_ids: + accepted_participant_ids.append(normalized_user['user_id']) + conversation_doc['accepted_participant_ids'] = accepted_participant_ids + conversation_doc['participant_count'] = len(accepted_participant_ids) + conversation_doc['updated_at'] = joined_at + return participant_record + + +def _truncate_preview(content, max_length=160): + content = _clean_string(content) + if len(content) <= int(max_length): + return content + return f"{content[: int(max_length) - 3]}..." + + +def build_collaboration_message_doc( + conversation_id, + sender_user, + content, + reply_to_message_id=None, + message_kind=MESSAGE_KIND_HUMAN, + message_id=None, + timestamp=None, +): + normalized_sender = normalize_collaboration_user(sender_user) + if not normalized_sender: + raise ValueError('sender_user is required') + + normalized_conversation_id = _clean_string(conversation_id) + if not normalized_conversation_id: + raise ValueError('conversation_id is required') + + normalized_content = str(content or '') + normalized_timestamp = _clean_string(timestamp) or utc_now_iso() + normalized_message_kind = _clean_string(message_kind) or MESSAGE_KIND_HUMAN + + if normalized_message_kind == MESSAGE_KIND_ASSISTANT: + role = 'assistant' + else: + role = 'user' + + return { + 'id': _clean_string(message_id) or f'{normalized_conversation_id}_{uuid.uuid4().hex}', + 'conversation_id': normalized_conversation_id, + 'role': role, + 'message_kind': normalized_message_kind, + 'content': normalized_content, + 'reply_to_message_id': _clean_string(reply_to_message_id) or None, + 'timestamp': normalized_timestamp, + 'metadata': { + 'sender': normalized_sender, + 'explicit_ai_invocation': normalized_message_kind == MESSAGE_KIND_AI_REQUEST, + 'last_message_preview': _truncate_preview(normalized_content), + }, + } + + +def build_collaboration_message_doc_from_legacy( + conversation_id, + legacy_message, + default_sender_user, +): + legacy_message = legacy_message or {} + legacy_role = _clean_string(legacy_message.get('role')).lower() + legacy_metadata = legacy_message.get('metadata', {}) if isinstance(legacy_message.get('metadata'), dict) else {} + + if legacy_role in ('assistant_artifact', 'assistant_artifact_chunk'): + return None + + content = str(legacy_message.get('content') or '') + message_kind = MESSAGE_KIND_HUMAN + sender_user = normalize_collaboration_user( + legacy_metadata.get('user_info') or default_sender_user, + ) + + if legacy_role == 'assistant': + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': _clean_string(legacy_message.get('agent_display_name')) or 'AI', + 'email': '', + } + elif legacy_role == 'safety': + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': 'Content Safety', + 'email': '', + } + elif legacy_role == 'file': + filename = _clean_string(legacy_message.get('filename')) or 'file' + content = f'[File shared] {filename}' + elif legacy_role == 'image': + is_user_upload = bool(legacy_metadata.get('is_user_upload')) + if is_user_upload: + filename = _clean_string(legacy_message.get('filename')) or 'image' + content = f'[Uploaded image] {filename}' + else: + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': _clean_string(legacy_message.get('agent_display_name')) or 'AI', + 'email': '', + } + image_url = _clean_string(legacy_message.get('content')) + content = f'[Generated image] {image_url}' if image_url else '[Generated image]' + elif legacy_role not in ('user', '') and not content.strip(): + return None + + if not sender_user: + return None + + collaboration_message = build_collaboration_message_doc( + conversation_id=conversation_id, + sender_user=sender_user, + content=content, + reply_to_message_id=legacy_message.get('reply_to_message_id'), + message_kind=message_kind, + timestamp=legacy_message.get('timestamp'), + ) + + collaboration_message['metadata']['source_message_id'] = _clean_string(legacy_message.get('id')) or None + collaboration_message['metadata']['source_role'] = legacy_role or None + if legacy_role == 'image': + collaboration_message['metadata']['legacy_image_url'] = _clean_string(legacy_message.get('content')) or None + if legacy_role == 'file': + collaboration_message['metadata']['legacy_filename'] = _clean_string(legacy_message.get('filename')) or None + + for optional_key in ( + 'model_deployment_name', + 'augmented', + 'hybrid_citations', + 'web_search_citations', + 'agent_citations', + 'agent_display_name', + 'agent_name', + 'extracted_text', + 'vision_analysis', + 'filename', + ): + if optional_key in legacy_message: + collaboration_message[optional_key] = legacy_message.get(optional_key) + + return collaboration_message \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index 3ccb6ca9..cb16bfd4 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.007" +VERSION = "0.241.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -320,6 +320,24 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/conversation_id") ) +cosmos_collaboration_conversations_container_name = "collaboration_conversations" +cosmos_collaboration_conversations_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_conversations_container_name, + partition_key=PartitionKey(path="/id") +) + +cosmos_collaboration_messages_container_name = "collaboration_messages" +cosmos_collaboration_messages_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_messages_container_name, + partition_key=PartitionKey(path="/conversation_id") +) + +cosmos_collaboration_user_state_container_name = "collaboration_user_state" +cosmos_collaboration_user_state_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_user_state_container_name, + partition_key=PartitionKey(path="/user_id") +) + cosmos_settings_container_name = "settings" cosmos_settings_container = cosmos_database.create_container_if_not_exists( id=cosmos_settings_container_name, diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py new file mode 100644 index 00000000..e081089e --- /dev/null +++ b/application/single_app/functions_collaboration.py @@ -0,0 +1,1019 @@ +# functions_collaboration.py + +"""Persistence, authorization, and serialization helpers for collaborative conversations.""" + +from config import * +from collaboration_models import ( + COLLABORATION_KIND, + GROUP_MULTI_USER_CHAT_TYPE, + MEMBERSHIP_ROLE_ADMIN, + MEMBERSHIP_ROLE_MEMBER, + MEMBERSHIP_ROLE_OWNER, + MEMBERSHIP_STATUS_ACCEPTED, + MEMBERSHIP_STATUS_DECLINED, + MEMBERSHIP_STATUS_PENDING, + MEMBERSHIP_STATUS_REMOVED, + MESSAGE_KIND_HUMAN, + PERSONAL_MULTI_USER_CHAT_TYPE, + add_personal_pending_participants, + apply_personal_invite_response, + build_collaboration_message_doc, + build_collaboration_message_doc_from_legacy, + build_collaboration_user_state, + build_group_collaboration_conversation, + build_personal_collaboration_conversation, + ensure_group_participant_record, + get_collaboration_user_state_doc_id, + refresh_personal_participant_indexes, + remove_personal_participant, + utc_now_iso, +) +from functions_appinsights import log_event +from functions_group import ( + assert_group_role, + check_group_status_allows_operation, + find_group_by_id, + get_user_groups, +) +from functions_message_artifacts import filter_assistant_artifact_items +from functions_thoughts import delete_thoughts_for_conversation, get_thoughts_for_message + + +PERSONAL_COLLABORATION_MANAGER_ROLES = { + MEMBERSHIP_ROLE_OWNER, + MEMBERSHIP_ROLE_ADMIN, +} + + +def is_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('conversation_kind') == COLLABORATION_KIND) + + +def is_personal_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('chat_type') == PERSONAL_MULTI_USER_CHAT_TYPE) + + +def is_group_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('chat_type') == GROUP_MULTI_USER_CHAT_TYPE) + + +def get_collaboration_conversation(conversation_id): + return cosmos_collaboration_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + + +def get_collaboration_user_state(user_id, conversation_id): + return cosmos_collaboration_user_state_container.read_item( + item=get_collaboration_user_state_doc_id(user_id, conversation_id), + partition_key=user_id, + ) + + +def get_collaboration_message(message_id): + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': message_id}], + enable_cross_partition_query=True, + )) + if not items: + raise CosmosResourceNotFoundError(message='Collaborative message not found') + return items[0] + + +def get_personal_collaboration_participant(conversation_doc, participant_user_id): + normalized_user_id = str(participant_user_id or '').strip() + for participant in list((conversation_doc or {}).get('participants', []) or []): + if str(participant.get('user_id') or '').strip() == normalized_user_id: + return participant + return None + + +def get_personal_collaboration_role(conversation_doc, participant_user_id, user_state=None): + if user_state and str(user_state.get('role') or '').strip(): + return str(user_state.get('role') or '').strip() + + participant = get_personal_collaboration_participant(conversation_doc, participant_user_id) + if not participant: + return '' + return str(participant.get('role') or '').strip() + + +def serialize_collaboration_message(message_doc): + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + return { + 'id': message_doc.get('id'), + 'conversation_id': message_doc.get('conversation_id'), + 'role': message_doc.get('role'), + 'message_kind': message_doc.get('message_kind', MESSAGE_KIND_HUMAN), + 'content': message_doc.get('content', ''), + 'reply_to_message_id': message_doc.get('reply_to_message_id'), + 'timestamp': message_doc.get('timestamp'), + 'sender': metadata.get('sender', {}), + 'metadata': metadata, + 'explicit_ai_invocation': bool(metadata.get('explicit_ai_invocation', False)), + 'model_deployment_name': message_doc.get('model_deployment_name'), + 'augmented': bool(message_doc.get('augmented', False)), + 'hybrid_citations': list(message_doc.get('hybrid_citations', []) or []), + 'web_search_citations': list(message_doc.get('web_search_citations', []) or []), + 'agent_citations': list(message_doc.get('agent_citations', []) or []), + 'agent_display_name': message_doc.get('agent_display_name'), + 'agent_name': message_doc.get('agent_name'), + } + + +def serialize_collaboration_conversation(conversation_doc, current_user_id, user_state=None): + conversation_doc = conversation_doc or {} + participants = list(conversation_doc.get('participants', []) or []) + membership_status = None + if user_state: + membership_status = user_state.get('membership_status') + elif current_user_id in set(conversation_doc.get('accepted_participant_ids', []) or []): + membership_status = MEMBERSHIP_STATUS_ACCEPTED + elif is_group_collaboration_conversation(conversation_doc): + membership_status = 'group_member' + + owner_user_ids = list(conversation_doc.get('owner_user_ids', []) or []) + admin_user_ids = list(conversation_doc.get('admin_user_ids', []) or []) + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc.get('scope'), dict) else {} + current_user_role = get_personal_collaboration_role( + conversation_doc, + current_user_id, + user_state=user_state, + ) + can_manage_members = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role in PERSONAL_COLLABORATION_MANAGER_ROLES + ) + can_manage_roles = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role == MEMBERSHIP_ROLE_OWNER + ) + can_accept_invite = membership_status == MEMBERSHIP_STATUS_PENDING + can_post_messages = bool( + is_group_collaboration_conversation(conversation_doc) + or membership_status == MEMBERSHIP_STATUS_ACCEPTED + ) + can_delete_conversation = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role == MEMBERSHIP_ROLE_OWNER + ) + can_leave_conversation = bool( + is_personal_collaboration_conversation(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + ) + + return { + 'id': conversation_doc.get('id'), + 'title': conversation_doc.get('title', ''), + 'conversation_kind': conversation_doc.get('conversation_kind'), + 'chat_type': conversation_doc.get('chat_type'), + 'status': conversation_doc.get('status', 'active'), + 'created_at': conversation_doc.get('created_at'), + 'updated_at': conversation_doc.get('updated_at'), + 'last_message_at': conversation_doc.get('last_message_at'), + 'last_message_preview': conversation_doc.get('last_message_preview', ''), + 'message_count': conversation_doc.get('message_count', 0), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + 'participants': participants, + 'accepted_participant_ids': list(conversation_doc.get('accepted_participant_ids', []) or []), + 'pending_participant_ids': list(conversation_doc.get('pending_participant_ids', []) or []), + 'owner_user_ids': owner_user_ids, + 'admin_user_ids': admin_user_ids, + 'current_user_role': current_user_role, + 'membership_status': membership_status, + 'can_manage_members': can_manage_members, + 'can_manage_roles': can_manage_roles, + 'can_accept_invite': can_accept_invite, + 'can_post_messages': can_post_messages, + 'can_delete_conversation': can_delete_conversation, + 'can_leave_conversation': can_leave_conversation, + 'scope': scope, + 'context': list(conversation_doc.get('context', []) or []), + 'scope_locked': conversation_doc.get('scope_locked', True), + 'locked_contexts': list(conversation_doc.get('locked_contexts', []) or []), + 'conversation_settings': dict(conversation_doc.get('conversation_settings', {}) or {}), + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'last_updated': conversation_doc.get('updated_at'), + 'is_pinned': bool((user_state or {}).get('is_pinned', False)), + 'is_hidden': bool((user_state or {}).get('is_hidden', False)), + 'classification': list(conversation_doc.get('classification', []) or []), + 'tags': list(conversation_doc.get('tags', []) or []), + 'strict': bool(conversation_doc.get('strict', False)), + 'summary': conversation_doc.get('summary'), + 'has_unread_assistant_response': False, + 'last_unread_assistant_message_id': None, + 'last_unread_assistant_at': None, + 'user_id': conversation_doc.get('created_by_user_id'), + 'source_conversation_id': conversation_doc.get('source_conversation_id'), + } + + +def get_personal_collaboration_conversation_by_source_conversation(source_conversation_id): + query = ( + 'SELECT TOP 1 * FROM c WHERE c.conversation_kind = @conversation_kind ' + 'AND c.chat_type = @chat_type AND c.source_conversation_id = @source_conversation_id' + ) + items = list(cosmos_collaboration_conversations_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + {'name': '@chat_type', 'value': PERSONAL_MULTI_USER_CHAT_TYPE}, + {'name': '@source_conversation_id', 'value': source_conversation_id}, + ], + enable_cross_partition_query=True, + )) + return items[0] if items else None + + +def _is_eligible_legacy_personal_conversation(source_conversation_doc): + chat_type = str(source_conversation_doc.get('chat_type') or '').strip().lower() + if chat_type.startswith('group') or chat_type.startswith('public'): + return False + + primary_context = next( + ( + context_item + for context_item in list(source_conversation_doc.get('context', []) or []) + if context_item.get('type') == 'primary' + ), + None, + ) + if primary_context and str(primary_context.get('scope') or '').strip().lower() in ('group', 'public'): + return False + + return True + + +def _copy_legacy_personal_messages_to_collaboration(source_conversation_id, collaboration_conversation_id, owner_user): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + raw_messages = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + raw_messages = filter_assistant_artifact_items(raw_messages) + + copied_messages = [] + for raw_message in raw_messages: + collaboration_message = build_collaboration_message_doc_from_legacy( + collaboration_conversation_id, + raw_message, + owner_user, + ) + if not collaboration_message: + continue + + metadata = collaboration_message.setdefault('metadata', {}) + metadata.setdefault('source_message_id', raw_message.get('id')) + metadata.setdefault('source_conversation_id', source_conversation_id) + metadata.setdefault('source_thought_user_id', str((owner_user or {}).get('user_id') or '').strip()) + + cosmos_collaboration_messages_container.upsert_item(collaboration_message) + copied_messages.append(collaboration_message) + + return copied_messages + + +def ensure_personal_collaboration_for_legacy_conversation(source_conversation_id, owner_user, invited_participants=None): + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + owner_summary = owner_user or {} + owner_user_id = str(owner_summary.get('user_id') or '').strip() + if not owner_user_id: + raise PermissionError('User not authenticated') + + if str(source_conversation_doc.get('user_id') or '').strip() != owner_user_id: + raise PermissionError('Only the conversation owner can convert this conversation') + + if not _is_eligible_legacy_personal_conversation(source_conversation_doc): + raise PermissionError('Only personal single-user conversations can be converted into personal collaborative conversations') + + collaboration_conversation_doc = None + linked_collaboration_id = str(source_conversation_doc.get('collaboration_conversation_id') or '').strip() + if linked_collaboration_id: + try: + collaboration_conversation_doc = get_collaboration_conversation(linked_collaboration_id) + except CosmosResourceNotFoundError: + collaboration_conversation_doc = None + + if collaboration_conversation_doc is None: + collaboration_conversation_doc = get_personal_collaboration_conversation_by_source_conversation( + source_conversation_id, + ) + + if collaboration_conversation_doc is not None: + invited_state_docs = [] + if invited_participants: + collaboration_conversation_doc, invited_state_docs = invite_personal_collaboration_participants( + collaboration_conversation_doc.get('id'), + owner_user_id, + invited_participants, + ) + return collaboration_conversation_doc, invited_state_docs, False, source_conversation_doc + + collaboration_conversation_doc, user_state_docs = create_personal_collaboration_conversation_record( + title=source_conversation_doc.get('title') or '', + creator_user=owner_summary, + invited_participants=invited_participants, + ) + invited_state_docs = [ + state_doc + for state_doc in user_state_docs + if state_doc.get('user_id') != owner_user_id + ] + + collaboration_conversation_doc['source_conversation_id'] = source_conversation_id + collaboration_conversation_doc['classification'] = list(source_conversation_doc.get('classification', []) or []) + collaboration_conversation_doc['tags'] = list(source_conversation_doc.get('tags', []) or []) + collaboration_conversation_doc['strict'] = bool(source_conversation_doc.get('strict', False)) + collaboration_conversation_doc['summary'] = source_conversation_doc.get('summary') + + source_context = list(source_conversation_doc.get('context', []) or []) + if source_context: + collaboration_conversation_doc['context'] = source_context + source_scope_locked = source_conversation_doc.get('scope_locked') + if source_scope_locked is not None: + collaboration_conversation_doc['scope_locked'] = bool(source_scope_locked) + source_locked_contexts = list(source_conversation_doc.get('locked_contexts', []) or []) + if source_locked_contexts: + collaboration_conversation_doc['locked_contexts'] = source_locked_contexts + + copied_messages = _copy_legacy_personal_messages_to_collaboration( + source_conversation_id, + collaboration_conversation_doc.get('id'), + owner_summary, + ) + if copied_messages: + last_copied_message = copied_messages[-1] + collaboration_conversation_doc['last_message_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['last_message_preview'] = ( + (last_copied_message.get('metadata') or {}).get('last_message_preview') or '' + ) + collaboration_conversation_doc['updated_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['message_count'] = len(copied_messages) + + cosmos_collaboration_conversations_container.upsert_item(collaboration_conversation_doc) + + conversion_timestamp = utc_now_iso() + source_conversation_doc['collaboration_conversation_id'] = collaboration_conversation_doc.get('id') + source_conversation_doc['converted_to_collaboration_at'] = conversion_timestamp + source_conversation_doc['is_hidden'] = True + source_conversation_doc['last_updated'] = conversion_timestamp + cosmos_conversations_container.upsert_item(source_conversation_doc) + + log_event( + '[Collaboration] Converted personal conversation into collaborative conversation', + extra={ + 'source_conversation_id': source_conversation_id, + 'conversation_id': collaboration_conversation_doc.get('id'), + 'created_by_user_id': owner_user_id, + 'copied_message_count': len(copied_messages), + }, + level=logging.INFO, + ) + return collaboration_conversation_doc, invited_state_docs, True, source_conversation_doc + + +def create_personal_collaboration_conversation_record(title, creator_user, invited_participants=None): + conversation_doc = build_personal_collaboration_conversation( + title=title, + creator_user=creator_user, + invited_participants=invited_participants, + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + user_states = [] + for participant in conversation_doc.get('participants', []): + membership_status = participant.get('status') + role = participant.get('role') + invited_by_user_id = '' + if participant.get('user_id') != conversation_doc.get('created_by_user_id'): + invited_by_user_id = conversation_doc.get('created_by_user_id') + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=participant, + role=role, + membership_status=membership_status, + invited_by_user_id=invited_by_user_id, + created_at=participant.get('invited_at') or conversation_doc.get('created_at'), + ) + cosmos_collaboration_user_state_container.upsert_item(state_doc) + user_states.append(state_doc) + + log_event( + '[Collaboration] Created personal collaborative conversation', + extra={ + 'conversation_id': conversation_doc.get('id'), + 'created_by_user_id': conversation_doc.get('created_by_user_id'), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + }, + level=logging.INFO, + ) + return conversation_doc, user_states + + +def create_group_collaboration_conversation_record(title, creator_user, group_doc): + conversation_doc = build_group_collaboration_conversation( + title=title, + creator_user=creator_user, + group_id=group_doc.get('id'), + group_name=group_doc.get('name', 'Group Workspace'), + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + log_event( + '[Collaboration] Created group collaborative conversation', + extra={ + 'conversation_id': conversation_doc.get('id'), + 'group_id': group_doc.get('id'), + 'created_by_user_id': conversation_doc.get('created_by_user_id'), + }, + level=logging.INFO, + ) + return conversation_doc + + +def list_personal_collaboration_conversations_for_user(user_id): + query = ( + 'SELECT * FROM c WHERE c.user_id = @user_id ' + 'AND c.conversation_kind = @conversation_kind' + ) + states = list(cosmos_collaboration_user_state_container.query_items( + query=query, + parameters=[ + {'name': '@user_id', 'value': user_id}, + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + ], + partition_key=user_id, + )) + + conversations = [] + for state_doc in states: + membership_status = state_doc.get('membership_status') + if membership_status not in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + + if state_doc.get('chat_type') != PERSONAL_MULTI_USER_CHAT_TYPE: + continue + + conversation_id = state_doc.get('conversation_id') + if not conversation_id: + continue + + try: + conversation_doc = get_collaboration_conversation(conversation_id) + except CosmosResourceNotFoundError: + continue + + conversations.append((conversation_doc, state_doc)) + + conversations.sort( + key=lambda item: item[0].get('updated_at') or item[0].get('created_at') or '', + reverse=True, + ) + return conversations + + +def list_group_collaboration_conversations_for_user(user_id): + user_groups = get_user_groups(user_id) + group_map = { + str(group_doc.get('id')): group_doc + for group_doc in user_groups + if group_doc.get('id') + } + if not group_map: + return [] + + query = ( + 'SELECT * FROM c WHERE c.conversation_kind = @conversation_kind ' + 'AND c.chat_type = @chat_type AND c.status = @status' + ) + items = list(cosmos_collaboration_conversations_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + {'name': '@chat_type', 'value': GROUP_MULTI_USER_CHAT_TYPE}, + {'name': '@status', 'value': 'active'}, + ], + enable_cross_partition_query=True, + )) + + filtered_items = [] + for conversation_doc in items: + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '') + if group_id not in group_map: + continue + + allowed, _ = check_group_status_allows_operation(group_map[group_id], 'view') + if allowed: + filtered_items.append(conversation_doc) + + filtered_items.sort( + key=lambda item: item.get('updated_at') or item.get('created_at') or '', + reverse=True, + ) + return filtered_items + + +def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, allow_pending=False): + if not is_collaboration_conversation(conversation_doc): + raise LookupError('Collaboration conversation not found') + + if is_personal_collaboration_conversation(conversation_doc): + try: + user_state = get_collaboration_user_state(user_id, conversation_doc.get('id')) + except CosmosResourceNotFoundError as exc: + raise PermissionError('You are not a participant in this collaborative conversation') from exc + + membership_status = user_state.get('membership_status') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + return { + 'user_state': user_state, + 'membership_status': membership_status, + } + if allow_pending and membership_status == MEMBERSHIP_STATUS_PENDING: + return { + 'user_state': user_state, + 'membership_status': membership_status, + } + raise PermissionError('You do not have access to this collaborative conversation') + + if is_group_collaboration_conversation(conversation_doc): + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '').strip() + if not group_id: + raise LookupError('Group collaborative conversation is missing group context') + + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + + group_role = assert_group_role( + user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'view') + if not allowed: + raise PermissionError(reason) + return { + 'group_doc': group_doc, + 'group_role': group_role, + 'membership_status': 'group_member', + } + + raise PermissionError('Unsupported collaboration conversation type') + + +def assert_user_can_participate_in_collaboration_conversation(user_id, conversation_doc): + access_context = assert_user_can_view_collaboration_conversation( + user_id, + conversation_doc, + allow_pending=False, + ) + + if is_group_collaboration_conversation(conversation_doc): + group_doc = access_context.get('group_doc') + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + + return access_context + + +def record_personal_invite_response(conversation_id, user_id, action): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Invite responses are only supported for personal collaborative conversations') + + user_state = get_collaboration_user_state(user_id, conversation_id) + participant_record = apply_personal_invite_response( + conversation_doc, + invited_user_id=user_id, + action=action, + responded_at=utc_now_iso(), + ) + + membership_status = MEMBERSHIP_STATUS_ACCEPTED if str(action).lower() == 'accept' else MEMBERSHIP_STATUS_DECLINED + user_state['membership_status'] = membership_status + user_state['updated_at'] = participant_record.get('responded_at') + user_state['responded_at'] = participant_record.get('responded_at') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + user_state['joined_at'] = participant_record.get('joined_at') + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state, participant_record + + +def invite_personal_collaboration_participants(conversation_id, owner_user_id, participants_to_add): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Member invites are only supported for personal collaborative conversations') + + actor_user_state = None + try: + actor_user_state = get_collaboration_user_state(owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + actor_user_state = None + + actor_role = get_personal_collaboration_role( + conversation_doc, + owner_user_id, + user_state=actor_user_state, + ) + if actor_role not in PERSONAL_COLLABORATION_MANAGER_ROLES: + raise PermissionError('Only conversation owners or admins can invite members') + + invite_timestamp = utc_now_iso() + added_participants = add_personal_pending_participants( + conversation_doc, + participants_to_add, + invited_at=invite_timestamp, + ) + if not added_participants: + return conversation_doc, [] + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + created_state_docs = [] + for participant in added_participants: + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=participant, + role=participant.get('role', MEMBERSHIP_ROLE_MEMBER), + membership_status=participant.get('status', MEMBERSHIP_STATUS_PENDING), + invited_by_user_id=owner_user_id, + created_at=invite_timestamp, + ) + cosmos_collaboration_user_state_container.upsert_item(state_doc) + created_state_docs.append(state_doc) + + return conversation_doc, created_state_docs + + +def remove_personal_collaboration_member(conversation_id, owner_user_id, member_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Member removal is only supported for personal collaborative conversations') + + actor_user_state = None + try: + actor_user_state = get_collaboration_user_state(owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + actor_user_state = None + + actor_role = get_personal_collaboration_role( + conversation_doc, + owner_user_id, + user_state=actor_user_state, + ) + if actor_role not in PERSONAL_COLLABORATION_MANAGER_ROLES: + raise PermissionError('Only conversation owners or admins can remove members') + + member_participant = get_personal_collaboration_participant(conversation_doc, member_user_id) + if member_participant is None: + raise LookupError('participant not found') + member_role = str(member_participant.get('role') or '').strip() + if actor_role != MEMBERSHIP_ROLE_OWNER and member_role != MEMBERSHIP_ROLE_MEMBER: + raise PermissionError('Only conversation owners can remove admins') + + removed_participant = remove_personal_participant( + conversation_doc, + participant_user_id=member_user_id, + removed_at=utc_now_iso(), + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(member_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['membership_status'] = MEMBERSHIP_STATUS_REMOVED + user_state['updated_at'] = removed_participant.get('removed_at') + user_state['removed_at'] = removed_participant.get('removed_at') + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, removed_participant + + +def list_collaboration_messages(conversation_id): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + return list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + + +def persist_collaboration_message(conversation_doc, sender_user, content, reply_to_message_id=None): + conversation_id = conversation_doc.get('id') + message_doc = build_collaboration_message_doc( + conversation_id=conversation_id, + sender_user=sender_user, + content=content, + reply_to_message_id=reply_to_message_id, + message_kind=MESSAGE_KIND_HUMAN, + timestamp=utc_now_iso(), + ) + + if is_group_collaboration_conversation(conversation_doc): + ensure_group_participant_record(conversation_doc, sender_user, joined_at=message_doc.get('timestamp')) + + cosmos_collaboration_messages_container.upsert_item(message_doc) + + conversation_doc['last_message_at'] = message_doc.get('timestamp') + conversation_doc['last_message_preview'] = ( + message_doc.get('metadata', {}).get('last_message_preview', '') + ) + conversation_doc['updated_at'] = message_doc.get('timestamp') + conversation_doc['message_count'] = int(conversation_doc.get('message_count', 0) or 0) + 1 + + if is_personal_collaboration_conversation(conversation_doc): + refresh_personal_participant_indexes(conversation_doc) + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return message_doc, conversation_doc + + +def update_personal_collaboration_title(conversation_id, current_user_id, new_title): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Title updates are only supported for personal collaborative conversations') + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can rename collaborative conversations') + + normalized_title = str(new_title or '').strip() + if not normalized_title: + raise ValueError('Title is required') + + conversation_doc['title'] = normalized_title + conversation_doc['updated_at'] = utc_now_iso() + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return conversation_doc + + +def toggle_personal_collaboration_pin(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Pin is only supported for personal collaborative conversations') + + user_state = get_collaboration_user_state(current_user_id, conversation_id) + user_state['is_pinned'] = not bool(user_state.get('is_pinned', False)) + user_state['updated_at'] = utc_now_iso() + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state + + +def toggle_personal_collaboration_hide(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Hide is only supported for personal collaborative conversations') + + user_state = get_collaboration_user_state(current_user_id, conversation_id) + user_state['is_hidden'] = not bool(user_state.get('is_hidden', False)) + user_state['updated_at'] = utc_now_iso() + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state + + +def update_personal_collaboration_member_role(conversation_id, current_user_id, member_user_id, new_role): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Role updates are only supported for personal collaborative conversations') + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can change participant roles') + + normalized_role = str(new_role or '').strip().lower() + if normalized_role not in (MEMBERSHIP_ROLE_ADMIN, MEMBERSHIP_ROLE_MEMBER): + raise ValueError('role must be admin or member') + + participant = get_personal_collaboration_participant(conversation_doc, member_user_id) + if participant is None: + raise LookupError('participant not found') + if str(participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise ValueError('Only active participants can have admin access') + if str(participant.get('role') or '').strip() == MEMBERSHIP_ROLE_OWNER: + raise ValueError('Use owner transfer to change owner access') + + if str(participant.get('role') or '').strip() == normalized_role: + return conversation_doc, participant + + timestamp = utc_now_iso() + participant['role'] = normalized_role + conversation_doc['updated_at'] = timestamp + refresh_personal_participant_indexes(conversation_doc) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(member_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['role'] = normalized_role + user_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, participant + + +def leave_personal_collaboration_conversation(conversation_id, current_user_id, new_owner_user_id=None): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Leave is only supported for personal collaborative conversations') + + participant = get_personal_collaboration_participant(conversation_doc, current_user_id) + if participant is None: + raise PermissionError('You are not a participant in this collaborative conversation') + if str(participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise PermissionError('Only active participants can leave this collaborative conversation') + + normalized_new_owner_user_id = str(new_owner_user_id or '').strip() + current_role = str(participant.get('role') or '').strip() + promoted_participant = None + timestamp = utc_now_iso() + + if current_role == MEMBERSHIP_ROLE_OWNER: + owner_user_ids = list(conversation_doc.get('owner_user_ids', []) or []) + other_owner_ids = [owner_user_id for owner_user_id in owner_user_ids if owner_user_id != current_user_id] + if not other_owner_ids and not normalized_new_owner_user_id: + raise ValueError('Assign a new owner before leaving this shared conversation') + + if normalized_new_owner_user_id: + if normalized_new_owner_user_id == current_user_id: + raise ValueError('Choose another participant as the new owner') + + promoted_participant = get_personal_collaboration_participant( + conversation_doc, + normalized_new_owner_user_id, + ) + if promoted_participant is None: + raise LookupError('The selected new owner is not a participant in this conversation') + if str(promoted_participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise ValueError('The selected new owner must already be an active participant') + promoted_participant['role'] = MEMBERSHIP_ROLE_OWNER + + try: + new_owner_state = get_collaboration_user_state(normalized_new_owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + new_owner_state = None + + if new_owner_state: + new_owner_state['role'] = MEMBERSHIP_ROLE_OWNER + new_owner_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(new_owner_state) + + participant['status'] = MEMBERSHIP_STATUS_REMOVED + participant['removed_at'] = timestamp + conversation_doc['updated_at'] = timestamp + refresh_personal_participant_indexes(conversation_doc) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(current_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['membership_status'] = MEMBERSHIP_STATUS_REMOVED + user_state['role'] = MEMBERSHIP_ROLE_MEMBER + user_state['removed_at'] = timestamp + user_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, participant, promoted_participant + + +def _delete_source_personal_conversation(conversation_doc, current_user_id): + source_conversation_id = str((conversation_doc or {}).get('source_conversation_id') or '').strip() + if not source_conversation_id: + return + + try: + source_conversation = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + return + + if str(source_conversation.get('user_id') or '').strip() != str(current_user_id or '').strip(): + return + if str(source_conversation.get('collaboration_conversation_id') or '').strip() != str(conversation_doc.get('id') or '').strip(): + return + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + source_messages = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + + for message_doc in source_messages: + cosmos_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=source_conversation_id, + ) + + delete_thoughts_for_conversation(source_conversation_id, current_user_id) + cosmos_conversations_container.delete_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + + +def delete_personal_collaboration_conversation(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_personal_collaboration_conversation(conversation_doc): + raise PermissionError('Delete is only supported for personal collaborative conversations') + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can delete this shared conversation') + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + messages = list(cosmos_collaboration_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + for message_doc in messages: + cosmos_collaboration_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=conversation_id, + ) + + state_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + state_docs = list(cosmos_collaboration_user_state_container.query_items( + query=state_query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + enable_cross_partition_query=True, + )) + for state_doc in state_docs: + cosmos_collaboration_user_state_container.delete_item( + item=state_doc.get('id'), + partition_key=state_doc.get('user_id'), + ) + + _delete_source_personal_conversation(conversation_doc, current_user_id) + cosmos_collaboration_conversations_container.delete_item( + item=conversation_id, + partition_key=conversation_id, + ) + return conversation_doc + + +def get_accessible_collaboration_message_thoughts(conversation_doc, message_doc, viewer_user_id): + conversation_id = str((conversation_doc or {}).get('id') or '').strip() + message_id = str((message_doc or {}).get('id') or '').strip() + metadata = (message_doc or {}).get('metadata', {}) if isinstance(message_doc, dict) else {} + + if not conversation_id or not message_id: + return [] + + direct_thoughts = get_thoughts_for_message(conversation_id, message_id, viewer_user_id) + if direct_thoughts: + return direct_thoughts + + fallback_user_ids = [] + for candidate_user_id in ( + metadata.get('source_thought_user_id'), + (conversation_doc or {}).get('created_by_user_id'), + ): + normalized_candidate = str(candidate_user_id or '').strip() + if not normalized_candidate or normalized_candidate in fallback_user_ids: + continue + fallback_user_ids.append(normalized_candidate) + + fallback_conversation_id = str( + metadata.get('source_conversation_id') + or (conversation_doc or {}).get('source_conversation_id') + or '' + ).strip() + fallback_message_id = str(metadata.get('source_message_id') or '').strip() + + for candidate_user_id in fallback_user_ids: + candidate_thoughts = get_thoughts_for_message(conversation_id, message_id, candidate_user_id) + if candidate_thoughts: + return candidate_thoughts + + if fallback_conversation_id and fallback_message_id: + candidate_thoughts = get_thoughts_for_message( + fallback_conversation_id, + fallback_message_id, + candidate_user_id, + ) + if candidate_thoughts: + return candidate_thoughts + + return [] \ No newline at end of file diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 8d09ee61..8958f26b 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -264,6 +264,9 @@ def get_settings(use_cosmos=False, include_source=False): # Processing Thoughts 'enable_thoughts': True, + # Collaborative Conversations + 'enable_collaborative_conversations': True, + # Search and Extract 'azure_ai_search_endpoint': '', 'azure_ai_search_key': '', diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py new file mode 100644 index 00000000..c9e18941 --- /dev/null +++ b/application/single_app/route_backend_collaboration.py @@ -0,0 +1,1021 @@ +# route_backend_collaboration.py + +import json +import threading +import time + +import app_settings_cache +from flask import Response, jsonify, request, stream_with_context + +from config import * +from collaboration_models import MEMBERSHIP_STATUS_PENDING, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso +from functions_appinsights import log_event +from functions_authentication import * +from functions_collaboration import ( + assert_user_can_participate_in_collaboration_conversation, + assert_user_can_view_collaboration_conversation, + create_group_collaboration_conversation_record, + create_personal_collaboration_conversation_record, + delete_personal_collaboration_conversation, + ensure_personal_collaboration_for_legacy_conversation, + get_collaboration_conversation, + get_collaboration_user_state, + invite_personal_collaboration_participants, + leave_personal_collaboration_conversation, + list_collaboration_messages, + list_group_collaboration_conversations_for_user, + list_personal_collaboration_conversations_for_user, + persist_collaboration_message, + record_personal_invite_response, + remove_personal_collaboration_member, + serialize_collaboration_conversation, + serialize_collaboration_message, + toggle_personal_collaboration_hide, + toggle_personal_collaboration_pin, + update_personal_collaboration_member_role, + update_personal_collaboration_title, +) +from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id +from functions_settings import get_settings, get_user_settings +from swagger_wrapper import swagger_route, get_auth_security + + +COLLABORATION_EVENT_HEARTBEAT_SECONDS = 15 +COLLABORATION_EVENT_TTL_SECONDS = 3600 + + +class CollaborationEventSession: + HEARTBEAT_EVENT = ': keep-alive\n\n' + + def __init__(self, conversation_id, heartbeat_interval_seconds=15, session_ttl_seconds=3600): + self.conversation_id = conversation_id + self.heartbeat_interval_seconds = heartbeat_interval_seconds + self.session_ttl_seconds = session_ttl_seconds + self.cache_key = f'collaboration:{conversation_id}' + self._condition = threading.Condition() + + def _build_metadata(self): + return { + 'conversation_id': self.conversation_id, + 'active': True, + 'heartbeat_interval_seconds': self.heartbeat_interval_seconds, + 'updated_at': utc_now_iso(), + } + + def initialize(self): + existing_metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if existing_metadata: + app_settings_cache.set_stream_session_meta( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + return + + app_settings_cache.initialize_stream_session_cache( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + + def publish(self, event_payload): + self.initialize() + event_text = f'data: {json.dumps(event_payload)}\n\n' + app_settings_cache.append_stream_session_event( + self.cache_key, + event_text, + ttl_seconds=self.session_ttl_seconds, + ) + app_settings_cache.set_stream_session_meta( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + with self._condition: + self._condition.notify_all() + + def iter_events(self, start_index=0): + self.initialize() + next_index = max(int(start_index or 0), 0) + last_heartbeat_at = time.time() + + while True: + pending_events = app_settings_cache.get_stream_session_events( + self.cache_key, + start_index=next_index, + ) or [] + if pending_events: + for event_to_yield in pending_events: + next_index += 1 + last_heartbeat_at = time.time() + yield event_to_yield + continue + + metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if not metadata: + self.initialize() + metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if not metadata: + return + + heartbeat_interval_seconds = int( + metadata.get('heartbeat_interval_seconds') or self.heartbeat_interval_seconds + ) + remaining_heartbeat_seconds = max( + heartbeat_interval_seconds - (time.time() - last_heartbeat_at), + 0.25, + ) + with self._condition: + self._condition.wait(timeout=min(1.0, remaining_heartbeat_seconds)) + + if (time.time() - last_heartbeat_at) >= heartbeat_interval_seconds: + last_heartbeat_at = time.time() + yield self.HEARTBEAT_EVENT + + +class CollaborationEventRegistry: + def __init__(self, heartbeat_interval_seconds=15, session_ttl_seconds=3600): + self.heartbeat_interval_seconds = heartbeat_interval_seconds + self.session_ttl_seconds = session_ttl_seconds + self._sessions = {} + self._lock = threading.Lock() + + def get_session(self, conversation_id): + with self._lock: + session = self._sessions.get(conversation_id) + if session is None: + session = CollaborationEventSession( + conversation_id=conversation_id, + heartbeat_interval_seconds=self.heartbeat_interval_seconds, + session_ttl_seconds=self.session_ttl_seconds, + ) + self._sessions[conversation_id] = session + session.initialize() + return session + + def publish(self, conversation_id, event_payload): + self.get_session(conversation_id).publish(event_payload) + + +COLLABORATION_EVENT_REGISTRY = CollaborationEventRegistry( + heartbeat_interval_seconds=COLLABORATION_EVENT_HEARTBEAT_SECONDS, + session_ttl_seconds=COLLABORATION_EVENT_TTL_SECONDS, +) + + +def get_user_state_or_none(user_id, conversation_id): + try: + return get_collaboration_user_state(user_id, conversation_id) + except CosmosResourceNotFoundError: + return None + + +def _build_collaboration_event(conversation_id, event_type, payload): + return { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'occurred_at': utc_now_iso(), + 'payload': payload, + } + + +def _require_collaboration_feature_enabled(): + settings = get_settings() + if not settings.get('enable_collaborative_conversations', False): + raise PermissionError('Collaborative conversations are disabled by configuration') + return settings + + +def _get_current_collaboration_user(): + current_user = get_current_user_info() + return normalize_collaboration_user(current_user) + + +def _normalize_participant_payload(raw_payload): + if raw_payload is None: + return [] + if isinstance(raw_payload, dict): + raw_payload = [raw_payload] + + normalized_participants = [] + for raw_participant in raw_payload: + participant_summary = normalize_collaboration_user(raw_participant) + if participant_summary: + normalized_participants.append(participant_summary) + return normalized_participants + + +def register_route_backend_collaboration(app): + @app.route('/api/collaboration/conversations', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def list_collaboration_conversations_api(): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + scope_filter = str(request.args.get('scope') or 'all').strip().lower() + include_pending = str(request.args.get('include_pending', 'true')).strip().lower() != 'false' + conversations = [] + + if scope_filter in ('all', 'personal'): + for conversation_doc, user_state in list_personal_collaboration_conversations_for_user(current_user['user_id']): + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + if include_pending or serialized.get('membership_status') != MEMBERSHIP_STATUS_PENDING: + conversations.append(serialized) + + if scope_filter in ('all', 'group'): + for conversation_doc in list_group_collaboration_conversations_for_user(current_user['user_id']): + conversations.append(serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + )) + + conversations.sort( + key=lambda item: item.get('updated_at') or item.get('created_at') or '', + reverse=True, + ) + return jsonify({'conversations': conversations}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to list conversations: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversations'}), 500 + + @app.route('/api/collaboration/conversations', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def create_collaboration_conversation_api(): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + conversation_type = str(data.get('conversation_type') or '').strip().lower() + title = str(data.get('title') or '').strip() + + if conversation_type == 'personal': + participants_to_invite = _normalize_participant_payload(data.get('participants', [])) + conversation_doc, user_states = create_personal_collaboration_conversation_record( + title=title, + creator_user=current_user, + invited_participants=participants_to_invite, + ) + creator_state = next( + (state for state in user_states if state.get('user_id') == current_user['user_id']), + None, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=creator_state, + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized}, + ), + ) + return jsonify({'conversation': serialized}), 201 + + if conversation_type == 'group': + group_id = str(data.get('group_id') or '').strip() + if not group_id: + user_settings = get_user_settings(current_user['user_id']) + group_id = str( + ((user_settings or {}).get('settings') or {}).get('activeGroupOid') or '' + ).strip() + if not group_id: + return jsonify({'error': 'group_id is required for group collaborative conversations'}), 400 + + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({'error': 'Group not found'}), 404 + + assert_group_role( + current_user['user_id'], + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + return jsonify({'error': reason}), 403 + + conversation_doc = create_group_collaboration_conversation_record( + title=title, + creator_user=current_user, + group_doc=group_doc, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized}, + ), + ) + return jsonify({'conversation': serialized}), 201 + + return jsonify({'error': 'conversation_type must be personal or group'}), 400 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to create conversation: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to create collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_conversation_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + access_context = assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=access_context.get('user_state'), + ) + return jsonify({'conversation': serialized}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//invite-response', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def respond_to_collaboration_invite_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + action = str(data.get('action') or '').strip().lower() + if action not in ('accept', 'decline'): + return jsonify({'error': 'action must be accept or decline'}), 400 + + conversation_doc, user_state, participant_record = record_personal_invite_response( + conversation_id, + current_user['user_id'], + action, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + event_type = 'collaboration.invite.accepted' if action == 'accept' else 'collaboration.invite.declined' + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + event_type, + { + 'conversation': serialized, + 'participant': participant_record, + }, + ), + ) + return jsonify({'conversation': serialized}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except (LookupError, PermissionError, ValueError) as exc: + return jsonify({'error': str(exc)}), 403 if isinstance(exc, PermissionError) else 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to respond to invite for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update invite response'}), 500 + + @app.route('/api/collaboration/conversations//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def invite_collaboration_members_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, state_docs = invite_personal_collaboration_participants( + conversation_id, + current_user['user_id'], + participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + invited_participants = [] + for state_doc in state_docs: + invited_participants.append({ + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + }) + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + }, + ), + ) + return jsonify({'conversation': serialized, 'invited_participants': invited_participants}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to invite members for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to invite collaborative conversation members'}), 500 + + @app.route('/api/collaboration/conversations/from-personal//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def convert_personal_conversation_to_collaboration_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, invited_state_docs, created_new, _ = ensure_personal_collaboration_for_legacy_conversation( + conversation_id, + current_user, + invited_participants=participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + invited_participants = [ + { + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + } + for state_doc in invited_state_docs + ] + + if created_new: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized, 'source_conversation_id': conversation_id}, + ), + ) + + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + 'source_conversation_id': conversation_id, + }, + ), + ) + + return jsonify({ + 'conversation': serialized, + 'invited_participants': invited_participants, + 'created': created_new, + 'source_conversation_id': conversation_id, + }), 201 if created_new else 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to convert personal conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to convert conversation to collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//members/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def remove_collaboration_member_api(conversation_id, member_user_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc, removed_participant = remove_personal_collaboration_member( + conversation_id, + current_user['user_id'], + member_user_id, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.removed', + { + 'conversation': serialized, + 'participant': removed_participant, + }, + ), + ) + return jsonify({'conversation': serialized, 'removed_participant': removed_participant}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to remove member for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to remove collaborative conversation member'}), 500 + + @app.route('/api/collaboration/conversations//members//role', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_collaboration_member_role_api(conversation_id, member_user_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + new_role = str(data.get('role') or '').strip().lower() + if not new_role: + return jsonify({'error': 'role is required'}), 400 + + conversation_doc, updated_participant = update_personal_collaboration_member_role( + conversation_id, + current_user['user_id'], + member_user_id, + new_role, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.role_updated', + { + 'conversation': serialized, + 'participant': updated_participant, + }, + ), + ) + return jsonify({'conversation': serialized, 'participant': updated_participant}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to update member role for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation role'}), 500 + + @app.route('/api/collaboration/conversations/', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_collaboration_conversation_title_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + new_title = str(data.get('title') or '').strip() + if not new_title: + return jsonify({'error': 'Title is required'}), 400 + + conversation_doc = update_personal_collaboration_title( + conversation_id, + current_user['user_id'], + new_title, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.updated', + {'conversation': serialized}, + ), + ) + return jsonify(serialized), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to update title for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//pin', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def toggle_collaboration_conversation_pin_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + _, user_state = toggle_personal_collaboration_pin(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'is_pinned': bool(user_state.get('is_pinned', False))}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to toggle pin for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to toggle collaborative pin status'}), 500 + + @app.route('/api/collaboration/conversations//hide', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def toggle_collaboration_conversation_hide_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + _, user_state = toggle_personal_collaboration_hide(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'is_hidden': bool(user_state.get('is_hidden', False))}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to toggle hide for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to toggle collaborative hide status'}), 500 + + @app.route('/api/collaboration/conversations//delete-action', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_delete_action_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + action = str(data.get('action') or '').strip().lower() + new_owner_user_id = str(data.get('new_owner_user_id') or '').strip() or None + + if action == 'delete': + conversation_doc = get_collaboration_conversation(conversation_id) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.deleted', + { + 'conversation': serialized, + 'deleted_by_user_id': current_user['user_id'], + }, + ), + ) + delete_personal_collaboration_conversation(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'action': 'delete', 'conversation_id': conversation_id}), 200 + + if action == 'leave': + conversation_doc, removed_participant, promoted_participant = leave_personal_collaboration_conversation( + conversation_id, + current_user['user_id'], + new_owner_user_id=new_owner_user_id, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.removed', + { + 'conversation': serialized, + 'participant': removed_participant, + 'promoted_participant': promoted_participant, + }, + ), + ) + return jsonify({ + 'success': True, + 'action': 'leave', + 'conversation': serialized, + 'removed_participant': removed_participant, + 'promoted_participant': promoted_participant, + }), 200 + + return jsonify({'error': 'action must be delete or leave'}), 400 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to complete delete action for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation membership'}), 500 + + @app.route('/api/collaboration/conversations//messages', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_messages_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + messages = [serialize_collaboration_message(doc) for doc in list_collaboration_messages(conversation_id)] + return jsonify({'messages': messages}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load messages for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversation messages'}), 500 + + @app.route('/api/collaboration/conversations//messages', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def post_collaboration_message_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + message_content = str(data.get('content') or '').strip() + reply_to_message_id = str(data.get('reply_to_message_id') or '').strip() or None + if not message_content: + return jsonify({'error': 'content is required'}), 400 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + message_doc, updated_conversation_doc = persist_collaboration_message( + conversation_doc, + current_user, + message_content, + reply_to_message_id=reply_to_message_id, + ) + serialized_message = serialize_collaboration_message(message_doc) + serialized_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_conversation, + 'message': serialized_message, + }, + ), + ) + return jsonify({'conversation': serialized_conversation, 'message': serialized_message}), 201 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to post message for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to post collaborative conversation message'}), 500 + + @app.route('/api/collaboration/conversations//typing', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_typing_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + is_typing = bool(data.get('is_typing', True)) + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + + typing_payload = { + 'user': current_user, + 'is_typing': is_typing, + 'expires_at': add_seconds_to_iso(utc_now_iso(), 8), + } + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.typing.updated', + typing_payload, + ), + ) + return jsonify({'success': True}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to publish typing event for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to publish typing event'}), 500 + + @app.route('/api/collaboration/conversations//events', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_events_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + + start_index = request.args.get('start_index', 0) + session = COLLABORATION_EVENT_REGISTRY.get_session(conversation_id) + return Response( + stream_with_context(session.iter_events(start_index=start_index)), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive', + }, + ) + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to attach event stream for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to attach collaborative event stream'}), 500 \ No newline at end of file diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 086019f1..b3237bbb 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -17,6 +17,13 @@ from functions_appinsights import log_event from functions_authentication import * from functions_chat import sort_messages_by_thread +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_accessible_collaboration_message_thoughts, + get_collaboration_conversation, + is_collaboration_conversation, + list_collaboration_messages, +) from functions_conversation_metadata import update_conversation_with_metadata from functions_debug import debug_print from functions_message_artifacts import ( @@ -84,29 +91,43 @@ def api_export_conversations(): settings = get_settings() exported = [] for conv_id in conversation_ids: + conversation = None + messages = [] try: conversation = cosmos_conversations_container.read_item( item=conv_id, partition_key=conv_id ) + if conversation.get('user_id') != user_id: + debug_print(f"Export: user {user_id} does not own conversation {conv_id}") + continue + + message_query = """ + SELECT * FROM c + WHERE c.conversation_id = @conversation_id + ORDER BY c.timestamp ASC + """ + messages = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': conv_id}], + partition_key=conv_id + )) except Exception: - debug_print(f"Export: conversation {conv_id} not found or access denied") - continue - - if conversation.get('user_id') != user_id: - debug_print(f"Export: user {user_id} does not own conversation {conv_id}") - continue - - message_query = """ - SELECT * FROM c - WHERE c.conversation_id = @conversation_id - ORDER BY c.timestamp ASC - """ - messages = list(cosmos_messages_container.query_items( - query=message_query, - parameters=[{'name': '@conversation_id', 'value': conv_id}], - partition_key=conv_id - )) + try: + conversation = get_collaboration_conversation(conv_id) + access_context = assert_user_can_view_collaboration_conversation( + user_id, + conversation, + allow_pending=True, + ) + user_state = access_context.get('user_state') or {} + conversation = dict(conversation) + conversation['is_pinned'] = bool(user_state.get('is_pinned', False)) + conversation['is_hidden'] = bool(user_state.get('is_hidden', False)) + messages = list_collaboration_messages(conv_id) + except Exception: + debug_print(f"Export: conversation {conv_id} not found or access denied") + continue exported.append( _build_export_entry( @@ -254,7 +275,7 @@ def _build_export_entry( filtered_messages = hydrate_agent_citations_from_artifacts(filtered_messages, artifact_payload_map) ordered_messages = sort_messages_by_thread(filtered_messages) - raw_thoughts = get_thoughts_for_conversation(conversation.get('id'), user_id) + raw_thoughts = [] if is_collaboration_conversation(conversation) else get_thoughts_for_conversation(conversation.get('id'), user_id) thoughts_by_message = defaultdict(list) for thought in raw_thoughts: thoughts_by_message[thought.get('message_id')].append(_sanitize_thought(thought)) @@ -275,6 +296,13 @@ def _build_export_entry( message_transcript_index = transcript_index thoughts = thoughts_by_message.get(message.get('id'), []) + if not thoughts and is_collaboration_conversation(conversation): + collaboration_thoughts = get_accessible_collaboration_message_thoughts( + conversation, + message, + user_id, + ) + thoughts = [_sanitize_thought(thought) for thought in collaboration_thoughts] exported_message = _sanitize_message( message, sequence_index=sequence_index, @@ -349,7 +377,7 @@ def _sanitize_conversation( return { 'id': conversation.get('id'), 'title': conversation.get('title', 'Untitled'), - 'last_updated': conversation.get('last_updated', ''), + 'last_updated': conversation.get('last_updated') or conversation.get('updated_at', ''), 'chat_type': conversation.get('chat_type', 'personal'), 'tags': conversation.get('tags', []), 'context': conversation.get('context', []), diff --git a/application/single_app/route_backend_thoughts.py b/application/single_app/route_backend_thoughts.py index 69aed271..406a59ae 100644 --- a/application/single_app/route_backend_thoughts.py +++ b/application/single_app/route_backend_thoughts.py @@ -1,7 +1,14 @@ # route_backend_thoughts.py from flask import request, jsonify +from config import CosmosResourceNotFoundError from functions_authentication import login_required, user_required, get_current_user_id +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_accessible_collaboration_message_thoughts, + get_collaboration_conversation, + get_collaboration_message, +) from functions_settings import get_settings from functions_thoughts import get_thoughts_for_message, get_pending_thoughts from swagger_wrapper import swagger_route, get_auth_security @@ -26,6 +33,23 @@ def api_get_message_thoughts(conversation_id, message_id): try: thoughts = get_thoughts_for_message(conversation_id, message_id, user_id) + if not thoughts: + try: + message_doc = get_collaboration_message(message_id) + if str(message_doc.get('conversation_id') or '') == str(conversation_id or ''): + collaboration_conversation = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + collaboration_conversation, + allow_pending=True, + ) + thoughts = get_accessible_collaboration_message_thoughts( + collaboration_conversation, + message_doc, + user_id, + ) + except CosmosResourceNotFoundError: + thoughts = thoughts or [] # Strip internal Cosmos fields before returning sanitized = [] for t in thoughts: @@ -40,6 +64,8 @@ def api_get_message_thoughts(conversation_id, message_id): 'timestamp': t.get('timestamp') }) return jsonify({'thoughts': sanitized, 'enabled': True}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: log_event(f"api_get_message_thoughts error: {e}", level="WARNING") return jsonify({'error': 'Failed to retrieve thoughts'}), 500 @@ -63,6 +89,17 @@ def api_get_pending_thoughts(conversation_id): return jsonify({'thoughts': [], 'enabled': False}), 200 try: + try: + collaboration_conversation = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + collaboration_conversation, + allow_pending=True, + ) + return jsonify({'thoughts': [], 'enabled': True}), 200 + except CosmosResourceNotFoundError: + pass + message_id = request.args.get('message_id') thoughts = get_pending_thoughts(conversation_id, user_id, message_id=message_id) sanitized = [] @@ -78,6 +115,8 @@ def api_get_pending_thoughts(conversation_id): 'timestamp': t.get('timestamp') }) return jsonify({'thoughts': sanitized, 'enabled': True}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: log_event(f"api_get_pending_thoughts error: {e}", level="WARNING") return jsonify({'error': 'Failed to retrieve pending thoughts'}), 500 diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 459aa800..80e26ae0 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -1,6 +1,7 @@ # route_backend_users.py from config import * +from collaboration_models import normalize_collaboration_user from functions_authentication import * from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -97,6 +98,86 @@ def api_get_user_info(user_id): return jsonify({ "error": f"User not found for oid {user_id}" }), 404 + + @app.route('/api/user/collaboration-suggestions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_collaboration_suggestions(): + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "Unable to identify user"}), 401 + + query = str(request.args.get('query') or '').strip().lower() + recent_only = str(request.args.get('recent_only', 'false')).strip().lower() == 'true' + + try: + requested_limit = int(request.args.get('limit', 8)) + except (TypeError, ValueError): + requested_limit = 8 + limit = max(1, min(requested_limit, 20)) + + user_settings_doc = get_user_settings(user_id) or {} + recent_collaborators = ((user_settings_doc.get('settings') or {}).get('recentCollaborators') or []) + + suggestions = [] + seen_user_ids = set() + + def add_suggestion(raw_value, source_label): + fallback_user_id = None + if isinstance(raw_value, dict): + fallback_user_id = raw_value.get('id') + + normalized_user = normalize_collaboration_user(raw_value, fallback_user_id=fallback_user_id) + if not normalized_user: + return + + normalized_user_id = normalized_user.get('user_id') + if not normalized_user_id or normalized_user_id == user_id or normalized_user_id in seen_user_ids: + return + + haystack = f"{normalized_user.get('display_name', '')} {normalized_user.get('email', '')}".strip().lower() + if query and query not in haystack: + return + + seen_user_ids.add(normalized_user_id) + suggestions.append({ + 'user_id': normalized_user_id, + 'display_name': normalized_user.get('display_name'), + 'email': normalized_user.get('email'), + 'source': source_label, + }) + + for recent_collaborator in recent_collaborators: + add_suggestion(recent_collaborator, 'recent') + if len(suggestions) >= limit: + return jsonify({'results': suggestions[:limit]}), 200 + + if not recent_only and query: + user_query = ( + f'SELECT TOP {max(limit * 3, 12)} c.id, c.display_name, c.email FROM c ' + 'WHERE c.id != @current_user_id AND ' + '((IS_DEFINED(c.display_name) AND CONTAINS(LOWER(c.display_name), @query)) ' + 'OR (IS_DEFINED(c.email) AND CONTAINS(LOWER(c.email), @query)))' + ) + local_results = list(cosmos_user_settings_container.query_items( + query=user_query, + parameters=[ + {'name': '@current_user_id', 'value': user_id}, + {'name': '@query', 'value': query}, + ], + enable_cross_partition_query=True, + )) + for local_result in local_results: + add_suggestion({ + 'id': local_result.get('id'), + 'display_name': local_result.get('display_name'), + 'email': local_result.get('email'), + }, 'local') + if len(suggestions) >= limit: + break + + return jsonify({'results': suggestions[:limit]}), 200 @app.route('/api/user/settings', methods=['GET', 'POST']) @swagger_route(security=get_auth_security()) @@ -157,6 +238,7 @@ def user_settings(): 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', # Tutorial visibility settings 'showTutorialButtons', + 'recentCollaborators', # Metrics and other settings 'metrics', 'lastUpdated' } # Add others as needed diff --git a/application/single_app/route_frontend_conversations.py b/application/single_app/route_frontend_conversations.py index d2b428fe..b54d09ab 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -1,9 +1,15 @@ # route_frontend_conversations.py from config import * +from functions_appinsights import log_event from functions_authentication import * from functions_debug import debug_print from functions_chat import sort_messages_by_thread +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_collaboration_conversation, + get_collaboration_message, +) from functions_message_artifacts import ( build_message_artifact_payload_map, filter_assistant_artifact_items, @@ -253,7 +259,16 @@ def get_message_metadata(message_id): )) if not messages: - return jsonify({'error': 'Message not found'}), 404 + message = get_collaboration_message(message_id) + conversation = get_collaboration_conversation(message.get('conversation_id')) + assert_user_can_view_collaboration_conversation( + user_id, + conversation, + allow_pending=True, + ) + if message.get('role', '') == 'user': + return jsonify(message.get('metadata', {})) + return jsonify(message) message = messages[0] @@ -282,7 +297,12 @@ def get_message_metadata(message_id): else: # Assistant, image, file messages - return full document return jsonify(message) + + except CosmosResourceNotFoundError: + return jsonify({'error': 'Message not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: - print(f"Error fetching message metadata: {str(e)}") + log_event(f"get_message_metadata failed: {e}", level="WARNING") return jsonify({'error': 'Failed to fetch message metadata'}), 500 \ No newline at end of file diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 1ab7b282..12456984 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1307,6 +1307,10 @@ a.citation-link:hover { justify-content: flex-start; } +.collaborator-message { + justify-content: flex-start; +} + /* Message bubble */ .message-bubble { max-width: 90%; @@ -1337,6 +1341,13 @@ a.citation-link:hover { min-width: min(320px, 90%); } +.collaborator-message .message-bubble { + background-color: #eef6ef; + color: black; + border-bottom-left-radius: 0; + min-width: min(280px, 90%); +} + /* File message bubble styling */ .file-message .message-bubble { background-color: #e8f5e9; /* Green */ @@ -1370,6 +1381,11 @@ a.citation-link:hover { color: #e9ecef; } +[data-bs-theme="dark"] .collaborator-message .message-bubble { + background-color: #35533f; + color: #f8f9fa; +} + [data-bs-theme="dark"] .file-message .message-bubble { background-color: #198754; /* Darker green for dark mode */ color: #ffffff; @@ -1477,10 +1493,43 @@ a.citation-link:hover { margin-right: 10px; } +.collaborator-message .avatar { + margin-right: 10px; +} + .file-message { justify-content: flex-end; } +.collaboration-typing-indicator { + min-height: 24px; + padding: 0 1rem 0.5rem 1rem; + font-size: 0.875rem; + color: #6c757d; +} + +.collaboration-mention-menu { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 0.5rem); + z-index: 1055; + max-height: 240px; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.12); +} + +.collaboration-mention-menu .list-group-item.active { + background-color: #0d6efd; + border-color: #0d6efd; +} + +.collaboration-participant-results { + max-height: 320px; + overflow-y: auto; +} + /* Style code blocks */ /* Code blocks: force internal scroll, never overflow parent */ diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js new file mode 100644 index 00000000..7a91126b --- /dev/null +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -0,0 +1,1181 @@ +// chat-collaboration.js + +import { appendMessage, updateSendButtonVisibility, updateUserMessageId, userInput } from './chat-messages.js'; +import { applyConversationMetadataUpdate } from './chat-conversations.js'; +import { loadUserSettings, saveUserSetting } from './chat-layout.js'; +import { showToast } from './chat-toast.js'; + +const RECENT_COLLABORATORS_KEY = 'recentCollaborators'; +const MAX_RECENT_COLLABORATORS = 12; +const DEFAULT_SUGGESTION_LIMIT = 8; + +const mentionMenu = document.getElementById('collaboration-mention-menu'); +const participantModalEl = document.getElementById('collaboration-participant-modal'); +const participantSearchInput = document.getElementById('collaboration-participant-search-input'); +const participantResults = document.getElementById('collaboration-participant-results'); +const participantConversationIdInput = document.getElementById('collaboration-participant-conversation-id'); +const confirmModalEl = document.getElementById('collaboration-confirm-modal'); +const confirmMessageEl = document.getElementById('collaboration-confirm-message'); +const confirmAddBtn = document.getElementById('collaboration-confirm-add-btn'); +const sendBtn = document.getElementById('send-btn'); + +let cachedUserSettingsPromise = null; +let activeCollaborativeConversationId = null; +let activeCollaborationEventSource = null; +let typingUsers = new Map(); +let lastTypingState = false; +let typingStopHandle = null; +let mentionSearchToken = 0; +let activeMentionState = null; +let pendingParticipantConfirmation = null; +const notifiedPendingInviteConversationIds = new Set(); +const promptedPendingInviteConversationIds = new Set(); + +function isCollaborationEnabled() { + return Boolean(window.appSettings?.enable_collaborative_conversations); +} + +function getConversationDomItem(conversationId) { + if (!conversationId) { + return null; + } + + return document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); +} + +function getConversationKind(conversationId) { + const item = getConversationDomItem(conversationId); + return item?.dataset?.conversationKind || null; +} + +function isCollaborationConversation(conversationId) { + return getConversationKind(conversationId) === 'collaborative'; +} + +function getConversationChatType(conversationId) { + const item = getConversationDomItem(conversationId); + return item?.getAttribute('data-chat-type') || null; +} + +function setConversationDataset(conversationId, metadata = {}) { + const conversationSelectors = [ + `.conversation-item[data-conversation-id="${conversationId}"]`, + `.sidebar-conversation-item[data-conversation-id="${conversationId}"]`, + ]; + + conversationSelectors.forEach(selector => { + const element = document.querySelector(selector); + if (!element) { + return; + } + + if (metadata.conversation_kind) { + element.dataset.conversationKind = metadata.conversation_kind; + } + if (metadata.membership_status) { + element.dataset.membershipStatus = metadata.membership_status; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_manage_members')) { + element.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_manage_roles')) { + element.dataset.canManageRoles = metadata.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_accept_invite')) { + element.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_post_messages')) { + element.dataset.canPostMessages = metadata.can_post_messages ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_delete_conversation')) { + element.dataset.canDeleteConversation = metadata.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_leave_conversation')) { + element.dataset.canLeaveConversation = metadata.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'current_user_role')) { + element.dataset.currentUserRole = metadata.current_user_role || ''; + } + }); +} + +function normalizeCollaborator(rawUser) { + if (!rawUser || typeof rawUser !== 'object') { + return null; + } + + const userId = String(rawUser.user_id || rawUser.userId || rawUser.id || '').trim(); + if (!userId) { + return null; + } + + const displayName = String(rawUser.display_name || rawUser.displayName || rawUser.name || rawUser.email || '').trim(); + const email = String(rawUser.email || rawUser.mail || '').trim(); + return { + user_id: userId, + display_name: displayName || email || 'Unknown User', + email, + }; +} + +function normalizeCollaborationConversation(rawConversation = {}) { + return { + ...rawConversation, + conversation_kind: rawConversation.conversation_kind || 'collaborative', + last_updated: rawConversation.last_updated || rawConversation.updated_at || rawConversation.last_message_at || rawConversation.created_at || new Date().toISOString(), + classification: Array.isArray(rawConversation.classification) ? rawConversation.classification : [], + tags: Array.isArray(rawConversation.tags) ? rawConversation.tags : [], + context: Array.isArray(rawConversation.context) ? rawConversation.context : [], + is_pinned: Boolean(rawConversation.is_pinned), + is_hidden: Boolean(rawConversation.is_hidden), + has_unread_assistant_response: Boolean(rawConversation.has_unread_assistant_response), + }; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function getCachedUserSettings() { + if (!cachedUserSettingsPromise) { + cachedUserSettingsPromise = loadUserSettings().then(settings => settings || {}); + } + return cachedUserSettingsPromise; +} + +function setCachedUserSettings(settings = {}) { + cachedUserSettingsPromise = Promise.resolve(settings || {}); +} + +async function rememberRecentCollaborator(collaborator) { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return; + } + + const userSettings = await getCachedUserSettings(); + const existing = Array.isArray(userSettings[RECENT_COLLABORATORS_KEY]) + ? userSettings[RECENT_COLLABORATORS_KEY] + : []; + + const updatedCollaborators = [ + { + ...normalizedCollaborator, + last_used_at: new Date().toISOString(), + }, + ...existing.filter(item => String(item?.user_id || item?.userId || item?.id || '').trim() !== normalizedCollaborator.user_id), + ].slice(0, MAX_RECENT_COLLABORATORS); + + const nextSettings = { + ...userSettings, + [RECENT_COLLABORATORS_KEY]: updatedCollaborators, + }; + setCachedUserSettings(nextSettings); + saveUserSetting({ [RECENT_COLLABORATORS_KEY]: updatedCollaborators }); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + credentials: 'same-origin', + ...options, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || `Request failed (${response.status})`); + } + return payload; +} + +async function searchLocalCollaborators(query = '', options = {}) { + const search = new URLSearchParams(); + search.set('query', query); + search.set('limit', String(options.limit || DEFAULT_SUGGESTION_LIMIT)); + if (options.recentOnly) { + search.set('recent_only', 'true'); + } + + const payload = await fetchJson(`/api/user/collaboration-suggestions?${search.toString()}`); + return Array.isArray(payload.results) ? payload.results : []; +} + +function ensureTypingIndicator() { + let typingIndicator = document.getElementById('collaboration-typing-indicator'); + if (typingIndicator) { + return typingIndicator; + } + + const chatbox = document.getElementById('chatbox'); + if (!chatbox || !chatbox.parentElement) { + return null; + } + + typingIndicator = document.createElement('div'); + typingIndicator.id = 'collaboration-typing-indicator'; + typingIndicator.className = 'collaboration-typing-indicator d-none'; + chatbox.insertAdjacentElement('afterend', typingIndicator); + return typingIndicator; +} + +function renderTypingIndicator() { + const typingIndicator = ensureTypingIndicator(); + if (!typingIndicator) { + return; + } + + const now = Date.now(); + typingUsers.forEach((entry, userId) => { + const expiresAt = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0; + if (!expiresAt || expiresAt <= now) { + typingUsers.delete(userId); + } + }); + + if (typingUsers.size === 0) { + typingIndicator.textContent = ''; + typingIndicator.classList.add('d-none'); + return; + } + + const names = Array.from(typingUsers.values()) + .map(entry => entry.displayName) + .filter(Boolean); + + if (names.length === 1) { + typingIndicator.textContent = `${names[0]} is typing...`; + } else if (names.length === 2) { + typingIndicator.textContent = `${names[0]} and ${names[1]} are typing...`; + } else { + typingIndicator.textContent = `${names[0]} and ${names.length - 1} others are typing...`; + } + + typingIndicator.classList.remove('d-none'); +} + +function clearTypingState() { + typingUsers = new Map(); + renderTypingIndicator(); +} + +function updateComposerAvailability(metadata = null) { + if (!userInput || !sendBtn) { + return; + } + + if (!metadata || metadata.conversation_kind !== 'collaborative') { + userInput.disabled = false; + sendBtn.disabled = false; + userInput.placeholder = 'Type your message...'; + return; + } + + const canPostMessages = metadata.can_post_messages !== false; + userInput.disabled = !canPostMessages; + sendBtn.disabled = !canPostMessages; + + if (canPostMessages) { + userInput.placeholder = 'Type a shared message...'; + return; + } + + if (metadata.membership_status === 'pending') { + userInput.placeholder = 'Accept the invite before posting messages...'; + } else { + userInput.placeholder = 'You cannot post messages in this conversation.'; + } +} + +function resolveMessageSenderType(message) { + if (message.role === 'assistant') { + return 'AI'; + } + + const senderUserId = message.sender?.user_id || message.metadata?.sender?.user_id || null; + if (senderUserId && senderUserId === getCurrentUserId()) { + return 'You'; + } + + return 'Collaborator'; +} + +function getCurrentUserId() { + return String(window.currentUser?.id || window.currentUser?.user_id || '').trim(); +} + +function getLatestPendingCollaborativeMessageId() { + const pendingMessages = Array.from(document.querySelectorAll('[data-message-id^="temp_user_"]')); + if (pendingMessages.length === 0) { + return null; + } + + return pendingMessages[pendingMessages.length - 1].getAttribute('data-message-id'); +} + +function reconcilePendingCollaborativeUserMessage(message, preferredTempId = null) { + const senderUserId = String(message?.sender?.user_id || message?.metadata?.sender?.user_id || '').trim(); + if (!senderUserId || senderUserId !== getCurrentUserId()) { + return false; + } + + const realMessageId = String(message?.id || '').trim(); + if (!realMessageId) { + return false; + } + + const pendingMessageId = preferredTempId || getLatestPendingCollaborativeMessageId(); + const existingRealMessage = document.querySelector(`[data-message-id="${realMessageId}"]`); + + if (existingRealMessage && pendingMessageId) { + const pendingMessage = document.querySelector(`[data-message-id="${pendingMessageId}"]`); + if (pendingMessage) { + pendingMessage.remove(); + } + return true; + } + + if (pendingMessageId) { + updateUserMessageId(pendingMessageId, realMessageId); + return true; + } + + return Boolean(existingRealMessage); +} + +function renderCollaborationMessage(message, options = {}) { + if (!message || !message.id) { + return; + } + + if (document.querySelector(`[data-message-id="${message.id}"]`)) { + return; + } + + const senderType = resolveMessageSenderType(message); + appendMessage( + senderType, + message.content || '', + message.model_deployment_name || null, + message.id, + Boolean(message.augmented), + Array.isArray(message.hybrid_citations) ? message.hybrid_citations : [], + Array.isArray(message.web_search_citations) ? message.web_search_citations : [], + Array.isArray(message.agent_citations) ? message.agent_citations : [], + message.agent_display_name || null, + message.agent_name || null, + { + ...message, + metadata: message.metadata || {}, + sender: message.sender || {}, + }, + Boolean(options.isNewMessage) + ); +} + +async function loadConversationMessages(conversationId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`); + const chatbox = document.getElementById('chatbox'); + if (!chatbox) { + return []; + } + + chatbox.innerHTML = ''; + clearTypingState(); + + const messages = Array.isArray(payload.messages) ? payload.messages : []; + messages.forEach(message => renderCollaborationMessage(message)); + return messages; +} + +function handleTypingEvent(payload = {}) { + const currentUserId = getCurrentUserId(); + const typingUser = normalizeCollaborator(payload.user); + if (!typingUser || typingUser.user_id === currentUserId) { + return; + } + + if (payload.is_typing === false) { + typingUsers.delete(typingUser.user_id); + renderTypingIndicator(); + return; + } + + typingUsers.set(typingUser.user_id, { + displayName: typingUser.display_name, + expiresAt: payload.expires_at, + }); + renderTypingIndicator(); +} + +function disconnectConversationEvents() { + const previousConversationId = activeCollaborativeConversationId; + + if (activeCollaborationEventSource) { + activeCollaborationEventSource.close(); + activeCollaborationEventSource = null; + } + + if (typingStopHandle) { + window.clearTimeout(typingStopHandle); + typingStopHandle = null; + } + + setTypingState(false, { + force: true, + conversationId: previousConversationId, + }); + + activeCollaborativeConversationId = null; + clearTypingState(); + lastTypingState = false; +} + +function handleConversationEvent(eventEnvelope = {}) { + if (!eventEnvelope || !eventEnvelope.event_type) { + return; + } + + const payload = eventEnvelope.payload || {}; + if (payload.conversation) { + const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + setConversationDataset(normalizedConversation.id, normalizedConversation); + applyConversationMetadataUpdate(normalizedConversation.id, normalizedConversation); + if (!['collaboration.message.created', 'collaboration.typing.updated'].includes(eventEnvelope.event_type)) { + void fetchConversationMetadata(normalizedConversation.id).catch(() => {}); + } + } + + if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { + if (reconcilePendingCollaborativeUserMessage(payload.message)) { + return; + } + renderCollaborationMessage(payload.message, { isNewMessage: true }); + return; + } + + if (eventEnvelope.event_type === 'collaboration.typing.updated') { + handleTypingEvent(payload); + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.invited' && Array.isArray(payload.participants)) { + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.removed' && payload.participant?.display_name) { + if (String(payload.participant.user_id || '').trim() === getCurrentUserId()) { + showToast('You no longer have access to this shared conversation.', 'warning'); + window.chatConversations?.removeConversationFromUi?.(eventEnvelope.conversation_id || payload.conversation?.id, { + refreshList: true, + skipToast: true, + }); + return; + } + showToast(`${payload.participant.display_name} was removed from the conversation.`, 'info'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.role_updated' && payload.participant?.display_name) { + const roleLabel = payload.participant.role === 'admin' ? 'admin' : 'member'; + showToast(`${payload.participant.display_name} is now ${roleLabel}.`, 'success'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.invite.accepted' && payload.participant?.display_name) { + showToast(`${payload.participant.display_name} accepted the invite.`, 'success'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.deleted') { + showToast('This shared conversation was deleted.', 'warning'); + window.chatConversations?.removeConversationFromUi?.(eventEnvelope.conversation_id || payload.conversation?.id, { + refreshList: true, + skipToast: true, + }); + } +} + +function subscribeToConversationEvents(conversationId) { + if (!isCollaborationEnabled() || !conversationId || typeof EventSource === 'undefined') { + return; + } + + disconnectConversationEvents(); + activeCollaborativeConversationId = conversationId; + activeCollaborationEventSource = new EventSource(`/api/collaboration/conversations/${encodeURIComponent(conversationId)}/events`); + activeCollaborationEventSource.onmessage = event => { + if (!event?.data) { + return; + } + + try { + handleConversationEvent(JSON.parse(event.data)); + } catch (error) { + console.warn('Failed to parse collaboration event:', error); + } + }; + activeCollaborationEventSource.onerror = () => { + console.warn('Collaboration event stream disconnected.'); + }; +} + +async function fetchConversationMetadata(conversationId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}`); + const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + setConversationDataset(conversationId, normalizedConversation); + applyConversationMetadataUpdate(conversationId, normalizedConversation); + return normalizedConversation; +} + +function showPendingInviteToast(conversation) { + if (!conversation?.id || !conversation.can_accept_invite) { + return; + } + + if (notifiedPendingInviteConversationIds.has(conversation.id)) { + return; + } + + notifiedPendingInviteConversationIds.add(conversation.id); + const actionId = `collaboration-invite-review-${conversation.id}-${Date.now()}`; + showToast( + `You were invited to ${escapeHtml(conversation.title || 'a collaborative conversation')}. `, + 'warning' + ); + + window.setTimeout(() => { + const actionButton = document.getElementById(actionId); + if (!actionButton) { + return; + } + + actionButton.addEventListener('click', async event => { + event.preventDefault(); + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversation.id); + } + if (window.showConversationDetails) { + window.showConversationDetails(conversation.id); + } + }, { once: true }); + }, 0); +} + +function notifyPendingInvites(conversations = []) { + conversations.forEach(conversation => { + if (conversation?.can_accept_invite) { + showPendingInviteToast(conversation); + } + }); +} + +async function fetchCollaborationConversationList() { + if (!isCollaborationEnabled()) { + return []; + } + + const payload = await fetchJson('/api/collaboration/conversations?include_pending=true'); + const conversations = Array.isArray(payload.conversations) ? payload.conversations : []; + const normalizedConversations = conversations.map(conversation => normalizeCollaborationConversation(conversation)); + notifyPendingInvites(normalizedConversations); + return normalizedConversations; +} + +async function activateConversation(conversationId, metadata = null) { + const conversationMetadata = metadata || await fetchConversationMetadata(conversationId); + updateComposerAvailability(conversationMetadata); + await loadConversationMessages(conversationId); + subscribeToConversationEvents(conversationId); + + if (conversationMetadata.can_accept_invite && !promptedPendingInviteConversationIds.has(conversationId)) { + promptedPendingInviteConversationIds.add(conversationId); + showPendingInviteToast(conversationMetadata); + if (window.showConversationDetails) { + window.setTimeout(() => { + window.showConversationDetails(conversationId); + }, 0); + } + } + + return conversationMetadata; +} + +function deactivateConversation() { + disconnectConversationEvents(); + updateComposerAvailability(null); + hideMentionMenu(); +} + +async function sendCollaborativeMessage(messageText, tempMessageId = null) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + return null; + } + + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: messageText, + }), + }); + + if (payload.conversation) { + const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + setConversationDataset(conversationId, normalizedConversation); + applyConversationMetadataUpdate(conversationId, normalizedConversation); + } + + if (payload.message) { + if (!reconcilePendingCollaborativeUserMessage(payload.message, tempMessageId)) { + renderCollaborationMessage(payload.message, { isNewMessage: true }); + } + } + + setTypingState(false, { force: true }); + return payload; +} + +function setTypingState(isTyping, options = {}) { + const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId || !isCollaborationConversation(conversationId) || !canPostMessages(conversationId)) { + return; + } + + if (!options.force && lastTypingState === isTyping) { + return; + } + + lastTypingState = isTyping; + fetch(`/api/collaboration/conversations/${conversationId}/typing`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ is_typing: isTyping }), + }).catch(error => { + console.warn('Failed to update collaboration typing state:', error); + }); +} + +function scheduleTypingState() { + if (!isCollaborationConversation(window.chatConversations?.getCurrentConversationId?.())) { + return; + } + + const hasContent = Boolean(userInput?.value?.trim()); + setTypingState(hasContent); + + if (typingStopHandle) { + window.clearTimeout(typingStopHandle); + } + typingStopHandle = window.setTimeout(() => { + setTypingState(false); + }, 3000); +} + +function getMentionMatch() { + if (!userInput) { + return null; + } + + const selectionStart = typeof userInput.selectionStart === 'number' + ? userInput.selectionStart + : userInput.value.length; + const beforeCursor = userInput.value.slice(0, selectionStart); + const match = beforeCursor.match(/(^|\s)@([^\s@]*)$/); + if (!match) { + return null; + } + + const startIndex = selectionStart - match[2].length - 1; + return { + query: match[2] || '', + startIndex, + endIndex: selectionStart, + }; +} + +function buildSuggestionItemHtml(suggestion) { + const subtitle = suggestion.email + ? `
${suggestion.email}
` + : '
No email recorded
'; + const sourceLabel = suggestion.source === 'recent' + ? 'Recent' + : 'Local'; + + return ` +
+
+
${suggestion.display_name}
+ ${subtitle} +
+ ${sourceLabel} +
+ `; +} + +function hideMentionMenu() { + if (!mentionMenu) { + return; + } + + mentionMenu.innerHTML = ''; + mentionMenu.classList.add('d-none'); + activeMentionState = null; +} + +function renderMentionMenu(results, mentionState) { + if (!mentionMenu) { + return; + } + + if (!Array.isArray(results) || results.length === 0) { + mentionMenu.innerHTML = '
No local collaborators found.
'; + mentionMenu.classList.remove('d-none'); + activeMentionState = { + ...mentionState, + results: [], + activeIndex: -1, + }; + return; + } + + activeMentionState = { + ...mentionState, + results, + activeIndex: 0, + }; + + mentionMenu.innerHTML = ''; + results.forEach((result, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `list-group-item list-group-item-action collaboration-mention-item${index === 0 ? ' active' : ''}`; + button.innerHTML = buildSuggestionItemHtml(result); + button.setAttribute('data-index', String(index)); + button.addEventListener('mousedown', event => { + event.preventDefault(); + openParticipantConfirmation(result, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState, + }); + }); + mentionMenu.appendChild(button); + }); + mentionMenu.classList.remove('d-none'); +} + +function updateMentionMenuActiveItem() { + if (!mentionMenu || !activeMentionState) { + return; + } + + const items = mentionMenu.querySelectorAll('.collaboration-mention-item'); + items.forEach((item, index) => { + item.classList.toggle('active', index === activeMentionState.activeIndex); + }); +} + +async function refreshMentionSuggestions() { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId || !canUseParticipantFlow(conversationId)) { + hideMentionMenu(); + return; + } + + const mentionState = getMentionMatch(); + if (!mentionState) { + hideMentionMenu(); + return; + } + + const searchToken = ++mentionSearchToken; + try { + const results = await searchLocalCollaborators(mentionState.query, { recentOnly: false, limit: DEFAULT_SUGGESTION_LIMIT }); + if (searchToken !== mentionSearchToken) { + return; + } + renderMentionMenu(results, mentionState); + } catch (error) { + if (searchToken !== mentionSearchToken) { + return; + } + hideMentionMenu(); + console.warn('Failed to load mention suggestions:', error); + } +} + +function removeMentionFromComposer(mentionState) { + if (!userInput || !mentionState) { + return; + } + + const beforeMention = userInput.value.slice(0, mentionState.startIndex); + const afterMention = userInput.value.slice(mentionState.endIndex); + const nextValue = `${beforeMention}${afterMention}`.replace(/\s{2,}/g, ' ').trimStart(); + userInput.value = nextValue; + updateSendButtonVisibility(); + userInput.focus(); +} + +function renderParticipantResults(results, emptyMessage = 'No collaborators found.') { + if (!participantResults) { + return; + } + + if (!Array.isArray(results) || results.length === 0) { + participantResults.innerHTML = `
${emptyMessage}
`; + return; + } + + participantResults.innerHTML = ''; + results.forEach(result => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'list-group-item list-group-item-action'; + button.innerHTML = buildSuggestionItemHtml(result); + button.addEventListener('click', () => { + openParticipantConfirmation(result, { + conversationId: participantConversationIdInput?.value || window.chatConversations?.getCurrentConversationId?.(), + source: 'picker', + }); + }); + participantResults.appendChild(button); + }); +} + +async function refreshParticipantPickerResults(query = '') { + try { + const results = await searchLocalCollaborators(query, { recentOnly: false, limit: 12 }); + renderParticipantResults(results); + } catch (error) { + renderParticipantResults([], 'Failed to load collaborators.'); + console.warn('Failed to refresh participant picker results:', error); + } +} + +function openParticipantPicker(options = {}) { + const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + showToast('Select a conversation first.', 'warning'); + return; + } + + if (!canUseParticipantFlow(conversationId)) { + showToast('Participants can only be added to eligible personal conversations you manage.', 'warning'); + return; + } + + if (!participantModalEl || !participantSearchInput || !participantConversationIdInput) { + return; + } + + participantConversationIdInput.value = conversationId; + participantSearchInput.value = ''; + renderParticipantResults([], 'Loading collaborators...'); + bootstrap.Modal.getOrCreateInstance(participantModalEl).show(); + refreshParticipantPickerResults(''); +} + +function openParticipantConfirmation(userSummary, context = {}) { + const collaborator = normalizeCollaborator(userSummary); + if (!collaborator || !confirmModalEl || !confirmMessageEl) { + return; + } + + pendingParticipantConfirmation = { + collaborator, + context, + }; + confirmMessageEl.innerHTML = `Are you sure you want to add ${collaborator.display_name}${collaborator.email ? ` (${collaborator.email})` : ''} to this conversation?`; + bootstrap.Modal.getOrCreateInstance(confirmModalEl).show(); +} + +function canUseParticipantFlow(conversationId) { + const chatType = getConversationChatType(conversationId); + if (!chatType || !['personal_single_user', 'personal_multi_user'].includes(chatType)) { + return false; + } + + if (!isCollaborationConversation(conversationId)) { + return true; + } + + const item = getConversationDomItem(conversationId); + return item?.dataset?.canManageMembers === 'true'; +} + +function canPostMessages(conversationId) { + if (!isCollaborationConversation(conversationId)) { + return true; + } + + const item = getConversationDomItem(conversationId); + return item?.dataset?.canPostMessages !== 'false'; +} + +async function addParticipantToConversation(conversationId, collaborator) { + const isCollaborative = isCollaborationConversation(conversationId); + const endpoint = isCollaborative + ? `/api/collaboration/conversations/${conversationId}/members` + : `/api/collaboration/conversations/from-personal/${conversationId}/members`; + + const payload = await fetchJson(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + participants: [collaborator], + }), + }); + + const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + await rememberRecentCollaborator(collaborator); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + + if (normalizedConversation.id && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(normalizedConversation.id); + } + + setConversationDataset(normalizedConversation.id, normalizedConversation); + return { + ...payload, + conversation: normalizedConversation, + }; +} + +async function confirmPendingParticipant() { + if (!pendingParticipantConfirmation) { + return; + } + + const { collaborator, context } = pendingParticipantConfirmation; + const conversationId = context.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + return; + } + + confirmAddBtn.disabled = true; + try { + const payload = await addParticipantToConversation(conversationId, collaborator); + bootstrap.Modal.getOrCreateInstance(confirmModalEl).hide(); + bootstrap.Modal.getOrCreateInstance(participantModalEl)?.hide(); + + if (context.source === 'mention' && context.mentionState) { + removeMentionFromComposer(context.mentionState); + hideMentionMenu(); + } + + showToast( + payload.created + ? 'Conversation converted to a collaborative chat and participant invited.' + : 'Participant invited to the conversation.', + 'success' + ); + + const detailsModalVisible = document.getElementById('conversation-details-modal')?.classList.contains('show'); + if (detailsModalVisible && window.showConversationDetails && payload.conversation?.id) { + window.showConversationDetails(payload.conversation.id); + } + } catch (error) { + showToast(error.message || 'Failed to add participant.', 'danger'); + } finally { + confirmAddBtn.disabled = false; + pendingParticipantConfirmation = null; + } +} + +async function respondToInvite(conversationId, action) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/invite-response`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action }), + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + + if (action === 'accept') { + notifiedPendingInviteConversationIds.delete(conversationId); + promptedPendingInviteConversationIds.delete(conversationId); + } + + if (action === 'accept' && payload.conversation?.id && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(payload.conversation.id); + } + + window.hideConversationDetails?.(); + showToast(action === 'accept' ? 'Invite accepted.' : 'Invite declined.', 'success'); + return payload; +} + +async function removeParticipant(conversationId, memberUserId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/members/${encodeURIComponent(memberUserId)}`, { + method: 'DELETE', + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversationId); + } + + showToast('Participant removed from the conversation.', 'success'); + if (window.showConversationDetails) { + window.showConversationDetails(conversationId); + } + return payload; +} + +async function updateParticipantRole(conversationId, memberUserId, role) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/members/${encodeURIComponent(memberUserId)}/role`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ role }), + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversationId); + } + + showToast(role === 'admin' ? 'Participant promoted to admin.' : 'Participant admin access removed.', 'success'); + if (window.showConversationDetails) { + window.showConversationDetails(conversationId); + } + return payload; +} + +function handleComposerInput() { + if (!isCollaborationEnabled()) { + return; + } + + scheduleTypingState(); + void refreshMentionSuggestions(); +} + +function handleComposerKeydown(event) { + if (!activeMentionState || mentionMenu?.classList.contains('d-none')) { + return false; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + activeMentionState.activeIndex = Math.min(activeMentionState.activeIndex + 1, Math.max(activeMentionState.results.length - 1, 0)); + updateMentionMenuActiveItem(); + return true; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + activeMentionState.activeIndex = Math.max(activeMentionState.activeIndex - 1, 0); + updateMentionMenuActiveItem(); + return true; + } + + if (event.key === 'Enter' && activeMentionState.activeIndex >= 0) { + event.preventDefault(); + const collaborator = activeMentionState.results[activeMentionState.activeIndex]; + if (collaborator) { + openParticipantConfirmation(collaborator, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState: activeMentionState, + }); + } + return true; + } + + if (event.key === 'Escape') { + hideMentionMenu(); + return true; + } + + return false; +} + +function handleComposerBlur() { + window.setTimeout(() => { + hideMentionMenu(); + }, 100); +} + +function initializeUi() { + if (!isCollaborationEnabled()) { + return; + } + + if (participantSearchInput) { + participantSearchInput.addEventListener('input', event => { + void refreshParticipantPickerResults(event.target.value || ''); + }); + } + + if (participantModalEl) { + participantModalEl.addEventListener('shown.bs.modal', () => { + participantSearchInput?.focus(); + }); + } + + if (confirmAddBtn) { + confirmAddBtn.addEventListener('click', () => { + void confirmPendingParticipant(); + }); + } + + document.addEventListener('click', event => { + if (!mentionMenu || mentionMenu.classList.contains('d-none')) { + return; + } + + const withinMentionMenu = mentionMenu.contains(event.target); + if (!withinMentionMenu && event.target !== userInput) { + hideMentionMenu(); + } + }); +} + +window.chatCollaboration = { + activateConversation, + deactivateConversation, + fetchCollaborationConversationList, + fetchConversationMetadata, + handleComposerBlur, + handleComposerInput, + handleComposerKeydown, + isCollaborationConversation, + openParticipantPicker, + removeParticipant, + respondToInvite, + sendCollaborativeMessage, + updateParticipantRole, + canUseParticipantFlow, +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeUi); +} else { + initializeUi(); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index c8438617..dac9ad1c 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -5,20 +5,105 @@ import { isColorLight } from "./chat-utils.js"; +function getConversationDetailsModalElements() { + return { + modal: document.getElementById('conversation-details-modal'), + modalTitle: document.getElementById('conversationDetailsModalLabel'), + content: document.getElementById('conversation-details-content'), + actionContainer: document.getElementById('conversation-details-actions'), + }; +} + +function cleanupConversationDetailsModalState() { + const anyVisibleModal = document.querySelector('.modal.show'); + if (anyVisibleModal) { + return; + } + + document.querySelectorAll('.modal-backdrop').forEach(backdrop => backdrop.remove()); + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('padding-right'); +} + +function getConversationDetailsModalInstance() { + const { modal } = getConversationDetailsModalElements(); + if (!modal || !window.bootstrap?.Modal) { + return null; + } + return bootstrap.Modal.getOrCreateInstance(modal); +} + +export function hideConversationDetails() { + const { modal } = getConversationDetailsModalElements(); + const modalInstance = getConversationDetailsModalInstance(); + if (!modal || !modalInstance) { + cleanupConversationDetailsModalState(); + return; + } + + if (modal.classList.contains('show')) { + modalInstance.hide(); + window.setTimeout(cleanupConversationDetailsModalState, 200); + return; + } + + cleanupConversationDetailsModalState(); +} + +function renderConversationDetailsActions(metadata, conversationId) { + const { actionContainer } = getConversationDetailsModalElements(); + if (!actionContainer) { + return; + } + + if (!metadata || !conversationId) { + actionContainer.innerHTML = ''; + return; + } + + const actionButtons = []; + + if (window.chatExport?.openExportWizard) { + actionButtons.push(` + + `); + } + + const isCollaborativeConversation = metadata.conversation_kind === 'collaborative'; + const canShowDeleteAction = isCollaborativeConversation + ? Boolean(metadata.can_delete_conversation || metadata.can_leave_conversation) + : true; + + if (canShowDeleteAction) { + const deleteLabel = isCollaborativeConversation + ? (metadata.can_delete_conversation ? 'Delete / Leave' : 'Leave') + : 'Delete'; + actionButtons.push(` + + `); + } + + actionContainer.innerHTML = actionButtons.join(''); +} + /** * Show conversation details in a modal * @param {string} conversationId - The conversation ID to show details for */ export async function showConversationDetails(conversationId) { - const modal = document.getElementById('conversation-details-modal'); - const modalTitle = document.getElementById('conversationDetailsModalLabel'); - const content = document.getElementById('conversation-details-content'); + const { modal, modalTitle, content } = getConversationDetailsModalElements(); if (!modal || !content) { console.error('Conversation details modal not found'); return; } + renderConversationDetailsActions(null, null); + // Show loading state content.innerHTML = `
@@ -30,19 +115,29 @@ export async function showConversationDetails(conversationId) { `; // Show the modal - const bsModal = new bootstrap.Modal(modal); - bsModal.show(); + const bsModal = getConversationDetailsModalInstance(); + if (bsModal && !modal.classList.contains('show')) { + bsModal.show(); + } try { - // Fetch conversation metadata - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const conversationItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = conversationItem?.dataset?.conversationKind === 'collaborative'; + let metadata = null; + + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + metadata = await response.json(); } - const metadata = await response.json(); - // Update modal title with conversation title, pin icon, and hidden icon const pinIcon = metadata.is_pinned ? '' : ''; const hiddenIcon = metadata.is_hidden ? '' : ''; @@ -53,9 +148,12 @@ export async function showConversationDetails(conversationId) { // Render the metadata content.innerHTML = renderConversationMetadata(metadata, conversationId); + renderConversationDetailsActions(metadata, conversationId); + attachConversationDetailActions(metadata, conversationId); } catch (error) { console.error('Error fetching conversation details:', error); + renderConversationDetailsActions(null, null); content.innerHTML = `
@@ -75,7 +173,32 @@ export async function showConversationDetails(conversationId) { * @returns {string} HTML string */ function renderConversationMetadata(metadata, conversationId) { - const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [], summary = null } = metadata; + const { + context = [], + tags = [], + strict = false, + classification = [], + last_updated, + updated_at, + chat_type = 'personal', + is_pinned = false, + is_hidden = false, + scope_locked, + locked_contexts = [], + summary = null, + conversation_kind = null, + participants = [], + membership_status = null, + can_manage_members = false, + can_manage_roles = false, + can_accept_invite = false, + can_post_messages = true, + can_delete_conversation = false, + can_leave_conversation = false, + current_user_role = '', + pending_invite_count = 0, + } = metadata; + const resolvedLastUpdated = last_updated || updated_at; // Organize tags by category const tagsByCategory = { @@ -94,6 +217,13 @@ function renderConversationMetadata(metadata, conversationId) { } }); + const participantRecords = Array.isArray(participants) && participants.length > 0 + ? participants + : tagsByCategory.participant; + const collaborationStatusHtml = conversation_kind === 'collaborative' + ? renderCollaborationMembershipStatus(membership_status, can_post_messages, pending_invite_count) + : `${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''}`; + // Build HTML sections let html = `
@@ -121,7 +251,7 @@ function renderConversationMetadata(metadata, conversationId) { Conversation ID: ${conversationId}
- Last Updated: ${formatDate(last_updated)} + Last Updated: ${formatDate(resolvedLastUpdated)}
Strict Mode: ${strict ? 'Enabled' : 'Disabled'} @@ -133,11 +263,16 @@ function renderConversationMetadata(metadata, conversationId) { Classifications: ${formatClassifications(classification)}
- Status: ${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''} + Status: ${collaborationStatusHtml}
Scope Lock: ${formatScopeLockStatus(scope_locked, locked_contexts)}
+ ${conversation_kind === 'collaborative' ? ` +
+ Your Role: ${formatCollaborationRole(current_user_role, can_delete_conversation, can_leave_conversation)} +
+ ` : ''}
@@ -161,15 +296,20 @@ function renderConversationMetadata(metadata, conversationId) { } // Participants Section - if (tagsByCategory.participant.length > 0) { + if (participantRecords.length > 0 || can_manage_members || can_accept_invite) { html += `
-
+
Participants
+ ${renderCollaborationActionButtons(conversationId, metadata)}
- ${renderParticipantsSection(tagsByCategory.participant)} + ${renderParticipantsSection(participantRecords, { + canManageMembers: can_manage_members, + canManageRoles: can_manage_roles, + conversationKind: conversation_kind, + })}
@@ -244,6 +384,70 @@ function renderConversationMetadata(metadata, conversationId) { return html; } +function renderCollaborationMembershipStatus(membershipStatus, canPostMessages, pendingInviteCount) { + if (!membershipStatus) { + return 'Normal'; + } + + const badges = []; + if (membershipStatus === 'accepted' || membershipStatus === 'group_member') { + badges.push('Active member'); + } + if (membershipStatus === 'pending') { + badges.push('Invite pending'); + } + if (!canPostMessages) { + badges.push('Read-only'); + } + if (pendingInviteCount > 0) { + badges.push(`${pendingInviteCount} pending`); + } + + return badges.join(' ') || 'Normal'; +} + +function formatCollaborationRole(currentUserRole, canDeleteConversation, canLeaveConversation) { + if (!currentUserRole) { + return 'Participant'; + } + + if (currentUserRole === 'owner') { + return `Owner${canDeleteConversation ? ' Can delete for everyone' : ''}`; + } + if (currentUserRole === 'admin') { + return 'Admin Can invite members'; + } + if (canLeaveConversation) { + return 'Member'; + } + return 'Participant'; +} + +function renderCollaborationActionButtons(conversationId, metadata) { + if (metadata.can_accept_invite) { + return ` +
+ + +
+ `; + } + + if (metadata.can_manage_members) { + return ` + + `; + } + + return ''; +} + /** * Render context section */ @@ -255,7 +459,7 @@ function renderContextSection(context) { if (primary) { const displayName = primary.name || primary.id; - const isGroupChat = primary.scope === 'group'; + const groupContextBadge = primary.scope === 'group' ? 'group' : ''; html += `
@@ -263,7 +467,7 @@ function renderContextSection(context) {
${primary.scope} - ${isGroupChat ? 'single-user' : ''} + ${groupContextBadge} ${displayName}
${primary.name ? `
ID: ${primary.id}
` : ''} @@ -299,22 +503,68 @@ function renderContextSection(context) { /** * Render participants section */ -function renderParticipantsSection(participants) { +function renderParticipantsSection(participants, options = {}) { let html = ''; participants.forEach(participant => { - const initials = (participant.name || 'U').slice(0, 2).toUpperCase(); + const displayName = participant.display_name || participant.name || 'Unknown User'; + const participantStatus = participant.status || null; + const participantRole = participant.role || null; + const initials = displayName.slice(0, 2).toUpperCase(); const avatarId = `participant-avatar-${participant.user_id}`; + const canRemoveParticipant = Boolean(options.canManageMembers) + && options.conversationKind === 'collaborative' + && participantRole !== 'owner'; + const canToggleAdmin = Boolean(options.canManageRoles) + && options.conversationKind === 'collaborative' + && participantRole !== 'owner' + && participantStatus === 'accepted'; + + let statusBadgesHtml = ''; + if (participantRole === 'owner') { + statusBadgesHtml += 'Owner'; + } + if (participantRole === 'admin') { + statusBadgesHtml += 'Admin'; + } + if (participantStatus === 'pending') { + statusBadgesHtml += 'Pending'; + } + if (participantStatus === 'removed') { + statusBadgesHtml += 'Removed'; + } + if (participantStatus === 'declined') { + statusBadgesHtml += 'Declined'; + } + + const participantActions = []; + if (canToggleAdmin) { + const nextRole = participantRole === 'admin' ? 'member' : 'admin'; + const roleActionLabel = participantRole === 'admin' ? 'Remove admin' : 'Make admin'; + participantActions.push(` + + `); + } + if (canRemoveParticipant) { + participantActions.push(` + + `); + } html += ` -
+
${initials}
-
-
${participant.name || 'Unknown User'}
+
+
${displayName}${statusBadgesHtml}
${participant.email || ''}
+ ${participantActions.length > 0 ? `
${participantActions.join('')}
` : ''}
`; }); @@ -329,6 +579,67 @@ function renderParticipantsSection(participants) { return html; } +function attachConversationDetailActions(metadata, conversationId) { + const addParticipantBtn = document.querySelector('[data-collaboration-action="add-participant"]'); + const acceptInviteBtn = document.querySelector('[data-collaboration-action="accept-invite"]'); + const declineInviteBtn = document.querySelector('[data-collaboration-action="decline-invite"]'); + const removeParticipantButtons = document.querySelectorAll('[data-collaboration-action="remove-participant"]'); + const roleButtons = document.querySelectorAll('[data-collaboration-action="toggle-participant-role"]'); + const exportConversationBtn = document.querySelector('[data-conversation-action="export"]'); + const deleteConversationBtn = document.querySelector('[data-conversation-action="delete"]'); + + if (addParticipantBtn) { + addParticipantBtn.addEventListener('click', () => { + window.chatCollaboration?.openParticipantPicker?.({ conversationId }); + }); + } + + if (acceptInviteBtn) { + acceptInviteBtn.addEventListener('click', () => { + window.chatCollaboration?.respondToInvite?.(conversationId, 'accept'); + }); + } + + if (declineInviteBtn) { + declineInviteBtn.addEventListener('click', () => { + window.chatCollaboration?.respondToInvite?.(conversationId, 'decline'); + }); + } + + removeParticipantButtons.forEach(button => { + button.addEventListener('click', () => { + const memberUserId = button.getAttribute('data-member-user-id'); + if (!memberUserId) { + return; + } + window.chatCollaboration?.removeParticipant?.(conversationId, memberUserId); + }); + }); + + roleButtons.forEach(button => { + button.addEventListener('click', () => { + const memberUserId = button.getAttribute('data-member-user-id'); + const nextRole = button.getAttribute('data-next-role'); + if (!memberUserId || !nextRole) { + return; + } + window.chatCollaboration?.updateParticipantRole?.(conversationId, memberUserId, nextRole); + }); + }); + + if (exportConversationBtn) { + exportConversationBtn.addEventListener('click', () => { + window.chatExport?.openExportWizard?.([conversationId], true); + }); + } + + if (deleteConversationBtn) { + deleteConversationBtn.addEventListener('click', () => { + window.chatConversations?.deleteConversation?.(conversationId); + }); + } +} + /** * Load profile image for a participant */ @@ -751,3 +1062,20 @@ document.addEventListener('click', function(e) { // Export functions for external use window.showConversationDetails = showConversationDetails; +window.hideConversationDetails = hideConversationDetails; + +function initializeConversationDetailsModal() { + const { modal } = getConversationDetailsModalElements(); + if (!modal || modal.dataset.initialized === 'true') { + return; + } + + modal.dataset.initialized = 'true'; + modal.addEventListener('hidden.bs.modal', cleanupConversationDetailsModalState); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeConversationDetailsModal); +} else { + initializeConversationDetailsModal(); +} diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 560571e9..31eb2896 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -23,6 +23,15 @@ const conversationsList = document.getElementById("conversations-list"); const currentConversationTitleEl = document.getElementById("current-conversation-title"); const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications"); const chatbox = document.getElementById("chatbox"); +const deleteConversationModalEl = document.getElementById("delete-conversation-modal"); +const deleteConversationMessageEl = document.getElementById("delete-conversation-message"); +const deleteConversationSharedWarningEl = document.getElementById("delete-conversation-shared-warning"); +const deleteConversationOwnerOptionsEl = document.getElementById("delete-conversation-owner-options"); +const deleteConversationTransferOptionEl = document.getElementById("delete-conversation-transfer-option"); +const deleteConversationOwnerSelectContainerEl = document.getElementById("delete-conversation-owner-select-container"); +const deleteConversationNewOwnerSelectEl = document.getElementById("delete-conversation-new-owner-select"); +const deleteConversationImpactNoteEl = document.getElementById("delete-conversation-impact-note"); +const confirmDeleteConversationBtn = document.getElementById("confirm-delete-conversation-btn"); // Track selected conversations let selectedConversations = new Set(); @@ -94,6 +103,7 @@ let showQuickSearch = false; // Track if quick search input is visible let quickSearchTerm = ""; // Current search term let pendingConversationCreation = null; // Reuse a single in-flight create request const markConversationReadRequests = new Map(); +let pendingDeleteConversationContext = null; function createUnreadDotElement() { const unreadDot = document.createElement("span"); @@ -226,16 +236,42 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { convoItem.setAttribute('data-conversation-title', updates.title); const titleElement = convoItem.querySelector('.conversation-title'); if (titleElement) { - const pinIcon = titleElement.querySelector('.bi-pin-angle'); + const existingIcons = Array.from(titleElement.querySelectorAll('i')).map(icon => icon.cloneNode(true)); titleElement.innerHTML = ''; - if (pinIcon) { - titleElement.appendChild(pinIcon); - } + existingIcons.forEach(icon => titleElement.appendChild(icon)); titleElement.appendChild(document.createTextNode(updates.title)); titleElement.title = updates.title; } } + if (updates.conversation_kind) { + convoItem.dataset.conversationKind = updates.conversation_kind; + } + if (Object.prototype.hasOwnProperty.call(updates, 'membership_status') && updates.membership_status) { + convoItem.dataset.membershipStatus = updates.membership_status; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_members')) { + convoItem.dataset.canManageMembers = updates.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_roles')) { + convoItem.dataset.canManageRoles = updates.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_accept_invite')) { + convoItem.dataset.canAcceptInvite = updates.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_post_messages')) { + convoItem.dataset.canPostMessages = updates.can_post_messages === false ? 'false' : 'true'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_delete_conversation')) { + convoItem.dataset.canDeleteConversation = updates.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_leave_conversation')) { + convoItem.dataset.canLeaveConversation = updates.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'current_user_role')) { + convoItem.dataset.currentUserRole = updates.current_user_role || ''; + } + if (Array.isArray(updates.classification)) { convoItem.dataset.classifications = JSON.stringify(updates.classification); } @@ -252,6 +288,15 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { classification: updates.classification, context: updates.context, chat_type: updates.chat_type, + conversation_kind: updates.conversation_kind, + membership_status: updates.membership_status, + can_manage_members: updates.can_manage_members, + can_manage_roles: updates.can_manage_roles, + can_accept_invite: updates.can_accept_invite, + can_post_messages: updates.can_post_messages, + can_delete_conversation: updates.can_delete_conversation, + can_leave_conversation: updates.can_leave_conversation, + current_user_role: updates.current_user_role, }); applySidebarConversationMetadataUpdate(conversationId, updates); @@ -377,6 +422,21 @@ document.addEventListener('DOMContentLoaded', () => { if (deleteSelectedBtn) { deleteSelectedBtn.style.display = "none"; } + + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.addEventListener('click', () => { + void executeDeleteConversationAction(); + }); + } + + if (deleteConversationModalEl) { + deleteConversationModalEl.addEventListener('hidden.bs.modal', () => { + resetDeleteConversationModalState(); + }); + deleteConversationModalEl.querySelectorAll('input[name="delete-conversation-action"]').forEach(input => { + input.addEventListener('change', toggleDeleteConversationTransferInputs); + }); + } // Set up quick search event listeners const searchBtn = document.getElementById('sidebar-search-btn'); @@ -594,11 +654,24 @@ export function loadConversations() { isLoadingConversations = true; conversationsList.innerHTML = '
Loading conversations...
'; // Loading state - return fetch("/api/get_conversations") - .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))) - .then(data => { + const legacyConversationsRequest = fetch("/api/get_conversations") + .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(error => { + console.warn('Failed to load collaborative conversations:', error); + return []; + }) + : Promise.resolve([]); + + return Promise.all([legacyConversationsRequest, collaborationConversationsRequest]) + .then(([data, collaborationConversations]) => { conversationsList.innerHTML = ""; // Clear loading state - if (!data.conversations || data.conversations.length === 0) { + const mergedConversations = [ + ...(Array.isArray(data.conversations) ? data.conversations : []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; + + if (mergedConversations.length === 0) { conversationsList.innerHTML = '
No conversations yet.
'; allConversations = []; updateHiddenToggleButton(); @@ -606,7 +679,7 @@ export function loadConversations() { } // Store all conversations for client-side operations - allConversations = data.conversations; + allConversations = mergedConversations; // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) const sortedConversations = [...allConversations].sort((a, b) => { @@ -671,18 +744,27 @@ export async function ensureConversationPresent(conversationId) { if (existing) return existing; // Fetch metadata to validate ownership and get details + let metadata = null; const res = await fetch(`/api/conversations/${conversationId}/metadata`); - if (!res.ok) { + if (res.ok) { + metadata = await res.json(); + } else if (window.chatCollaboration?.fetchConversationMetadata) { + try { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } catch (error) { + const err = await res.json().catch(() => ({})); + throw new Error(error.message || err.error || `Failed to load conversation ${conversationId}`); + } + } else { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Failed to load conversation ${conversationId}`); } - const metadata = await res.json(); // Build a conversation object compatible with createConversationItem const convo = { id: conversationId, title: metadata.title || 'Conversation', - last_updated: metadata.last_updated || new Date().toISOString(), + last_updated: metadata.last_updated || metadata.updated_at || new Date().toISOString(), classification: metadata.classification || [], context: metadata.context || [], chat_type: metadata.chat_type || null, @@ -691,6 +773,15 @@ export async function ensureConversationPresent(conversationId) { has_unread_assistant_response: metadata.has_unread_assistant_response || false, last_unread_assistant_message_id: metadata.last_unread_assistant_message_id || null, last_unread_assistant_at: metadata.last_unread_assistant_at || null, + conversation_kind: metadata.conversation_kind || null, + membership_status: metadata.membership_status || null, + can_manage_members: metadata.can_manage_members || false, + can_manage_roles: metadata.can_manage_roles || false, + can_accept_invite: metadata.can_accept_invite || false, + can_post_messages: metadata.can_post_messages !== false, + can_delete_conversation: metadata.can_delete_conversation || false, + can_leave_conversation: metadata.can_leave_conversation || false, + current_user_role: metadata.current_user_role || '', }; // Keep allConversations in sync @@ -713,6 +804,32 @@ export function createConversationItem(convo) { convoItem.setAttribute("data-conversation-id", convo.id); convoItem.setAttribute("data-conversation-title", convo.title); // Store title too convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? "true" : "false"; + const isCollaborativeConversation = convo.conversation_kind === 'collaborative'; + const conversationChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type; + const canManageMembers = isCollaborativeConversation + ? Boolean(convo.can_manage_members) + : conversationChatType === 'personal_single_user'; + const canManageRoles = isCollaborativeConversation ? Boolean(convo.can_manage_roles) : false; + const canEditCollaborativeTitle = !isCollaborativeConversation || canManageRoles; + const canShowAddParticipants = ['personal_single_user', 'personal_multi_user'].includes(conversationChatType || '') + && canManageMembers; + const canDeleteCollaborativeConversation = Boolean(convo.can_delete_conversation); + const canLeaveCollaborativeConversation = Boolean(convo.can_leave_conversation); + const collaborativeDeleteLabel = canDeleteCollaborativeConversation ? 'Delete / Leave' : 'Leave'; + + if (isCollaborativeConversation) { + convoItem.dataset.conversationKind = 'collaborative'; + } + if (convo.membership_status) { + convoItem.dataset.membershipStatus = convo.membership_status; + } + convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false'; + convoItem.dataset.canManageRoles = canManageRoles ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = convo.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = convo.can_post_messages === false ? 'false' : 'true'; + convoItem.dataset.canDeleteConversation = canDeleteCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.canLeaveConversation = canLeaveCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.currentUserRole = convo.current_user_role || ''; // *** Store classification data as stringified JSON *** convoItem.dataset.classifications = JSON.stringify(convo.classification || []); @@ -826,6 +943,13 @@ export function createConversationItem(convo) { pinIcon.classList.add("bi", "bi-pin-angle", "me-1"); titleSpan.appendChild(pinIcon); } + + if (isCollaborativeConversation) { + const collaborationIcon = document.createElement("i"); + collaborationIcon.classList.add("bi", "bi-people", "me-1"); + collaborationIcon.title = "Collaborative conversation"; + titleSpan.appendChild(collaborationIcon); + } titleSpan.appendChild(document.createTextNode(convo.title)); titleSpan.title = convo.title; // Tooltip for full title @@ -868,6 +992,17 @@ export function createConversationItem(convo) { detailsA.innerHTML = 'Details'; detailsLi.appendChild(detailsA); + let addParticipantsA = null; + let addParticipantsLi = null; + if (canShowAddParticipants) { + addParticipantsLi = document.createElement("li"); + addParticipantsA = document.createElement("a"); + addParticipantsA.classList.add("dropdown-item", "add-participants-btn"); + addParticipantsA.href = "#"; + addParticipantsA.innerHTML = 'Add participants'; + addParticipantsLi.appendChild(addParticipantsA); + } + // Add Pin option const pinLi = document.createElement("li"); const pinA = document.createElement("a"); @@ -915,17 +1050,32 @@ export function createConversationItem(convo) { const deleteA = document.createElement("a"); deleteA.classList.add("dropdown-item", "delete-btn", "text-danger"); deleteA.href = "#"; - deleteA.innerHTML = 'Delete'; + deleteA.innerHTML = `${isCollaborativeConversation ? collaborativeDeleteLabel : 'Delete'}`; deleteLi.appendChild(deleteA); dropdownMenu.appendChild(detailsLi); - dropdownMenu.appendChild(pinLi); - dropdownMenu.appendChild(hideLi); - dropdownMenu.appendChild(selectLi); - dropdownMenu.appendChild(exportLi); - - dropdownMenu.appendChild(editLi); - dropdownMenu.appendChild(deleteLi); + if (addParticipantsLi) { + dropdownMenu.appendChild(addParticipantsLi); + } + if (isCollaborativeConversation) { + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); + dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + if (canEditCollaborativeTitle) { + dropdownMenu.appendChild(editLi); + } + if (canDeleteCollaborativeConversation || canLeaveCollaborativeConversation) { + dropdownMenu.appendChild(deleteLi); + } + } else { + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); + dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + dropdownMenu.appendChild(editLi); + dropdownMenu.appendChild(deleteLi); + } rightDiv.appendChild(dropdownBtn); rightDiv.appendChild(dropdownMenu); @@ -951,6 +1101,15 @@ export function createConversationItem(convo) { selectConversation(convo.id); }); + if (addParticipantsA) { + addParticipantsA.addEventListener("click", event => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + window.chatCollaboration?.openParticipantPicker?.({ conversationId: convo.id }); + }); + } + editA.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); @@ -1124,7 +1283,13 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, const newSpan = document.createElement("span"); newSpan.classList.add("conversation-title", "text-truncate"); - newSpan.textContent = convo.title; + if (convoItem.dataset.conversationKind === 'collaborative') { + const collaborationIcon = document.createElement("i"); + collaborationIcon.classList.add("bi", "bi-people", "me-1"); + collaborationIcon.title = "Collaborative conversation"; + newSpan.appendChild(collaborationIcon); + } + newSpan.appendChild(document.createTextNode(convo.title)); newSpan.title = convo.title; // Add tooltip back input.replaceWith(newSpan); // Replace input with updated span @@ -1137,7 +1302,10 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, } export async function updateConversationTitle(conversationId, newTitle) { - const response = await fetch(`/api/conversations/${conversationId}`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}` : `/api/conversations/${conversationId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle }), @@ -1208,12 +1376,21 @@ export async function selectConversation(conversationId) { } const conversationTitle = convoItem.getAttribute("data-conversation-title") || "Conversation"; // Use stored title + const isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative'; + let metadata = null; // Fetch the latest conversation metadata to get accurate chat_type, pin, and hide status try { - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - if (response.ok) { - const metadata = await response.json(); + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (response.ok) { + metadata = await response.json(); + } + } + + if (metadata) { // Update Header Title with pin icon and hidden status if (currentConversationTitleEl) { @@ -1324,6 +1501,16 @@ export async function selectConversation(conversationId) { const metaScopeLocked = metadata.scope_locked !== undefined ? metadata.scope_locked : null; const metaLockedContexts = metadata.locked_contexts || []; restoreScopeLockState(metaScopeLocked, metaLockedContexts); + + convoItem.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = metadata.can_post_messages === false ? 'false' : 'true'; + if (metadata.membership_status) { + convoItem.dataset.membershipStatus = metadata.membership_status; + } + if (metadata.conversation_kind) { + convoItem.dataset.conversationKind = metadata.conversation_kind; + } } } catch (error) { console.warn('Failed to fetch conversation metadata:', error); @@ -1335,16 +1522,21 @@ export async function selectConversation(conversationId) { renderConversationHeaderBadges(convoItem); } - await loadMessages(conversationId); - try { - const streamingModule = await import('./chat-streaming.js'); - await streamingModule.reattachStreamingConversation(conversationId); - } catch (error) { - console.warn('Failed to reattach active stream for conversation:', error); + if (isCollaborativeConversation && window.chatCollaboration?.activateConversation) { + await window.chatCollaboration.activateConversation(conversationId, metadata); + } else { + window.chatCollaboration?.deactivateConversation?.(); + await loadMessages(conversationId); + try { + const streamingModule = await import('./chat-streaming.js'); + await streamingModule.reattachStreamingConversation(conversationId); + } catch (error) { + console.warn('Failed to reattach active stream for conversation:', error); + } + markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { + console.warn('Failed to clear unread state for conversation:', error); + }); } - markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { - console.warn('Failed to clear unread state for conversation:', error); - }); highlightSelectedConversation(conversationId); // Show the conversation info button since we have an active conversation @@ -1388,45 +1580,260 @@ export function highlightSelectedConversation(conversationId) { }); } -// Delete a conversation -export function deleteConversation(conversationId) { - if (!confirm("Are you sure you want to delete this conversation? This action cannot be undone.")) { +function getConversationFromCache(conversationId) { + return allConversations.find(conversation => conversation.id === conversationId) || null; +} + +function toggleDeleteConversationTransferInputs() { + if (!deleteConversationOwnerSelectContainerEl || !deleteConversationModalEl || !confirmDeleteConversationBtn) { return; } - // Optionally show loading state on the item being deleted + const selectedAction = deleteConversationModalEl.querySelector('input[name="delete-conversation-action"]:checked')?.value; + deleteConversationOwnerSelectContainerEl.classList.toggle('d-none', selectedAction !== 'leave'); - fetch(`/api/conversations/${conversationId}`, { method: "DELETE" }) - .then(response => { - if (response.ok) { - const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`); - if (convoItem) convoItem.remove(); - - // If the deleted conversation was the current one, reset the chat view - if (currentConversationId === conversationId) { - currentConversationId = null; - if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation"; - if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; // Clear classifications - if (chatbox) chatbox.innerHTML = '
Select a conversation to view messages.
'; // Reset chatbox - highlightSelectedConversation(null); // Deselect all - toggleConversationInfoButton(false); // Hide the info button - } - - // Also reload sidebar conversations if the sidebar exists - if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { - window.chatSidebarConversations.loadSidebarConversations(); + if (selectedAction === 'leave') { + confirmDeleteConversationBtn.classList.remove('btn-danger'); + confirmDeleteConversationBtn.classList.add('btn-warning'); + confirmDeleteConversationBtn.innerHTML = 'Assign Owner & Leave'; + return; + } + + confirmDeleteConversationBtn.classList.remove('btn-warning'); + confirmDeleteConversationBtn.classList.add('btn-danger'); + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; +} + +function resetDeleteConversationModalState() { + pendingDeleteConversationContext = null; + + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?'; + } + if (deleteConversationSharedWarningEl) { + deleteConversationSharedWarningEl.classList.add('d-none'); + } + if (deleteConversationOwnerOptionsEl) { + deleteConversationOwnerOptionsEl.classList.add('d-none'); + } + if (deleteConversationTransferOptionEl) { + deleteConversationTransferOptionEl.classList.add('d-none'); + } + if (deleteConversationOwnerSelectContainerEl) { + deleteConversationOwnerSelectContainerEl.classList.add('d-none'); + } + if (deleteConversationNewOwnerSelectEl) { + deleteConversationNewOwnerSelectEl.innerHTML = ''; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.disabled = false; + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; + confirmDeleteConversationBtn.classList.remove('btn-warning'); + confirmDeleteConversationBtn.classList.add('btn-danger'); + } + + const deleteRadio = document.getElementById('delete-conversation-action-delete'); + if (deleteRadio) { + deleteRadio.checked = true; + } +} + +async function buildDeleteConversationContext(conversationId) { + const cachedConversation = getConversationFromCache(conversationId) || {}; + const isCollaborativeConversation = cachedConversation.conversation_kind === 'collaborative' + || document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative' + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative'; + + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + return window.chatCollaboration.fetchConversationMetadata(conversationId); + } + + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})); + throw new Error(errorPayload.error || 'Failed to load conversation metadata'); + } + return response.json(); +} + +function configureDeleteConversationModal(conversationId, metadata = {}) { + resetDeleteConversationModalState(); + + const isCollaborativeConversation = metadata.conversation_kind === 'collaborative'; + const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || '').trim(); + const activeParticipants = Array.isArray(metadata.participants) + ? metadata.participants.filter(participant => participant?.status === 'accepted') + : []; + const transferableParticipants = activeParticipants.filter(participant => participant?.user_id && participant.user_id !== currentUserId); + + pendingDeleteConversationContext = { + conversationId, + isCollaborativeConversation, + metadata, + transferableParticipants, + }; + + if (!isCollaborativeConversation) { + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?'; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; + } + return; + } + + if (deleteConversationSharedWarningEl) { + deleteConversationSharedWarningEl.classList.remove('d-none'); + } + + if (metadata.can_delete_conversation) { + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'This is a multi-user conversation. Do you want to delete it for everyone, or assign another owner and leave the conversation?'; + } + if (deleteConversationOwnerOptionsEl) { + deleteConversationOwnerOptionsEl.classList.remove('d-none'); + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'Deleting removes the shared conversation for every participant.'; + } + if (deleteConversationTransferOptionEl && transferableParticipants.length > 0) { + deleteConversationTransferOptionEl.classList.remove('d-none'); + } + if (deleteConversationNewOwnerSelectEl) { + deleteConversationNewOwnerSelectEl.innerHTML = transferableParticipants + .map(participant => ``) + .join(''); + } + return; + } + + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to leave this shared conversation?'; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'Leaving removes this conversation from your list, but other participants will keep it.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.classList.remove('btn-danger'); + confirmDeleteConversationBtn.classList.add('btn-warning'); + confirmDeleteConversationBtn.innerHTML = 'Leave Conversation'; + } +} + +export function removeConversationFromUi(conversationId, options = {}) { + if (!conversationId) { + return; + } + + allConversations = allConversations.filter(conversation => conversation.id !== conversationId); + document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.remove(); + document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.remove(); + + if (currentConversationId === conversationId || window.currentConversationId === conversationId) { + currentConversationId = null; + window.currentConversationId = null; + if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation"; + if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; + if (chatbox) { + chatbox.innerHTML = '
Select a conversation to view messages.
'; + } + highlightSelectedConversation(null); + toggleConversationInfoButton(false); + window.chatCollaboration?.deactivateConversation?.(); + setSidebarActiveConversation(null); + } + + window.hideConversationDetails?.(); + + if (!options.skipToast) { + showToast(options.toastMessage || 'Conversation removed.', 'success'); + } + + if (options.refreshList !== false) { + loadConversations(); + } +} + +async function executeDeleteConversationAction() { + if (!pendingDeleteConversationContext || !confirmDeleteConversationBtn) { + return; + } + + const { conversationId, isCollaborativeConversation, metadata } = pendingDeleteConversationContext; + confirmDeleteConversationBtn.disabled = true; + + try { + if (!isCollaborativeConversation) { + const response = await fetch(`/api/conversations/${conversationId}`, { method: 'DELETE' }); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})); + throw new Error(errorPayload.error || 'Failed to delete conversation'); + } + + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide(); + removeConversationFromUi(conversationId, { toastMessage: 'Conversation deleted.' }); + return; + } + + let action = metadata.can_delete_conversation ? 'delete' : 'leave'; + let newOwnerUserId = null; + if (metadata.can_delete_conversation) { + action = deleteConversationModalEl?.querySelector('input[name="delete-conversation-action"]:checked')?.value || 'delete'; + if (action === 'leave') { + newOwnerUserId = deleteConversationNewOwnerSelectEl?.value || null; + if (!newOwnerUserId) { + throw new Error('Choose a new owner before leaving the shared conversation.'); } - - showToast("Conversation deleted.", "success"); - } else { - return response.json().then(err => Promise.reject(err)); // Pass error details } - }) - .catch(error => { - console.error("Error deleting conversation:", error); - showToast(`Error deleting conversation: ${error.error || 'Unknown error'}`, "danger"); - // Re-enable button if loading state was shown + } + + const response = await fetch(`/api/collaboration/conversations/${conversationId}/delete-action`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ + action, + new_owner_user_id: newOwnerUserId, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || 'Failed to update the shared conversation'); + } + + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide(); + removeConversationFromUi(conversationId, { + toastMessage: action === 'delete' ? 'Shared conversation deleted for all participants.' : 'You left the shared conversation.', }); + } catch (error) { + showToast(error.message || 'Failed to update the conversation.', 'danger'); + } finally { + confirmDeleteConversationBtn.disabled = false; + } +} + +// Delete a conversation +export async function deleteConversation(conversationId) { + if (!conversationId || !deleteConversationModalEl) { + return; + } + + try { + const metadata = await buildDeleteConversationContext(conversationId); + configureDeleteConversationModal(conversationId, metadata); + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl).show(); + } catch (error) { + showToast(error.message || 'Failed to prepare the delete dialog.', 'danger'); + } } // Create a new conversation via API @@ -1711,7 +2118,10 @@ async function deleteSelectedConversations() { // Toggle conversation pin status async function toggleConversationPin(conversationId) { try { - const response = await fetch(`/api/conversations/${conversationId}/pin`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/pin` : `/api/conversations/${conversationId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1743,7 +2153,10 @@ async function toggleConversationPin(conversationId) { // Toggle conversation hide status async function toggleConversationHide(conversationId) { try { - const response = await fetch(`/api/conversations/${conversationId}/hide`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/hide` : `/api/conversations/${conversationId}/hide`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1900,6 +2313,7 @@ window.chatConversations = { markConversationRead, setConversationUnreadState, deleteConversation, + removeConversationFromUi, toggleConversationSelection, deleteSelectedConversations, bulkPinConversations, @@ -2016,6 +2430,13 @@ function addChatTypeBadges(convoItem, classificationsEl) { // Don't show badges for Model-only conversations if (chatType === 'personal' || chatType === 'personal_single_user') { return; + } else if (chatType === 'personal_multi_user') { + const sharedBadge = document.createElement("span"); + sharedBadge.classList.add("badge", "bg-primary-subtle", "text-primary-emphasis"); + sharedBadge.textContent = 'shared'; + + appendBadgeSpacer(); + classificationsEl.appendChild(sharedBadge); } else if (chatType && chatType.startsWith('group')) { // Group workspace was used const groupBadge = document.createElement("span"); diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js index f42e0123..03d5f20a 100644 --- a/application/single_app/static/js/chat/chat-export.js +++ b/application/single_app/static/js/chat/chat-export.js @@ -114,10 +114,25 @@ function prevStep() { async function _loadConversationTitles() { try { - const response = await fetch('/api/get_conversations'); - if (!response.ok) throw new Error('Failed to fetch conversations'); - const data = await response.json(); - const conversations = data.conversations || []; + const legacyConversationsRequest = fetch('/api/get_conversations') + .then(response => { + if (!response.ok) { + throw new Error('Failed to fetch conversations'); + } + return response.json(); + }); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(() => []) + : Promise.resolve([]); + + const [legacyData, collaborationConversations] = await Promise.all([ + legacyConversationsRequest, + collaborationConversationsRequest, + ]); + const conversations = [ + ...(legacyData?.conversations || []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; exportConversationTitles = {}; conversations.forEach(c => { if (exportConversationIds.includes(c.id)) { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d350eb3a..dda63795 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -1040,6 +1040,17 @@ export function appendMessage( marked.parse(escapeHtml(messageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml); + } else if (sender === "Collaborator") { + messageClass = "collaborator-message"; + senderLabel = fullMessageObject?.sender?.display_name + || fullMessageObject?.metadata?.sender?.display_name + || "Participant"; + avatarAltText = `${senderLabel} Avatar`; + avatarImg = "/static/images/user-avatar.png"; + const sanitizedCollaboratorHtml = DOMPurify.sanitize( + marked.parse(escapeHtml(messageContent)) + ); + messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml); } else if (sender === "File") { messageClass = "file-message"; senderLabel = "File Added"; @@ -1408,6 +1419,28 @@ export function sendMessage() { } export function actuallySendMessage(finalMessageToSend) { + const isCollaborativeConversation = Boolean( + currentConversationId + && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) + ); + + if (isCollaborativeConversation) { + const tempUserMessageId = `temp_user_${Date.now()}`; + appendMessage("You", finalMessageToSend, null, tempUserMessageId); + userInput.value = ""; + userInput.style.height = ""; + updateSendButtonVisibility(); + + window.chatCollaboration.sendCollaborativeMessage(finalMessageToSend, tempUserMessageId).catch(error => { + const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + showToast(error.message || 'Failed to send shared message.', 'danger'); + }); + return; + } + // Generate a temporary message ID for the user message const tempUserMessageId = `temp_user_${Date.now()}`; @@ -1636,6 +1669,10 @@ if (sendBtn) { if (userInput) { userInput.addEventListener("keydown", function (e) { + if (window.chatCollaboration?.handleComposerKeydown?.(e)) { + return; + } + // Check if Enter key is pressed if (e.key === "Enter") { // Check if Shift key is NOT pressed @@ -1650,9 +1687,18 @@ if (userInput) { }); // Monitor input changes for send button visibility - userInput.addEventListener("input", updateSendButtonVisibility); - userInput.addEventListener("focus", updateSendButtonVisibility); - userInput.addEventListener("blur", updateSendButtonVisibility); + userInput.addEventListener("input", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerInput?.(); + }); + userInput.addEventListener("focus", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerInput?.(); + }); + userInput.addEventListener("blur", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerBlur?.(); + }); } // Monitor prompt selection changes diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index 02d40164..a97c9a31 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -205,11 +205,24 @@ export function loadSidebarConversations() { pendingSidebarReload = false; // Clear any pending reload flag sidebarConversationsList.innerHTML = '
Loading conversations...
'; - fetch("/api/get_conversations") - .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))) - .then(data => { + const legacyConversationsRequest = fetch("/api/get_conversations") + .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(error => { + console.warn('Failed to load collaborative sidebar conversations:', error); + return []; + }) + : Promise.resolve([]); + + Promise.all([legacyConversationsRequest, collaborationConversationsRequest]) + .then(([data, collaborationConversations]) => { sidebarConversationsList.innerHTML = ""; - if (!data.conversations || data.conversations.length === 0) { + const mergedConversations = [ + ...(Array.isArray(data.conversations) ? data.conversations : []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; + + if (mergedConversations.length === 0) { sidebarConversationsList.innerHTML = '
No conversations yet.
'; dispatchSidebarConversationsLoaded({ loaded: true, count: 0, hasVisibleConversations: false, isError: false }); @@ -225,7 +238,7 @@ export function loadSidebarConversations() { } // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) - const sortedConversations = [...data.conversations].sort((a, b) => { + const sortedConversations = [...mergedConversations].sort((a, b) => { const aPinned = a.is_pinned || false; const bPinned = b.is_pinned || false; @@ -266,7 +279,7 @@ export function loadSidebarConversations() { dispatchSidebarConversationsLoaded({ loaded: true, - count: data.conversations.length, + count: mergedConversations.length, hasVisibleConversations: visibleConversations.length > 0, isError: false }); @@ -313,28 +326,74 @@ function createSidebarConversationItem(convo) { convoItem.classList.add("sidebar-conversation-item"); convoItem.setAttribute("data-conversation-id", convo.id); convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? 'true' : 'false'; + const isCollaborativeConversation = convo.conversation_kind === 'collaborative'; + const normalizedChatType = normalizeSidebarChatType(convo.chat_type || '', convo.context || []); + const canManageMembers = isCollaborativeConversation + ? Boolean(convo.can_manage_members) + : normalizedChatType === 'personal_single_user'; + const canManageRoles = isCollaborativeConversation ? Boolean(convo.can_manage_roles) : false; + const canEditCollaborativeTitle = !isCollaborativeConversation || canManageRoles; + const canShowAddParticipants = ['personal_single_user', 'personal_multi_user'].includes(normalizedChatType) + && canManageMembers; + const canDeleteCollaborativeConversation = Boolean(convo.can_delete_conversation); + const canLeaveCollaborativeConversation = Boolean(convo.can_leave_conversation); + const collaborativeDeleteLabel = canDeleteCollaborativeConversation ? 'Delete / Leave' : 'Leave'; + + if (isCollaborativeConversation) { + convoItem.dataset.conversationKind = 'collaborative'; + } + if (convo.membership_status) { + convoItem.dataset.membershipStatus = convo.membership_status; + } + convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false'; + convoItem.dataset.canManageRoles = canManageRoles ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = convo.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = convo.can_post_messages === false ? 'false' : 'true'; + convoItem.dataset.canDeleteConversation = canDeleteCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.canLeaveConversation = canLeaveCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.currentUserRole = convo.current_user_role || ''; applySidebarConversationContextAttributes(convoItem, convo.chat_type || '', convo.context || []); const isPinned = convo.is_pinned || false; const isHidden = convo.is_hidden || false; const pinIcon = isPinned ? '' : ''; const hiddenIcon = isHidden ? '' : ''; + const collaborationIcon = isCollaborativeConversation ? '' : ''; + const titleTooltip = isCollaborativeConversation + ? convo.title + : `${convo.title} (Double-click to edit)`; + const addParticipantsItemHtml = canShowAddParticipants + ? '
  • Add participants
  • ' + : ''; + const legacyActionsHtml = isCollaborativeConversation + ? ` +
  • ${isPinned ? 'Unpin' : 'Pin'}
  • +
  • ${isHidden ? 'Unhide' : 'Hide'}
  • +
  • Select
  • +
  • Export
  • + ${canEditCollaborativeTitle ? '
  • Edit title
  • ' : ''} + ${(canDeleteCollaborativeConversation || canLeaveCollaborativeConversation) ? `
  • ${collaborativeDeleteLabel}
  • ` : ''} + ` + : ` +
  • ${isPinned ? 'Unpin' : 'Pin'}
  • +
  • ${isHidden ? 'Unhide' : 'Hide'}
  • +
  • Select
  • +
  • Export
  • +
  • Edit title
  • +
  • Delete
  • + `; convoItem.innerHTML = `
    - +
    @@ -395,6 +454,9 @@ function createSidebarConversationItem(convo) { const titleElement = convoItem.querySelector('.sidebar-conversation-title'); if (titleElement) { titleElement.addEventListener('dblclick', (e) => { + if (!canEditCollaborativeTitle) { + return; + } e.preventDefault(); e.stopPropagation(); enableSidebarTitleEdit(convo.id); @@ -457,6 +519,7 @@ function createSidebarConversationItem(convo) { // Add dropdown menu event handlers const detailsBtn = convoItem.querySelector('.details-btn'); + const addParticipantsBtn = convoItem.querySelector('.add-participants-btn'); const pinBtn = convoItem.querySelector('.pin-btn'); const hideBtn = convoItem.querySelector('.hide-btn'); const selectBtn = convoItem.querySelector('.select-btn'); @@ -478,6 +541,15 @@ function createSidebarConversationItem(convo) { } }); } + + if (addParticipantsBtn) { + addParticipantsBtn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + closeSidebarConversationDropdown(dropdownBtn, dropdownInstance); + window.chatCollaboration?.openParticipantPicker?.({ conversationId: convo.id }); + }); + } if (pinBtn) { pinBtn.addEventListener('click', async (e) => { @@ -487,7 +559,7 @@ function createSidebarConversationItem(convo) { closeSidebarConversationDropdown(dropdownBtn, dropdownInstance); // Toggle pin status try { - const response = await fetch(`/api/conversations/${convo.id}/pin`, { + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${convo.id}/pin` : `/api/conversations/${convo.id}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -518,7 +590,7 @@ function createSidebarConversationItem(convo) { closeSidebarConversationDropdown(dropdownBtn, dropdownInstance); // Toggle hide status try { - const response = await fetch(`/api/conversations/${convo.id}/hide`, { + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${convo.id}/hide` : `/api/conversations/${convo.id}/hide`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -797,8 +869,12 @@ export function updateSidebarConversationTitle(conversationId, newTitle) { if (sidebarItem) { const titleElement = sidebarItem.querySelector('.sidebar-conversation-title'); if (titleElement) { - titleElement.textContent = newTitle; - titleElement.title = `${newTitle} (Double-click to edit)`; + const existingIcons = Array.from(titleElement.querySelectorAll('i')).map(icon => icon.cloneNode(true)); + titleElement.innerHTML = ''; + existingIcons.forEach(icon => titleElement.appendChild(icon)); + titleElement.appendChild(document.createTextNode(newTitle)); + const isCollaborativeConversation = sidebarItem.dataset.conversationKind === 'collaborative'; + titleElement.title = isCollaborativeConversation ? newTitle : `${newTitle} (Double-click to edit)`; } } @@ -818,6 +894,34 @@ export function applySidebarConversationMetadataUpdate(conversationId, updates = updateSidebarConversationTitle(conversationId, updates.title); } + if (updates.conversation_kind) { + sidebarItem.dataset.conversationKind = updates.conversation_kind; + } + if (Object.prototype.hasOwnProperty.call(updates, 'membership_status') && updates.membership_status) { + sidebarItem.dataset.membershipStatus = updates.membership_status; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_members')) { + sidebarItem.dataset.canManageMembers = updates.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_roles')) { + sidebarItem.dataset.canManageRoles = updates.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_accept_invite')) { + sidebarItem.dataset.canAcceptInvite = updates.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_post_messages')) { + sidebarItem.dataset.canPostMessages = updates.can_post_messages === false ? 'false' : 'true'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_delete_conversation')) { + sidebarItem.dataset.canDeleteConversation = updates.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_leave_conversation')) { + sidebarItem.dataset.canLeaveConversation = updates.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'current_user_role')) { + sidebarItem.dataset.currentUserRole = updates.current_user_role || ''; + } + if (Object.prototype.hasOwnProperty.call(updates, 'chat_type') || Array.isArray(updates.context)) { applySidebarConversationContextAttributes(sidebarItem, updates.chat_type || '', updates.context || []); renderSidebarConversationScopeBadge(sidebarItem, updates.chat_type || '', updates.context || []); @@ -871,7 +975,8 @@ export function enableSidebarTitleEdit(conversationId) { try { // Call the update function from main module - const response = await fetch(`/api/conversations/${conversationId}`, { + const isCollaborativeConversation = sidebarItem.dataset.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}` : `/api/conversations/${conversationId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index f47936a8..298bff08 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -635,6 +635,7 @@
    +
    {% if app_settings.enable_speech_to_text_input %} @@ -874,6 +875,77 @@
    + +
    +
    +
    + + + + `; metadataContainerHtml = ``; + } else if (sender === "Collaborator") { + messageFooterHtml = ` + `; } else if (sender === "image" || sender === "File") { // Image and file messages get mask button on left, metadata button on right side const metadataContainerId = `metadata-${messageId || Date.now()}`; @@ -1224,9 +1459,11 @@ export function appendMessage( sender === "You" || sender === "File" ? "flex-row-reverse" : "" }"> ${ - avatarImg - ? `${avatarAltText}` - : "" + avatarHtml + ? avatarHtml + : avatarImg + ? `${avatarAltText}` + : "" }
    @@ -1234,12 +1471,18 @@ export function appendMessage( ${fullMessageObject?.metadata?.edited ? 'Edited' : ''} ${fullMessageObject?.metadata?.retried ? 'Retried' : ''}
    + ${replyQuoteHtml}
    ${messageContentHtml}
    ${metadataContainerHtml} ${messageFooterHtml}
    `; + messageDiv.dataset.replySenderName = stripHtmlTags(senderLabel).replace(/\s+/g, " ").trim() || "Participant"; + if (typeof messageContent === "string") { + messageDiv.dataset.replyPreviewText = buildPlainTextPreview(messageContent); + } + // Append and scroll (common actions for non-AI) chatbox.appendChild(messageDiv); @@ -1266,6 +1509,11 @@ export function appendMessage( } } + if (sender === "Collaborator") { + attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, messageContent); + hydrateCollaboratorAvatar(messageDiv, getMessageSenderUserId(fullMessageObject), senderLabel); + } + // Add event listener for image info button (uploaded images) if (sender === "image" && fullMessageObject?.metadata?.is_user_upload) { const imageInfoBtn = messageDiv.querySelector('.image-info-btn'); @@ -1426,7 +1674,8 @@ export function actuallySendMessage(finalMessageToSend) { if (isCollaborativeConversation) { const tempUserMessageId = `temp_user_${Date.now()}`; - appendMessage("You", finalMessageToSend, null, tempUserMessageId); + const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.() || null; + appendMessage("You", finalMessageToSend, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); userInput.value = ""; userInput.style.height = ""; updateSendButtonVisibility(); @@ -1938,6 +2187,51 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) } } +function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, messageContent) { + const dropdownReplyBtn = messageDiv.querySelector(".dropdown-reply-btn"); + if (dropdownReplyBtn) { + dropdownReplyBtn.addEventListener("click", e => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute("data-message-id"); + window.chatCollaboration?.replyToMessage?.({ + ...(fullMessageObject || {}), + id: currentMessageId, + content: messageContent, + sender: fullMessageObject?.sender || fullMessageObject?.metadata?.sender || { + display_name: messageDiv.dataset.replySenderName || "Participant", + }, + }); + }); + } + + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); + const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); + if (dropdownToggle && dropdownMenu) { + dropdownToggle.addEventListener("show.bs.dropdown", () => { + const localChatbox = document.getElementById("chatbox"); + if (localChatbox) { + dropdownMenu.remove(); + localChatbox.appendChild(dropdownMenu); + + const rect = dropdownToggle.getBoundingClientRect(); + const chatboxRect = localChatbox.getBoundingClientRect(); + dropdownMenu.style.position = "absolute"; + dropdownMenu.style.top = `${rect.bottom - chatboxRect.top + localChatbox.scrollTop + 2}px`; + dropdownMenu.style.left = `${rect.left - chatboxRect.left}px`; + dropdownMenu.style.zIndex = "9999"; + } + }); + + dropdownToggle.addEventListener("hidden.bs.dropdown", () => { + const dropdown = messageDiv.querySelector(".message-footer .dropdown"); + if (dropdown && dropdownMenu.parentElement !== dropdown) { + dropdownMenu.remove(); + dropdown.appendChild(dropdownMenu); + } + }); + } +} + // Function to toggle user message metadata drawer function toggleUserMessageMetadata(messageDiv, messageId) { console.log(`🔀 Toggling metadata for message: ${messageId}`); diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 298bff08..e4ff9a30 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -632,6 +632,15 @@
    {% endif %}
    +
    +
    +
    +
    +
    + +
    diff --git a/docs/explanation/fixes/COLLABORATION_REPLY_AND_AVATAR_FIX.md b/docs/explanation/fixes/COLLABORATION_REPLY_AND_AVATAR_FIX.md new file mode 100644 index 00000000..1e5ad863 --- /dev/null +++ b/docs/explanation/fixes/COLLABORATION_REPLY_AND_AVATAR_FIX.md @@ -0,0 +1,64 @@ +# Collaboration Reply And Avatar Fix (v0.241.014) + +Fixed/Implemented in version: **0.241.014** + +## Issue Description + +Shared conversations still felt incomplete in three visible ways: + +1. Other participants could show a broken or collapsed avatar inside shared message bubbles, even when a profile image existed. +2. Shared join, removal, and role-change toasts could replay when a user re-opened the same shared conversation because the event stream cached historical events. +3. Shared chats did not expose a reply workflow, so users could not quote and answer a specific participant message from the conversation timeline. + +## Root Cause Analysis + +The collaboration UI already loaded participant avatars in the conversation details modal and persisted `reply_to_message_id` in the collaboration route, but the chat transcript and composer did not reuse either capability. + +- Shared message rendering used a custom collaborator avatar wrapper instead of the same top-level avatar image styling used elsewhere in chat. +- The collaborator avatar flex item could shrink under the shared-message layout, which caused profile images to collapse into a thin strip on the left edge of the row. +- The collaboration event client reattached to a replayable event cache without suppressing historical events, so stale toasts were shown again on re-entry. +- The compose area had no reply target state, no reply preview container, and no user-message action for selecting a reply target. + +## Files Modified + +1. `application/single_app/static/js/chat/chat-collaboration.js` +2. `application/single_app/static/js/chat/chat-messages.js` +3. `application/single_app/static/css/chats.css` +4. `application/single_app/templates/chats.html` +5. `ui_tests/test_chat_collaboration_ui_scaffolding.py` +6. `functional_tests/test_collaboration_reply_and_avatar_fix.py` +7. `application/single_app/config.py` + +## Code Changes Summary + +1. Added composer-level reply state for collaborative conversations, including a dismissible reply preview panel above the chat input. +2. Added a Reply action to collaborator message menus and rendered quoted reply context inside shared user and collaborator bubbles. +3. Reused the existing per-user profile image endpoint to hydrate collaborator avatars in shared message bubbles, with initials as the fallback state. +4. Switched hydrated collaborator avatars onto the same top-level avatar image element used by the standard chat rows and locked the avatar slot to a fixed flex width so it cannot collapse. +5. Added replay-event suppression in the collaboration event client so cached invite, removal, and role-change events do not re-toast each time a shared conversation is reselected. +6. Updated the collaboration UI regression and added a functional regression file that locks in the reply-preview, avatar, and replay-suppression hooks. + +## Testing Approach + +1. Updated `ui_tests/test_chat_collaboration_ui_scaffolding.py` to validate the reply preview container and reply selection behavior. +2. Added `functional_tests/test_collaboration_reply_and_avatar_fix.py` to verify the relevant collaboration UI hooks remain present. +3. Ran file diagnostics on the modified JavaScript, CSS, HTML, and Python files. + +## Validation + +### Before + +1. Collaborator bubbles showed the generic avatar even when profile images were available. +2. Reopening a shared conversation could replay old green collaboration toasts from the cached SSE session. +3. Users had no targeted reply workflow in shared chats. + +### After + +1. Shared collaborator bubbles can show the participant profile image when one exists and otherwise fall back to initials, without collapsing the avatar width. +2. Historical collaboration events are ignored when reconnecting to a shared conversation, which prevents repeated join or role-change toasts. +3. Users can reply to another participant, see the quoted reply target above the composer, and keep the quoted context in the resulting shared message bubble. + +## Related Tests + +1. `functional_tests/test_collaboration_reply_and_avatar_fix.py` +2. `ui_tests/test_chat_collaboration_ui_scaffolding.py` \ No newline at end of file diff --git a/functional_tests/test_collaboration_reply_and_avatar_fix.py b/functional_tests/test_collaboration_reply_and_avatar_fix.py new file mode 100644 index 00000000..30ebdfba --- /dev/null +++ b/functional_tests/test_collaboration_reply_and_avatar_fix.py @@ -0,0 +1,61 @@ +# test_collaboration_reply_and_avatar_fix.py +""" +Functional test for collaboration reply, avatar, and replay suppression fixes. +Version: 0.241.014 +Implemented in: 0.241.014 + +This test ensures shared conversations expose the reply preview scaffold, +persist reply linkage when posting shared messages, hydrate collaborator +avatars when profile images exist, and suppress replayed collaboration events +from showing the same join or role toasts each time a shared conversation is + reopened. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHAT_COLLABORATION_FILE = REPO_ROOT / 'application' / 'single_app' / 'static' / 'js' / 'chat' / 'chat-collaboration.js' +CHAT_MESSAGES_FILE = REPO_ROOT / 'application' / 'single_app' / 'static' / 'js' / 'chat' / 'chat-messages.js' +CHATS_TEMPLATE_FILE = REPO_ROOT / 'application' / 'single_app' / 'templates' / 'chats.html' +CHATS_CSS_FILE = REPO_ROOT / 'application' / 'single_app' / 'static' / 'css' / 'chats.css' + + +def read_text(path): + return path.read_text(encoding='utf-8') + + +def test_collaboration_reply_and_avatar_fix(): + collaboration_source = read_text(CHAT_COLLABORATION_FILE) + messages_source = read_text(CHAT_MESSAGES_FILE) + template_source = read_text(CHATS_TEMPLATE_FILE) + css_source = read_text(CHATS_CSS_FILE) + + assert 'collaboration-reply-preview' in template_source + assert 'collaboration-reply-cancel-btn' in template_source + + assert 'activeReplyContext' in collaboration_source + assert 'replyToMessage' in collaboration_source + assert 'getPendingMessageContext' in collaboration_source + assert 'reply_to_message_id: activeReplyContext?.message_id || null' in collaboration_source + assert 'seenCollaborationEventKeys' in collaboration_source + assert 'isReplayEvent' in collaboration_source + + assert 'renderReplyQuoteHtml' in messages_source + assert 'createCollaboratorAvatarHtml' in messages_source + assert 'hydrateCollaboratorAvatar' in messages_source + assert 'class="avatar collaborator-avatar"' in messages_source + assert 'avatarElement.replaceWith(imageElement)' in messages_source + assert 'dropdown-reply-btn' in messages_source + assert 'window.chatCollaboration?.replyToMessage?.' in messages_source + + assert '.collaboration-reply-preview' in css_source + assert '.collaboration-quote-block' in css_source + assert '.avatar.avatar-initials' in css_source + assert 'flex: 0 0 30px;' in css_source + assert '.collaborator-message .message-content' in css_source + + +if __name__ == '__main__': + test_collaboration_reply_and_avatar_fix() + print('collaboration reply and avatar regression checks passed') \ No newline at end of file diff --git a/ui_tests/test_chat_collaboration_ui_scaffolding.py b/ui_tests/test_chat_collaboration_ui_scaffolding.py index be7dd81a..014573b8 100644 --- a/ui_tests/test_chat_collaboration_ui_scaffolding.py +++ b/ui_tests/test_chat_collaboration_ui_scaffolding.py @@ -1,8 +1,8 @@ # test_chat_collaboration_ui_scaffolding.py """ UI test for chat collaboration scaffolding. -Version: 0.241.012 -Implemented in: 0.241.012 +Version: 0.241.014 +Implemented in: 0.241.014 This test ensures the authenticated chats page loads the collaboration UI containers needed for participant management and @-mention suggestions without @@ -59,6 +59,7 @@ def track_console(message): expect(page.locator('#chatbox')).to_be_visible() expect(page.locator('#user-input')).to_be_visible() + expect(page.locator('#collaboration-reply-preview')).to_have_count(1) expect(page.locator('#collaboration-mention-menu')).to_have_count(1) expect(page.locator('#collaboration-mention-menu')).to_have_class('list-group collaboration-mention-menu d-none') expect(page.locator('#collaboration-participant-modal')).to_have_count(1) @@ -116,6 +117,54 @@ def track_console(message): } """) + page.evaluate(""" + () => { + const replyPreview = document.getElementById('collaboration-reply-preview'); + window.chatCollaboration.replyToMessage({ + id: 'mock-reply-message', + content: 'Please look at the updated shared reply workflow.', + sender: { + user_id: 'member-user-002', + display_name: 'Member User' + } + }); + + if (!replyPreview || replyPreview.classList.contains('d-none')) { + throw new Error('Reply preview did not become visible after selecting Reply.'); + } + } + """) + expect(page.locator('#collaboration-reply-preview')).not_to_have_class('collaboration-reply-preview d-none') + expect(page.locator('#collaboration-reply-preview-label')).to_contain_text('Replying to Member User') + expect(page.locator('#collaboration-reply-preview-text')).to_contain_text('updated shared reply workflow') + page.click('#collaboration-reply-cancel-btn') + expect(page.locator('#collaboration-reply-preview')).to_have_class('collaboration-reply-preview d-none') + + page.evaluate(""" + () => { + const message = document.createElement('div'); + message.className = 'message collaborator-message'; + message.innerHTML = ` +
    + Collaborator Avatar +
    Shared message
    +
    + `; + document.body.appendChild(message); + + const avatar = message.querySelector('.avatar'); + const styles = window.getComputedStyle(avatar); + if (styles.flexShrink !== '0') { + throw new Error(`Expected collaborator avatar flex-shrink to be 0 but received ${styles.flexShrink}.`); + } + if (styles.minWidth !== '30px') { + throw new Error(`Expected collaborator avatar min-width to be 30px but received ${styles.minWidth}.`); + } + + message.remove(); + } + """) + page.evaluate(""" () => { window.currentUser = { id: 'owner-user-001' }; From 228083c342d9fa571d9c900170457935d397d887 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 13:54:11 -0400 Subject: [PATCH 04/28] +1 --- .../static/js/chat/chat-collaboration.js | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index f4c525e5..73900071 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -787,7 +787,7 @@ function handleConversationEvent(eventEnvelope = {}) { const payload = eventEnvelope.payload || {}; if (payload.conversation) { - const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + const normalizedConversation = cacheCollaborationConversation(payload.conversation); setConversationDataset(normalizedConversation.id, normalizedConversation); applyConversationMetadataUpdate(normalizedConversation.id, normalizedConversation); if (!['collaboration.message.created', 'collaboration.typing.updated'].includes(eventEnvelope.event_type)) { @@ -796,6 +796,12 @@ function handleConversationEvent(eventEnvelope = {}) { } if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { + const senderUserId = String(payload.message?.sender?.user_id || payload.message?.metadata?.sender?.user_id || '').trim(); + if (senderUserId && senderUserId !== getCurrentUserId() && isCurrentUserMentioned(payload.message)) { + const senderName = normalizeCollaborator(payload.message.sender || payload.message.metadata?.sender || {})?.display_name || 'A participant'; + showToast(`${senderName} tagged you in a shared message.`, 'info'); + } + const decoratedMessage = decorateReplyMessage(payload.message); cacheCollaborationMessage(payload.message); if (reconcilePendingCollaborativeUserMessage(payload.message)) { @@ -874,7 +880,7 @@ function subscribeToConversationEvents(conversationId) { async function fetchConversationMetadata(conversationId) { const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}`); - const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + const normalizedConversation = cacheCollaborationConversation(payload.conversation || {}); setConversationDataset(conversationId, normalizedConversation); applyConversationMetadataUpdate(conversationId, normalizedConversation); return normalizedConversation; @@ -929,7 +935,7 @@ async function fetchCollaborationConversationList() { const payload = await fetchJson('/api/collaboration/conversations?include_pending=true'); const conversations = Array.isArray(payload.conversations) ? payload.conversations : []; - const normalizedConversations = conversations.map(conversation => normalizeCollaborationConversation(conversation)); + const normalizedConversations = conversations.map(conversation => cacheCollaborationConversation(conversation)); notifyPendingInvites(normalizedConversations); return normalizedConversations; } @@ -967,6 +973,8 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { return null; } + const mentionedParticipants = extractMentionedParticipantsFromMessage(messageText, conversationId); + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`, { method: 'POST', headers: { @@ -975,11 +983,12 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { body: JSON.stringify({ content: messageText, reply_to_message_id: activeReplyContext?.message_id || null, + mentioned_participants: mentionedParticipants, }), }); if (payload.conversation) { - const normalizedConversation = normalizeCollaborationConversation(payload.conversation); + const normalizedConversation = cacheCollaborationConversation(payload.conversation); setConversationDataset(conversationId, normalizedConversation); applyConversationMetadataUpdate(conversationId, normalizedConversation); } @@ -1061,9 +1070,11 @@ function buildSuggestionItemHtml(suggestion) { const subtitle = suggestion.email ? `
    ${suggestion.email}
    ` : '
    No email recorded
    '; - const sourceLabel = suggestion.source === 'recent' + const sourceLabel = suggestion.action === 'tag' + ? 'Tag' + : suggestion.source === 'recent' ? 'Recent' - : 'Local'; + : 'Invite'; return `
    @@ -1117,6 +1128,11 @@ function renderMentionMenu(results, mentionState) { button.setAttribute('data-index', String(index)); button.addEventListener('mousedown', event => { event.preventDefault(); + if (result.action === 'tag') { + insertParticipantMention(result, mentionState); + return; + } + openParticipantConfirmation(result, { conversationId: window.chatConversations?.getCurrentConversationId?.(), source: 'mention', @@ -1141,7 +1157,7 @@ function updateMentionMenuActiveItem() { async function refreshMentionSuggestions() { const conversationId = window.chatConversations?.getCurrentConversationId?.(); - if (!conversationId || !canUseParticipantFlow(conversationId)) { + if (!conversationId) { hideMentionMenu(); return; } @@ -1154,7 +1170,7 @@ async function refreshMentionSuggestions() { const searchToken = ++mentionSearchToken; try { - const results = await searchLocalCollaborators(mentionState.query, { recentOnly: false, limit: DEFAULT_SUGGESTION_LIMIT }); + const results = await loadMentionSuggestions(conversationId, mentionState.query); if (searchToken !== mentionSearchToken) { return; } @@ -1457,11 +1473,15 @@ function handleComposerKeydown(event) { event.preventDefault(); const collaborator = activeMentionState.results[activeMentionState.activeIndex]; if (collaborator) { - openParticipantConfirmation(collaborator, { - conversationId: window.chatConversations?.getCurrentConversationId?.(), - source: 'mention', - mentionState: activeMentionState, - }); + if (collaborator.action === 'tag') { + insertParticipantMention(collaborator, activeMentionState); + } else { + openParticipantConfirmation(collaborator, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState: activeMentionState, + }); + } } return true; } From 2aa973beaaa22e48ba9d1db6c0bcfc4f64d36f3e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 16 Apr 2026 15:45:57 -0400 Subject: [PATCH 05/28] Implemented the collaboration shared AI workflow end to end --- application/single_app/config.py | 2 +- .../single_app/functions_collaboration.py | 319 ++++++++- .../single_app/functions_notifications.py | 92 +++ .../single_app/route_backend_collaboration.py | 447 +++++++++++- application/single_app/static/css/chats.css | 39 ++ .../static/js/chat/chat-collaboration.js | 172 ++++- .../static/js/chat/chat-messages.js | 641 +++++++++++++++--- .../static/js/chat/chat-streaming.js | 16 +- .../fixes/COLLABORATION_MESSAGE_DELETE_FIX.md | 41 ++ .../COLLABORATION_MESSAGE_NOTIFICATION_FIX.md | 43 ++ ...ON_METADATA_AND_PARTICIPANT_MENTION_FIX.md | 53 ++ .../COLLABORATION_SHARED_AI_WORKFLOW_FIX.md | 39 ++ .../test_collaboration_message_delete_fix.py | 336 +++++++++ ...est_collaboration_message_notifications.py | 379 +++++++++++ ...collaboration_metadata_and_mentions_fix.py | 67 ++ .../test_collaboration_shared_ai_workflow.py | 64 ++ .../test_chat_collaboration_ui_scaffolding.py | 239 ++++++- 17 files changed, 2860 insertions(+), 129 deletions(-) create mode 100644 docs/explanation/fixes/COLLABORATION_MESSAGE_DELETE_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_MESSAGE_NOTIFICATION_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_METADATA_AND_PARTICIPANT_MENTION_FIX.md create mode 100644 docs/explanation/fixes/COLLABORATION_SHARED_AI_WORKFLOW_FIX.md create mode 100644 functional_tests/test_collaboration_message_delete_fix.py create mode 100644 functional_tests/test_collaboration_message_notifications.py create mode 100644 functional_tests/test_collaboration_metadata_and_mentions_fix.py create mode 100644 functional_tests/test_collaboration_shared_ai_workflow.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4445d839..70a9865a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.014" +VERSION = "0.241.019" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py index b944f9f3..4e399b01 100644 --- a/application/single_app/functions_collaboration.py +++ b/application/single_app/functions_collaboration.py @@ -3,6 +3,7 @@ """Persistence, authorization, and serialization helpers for collaborative conversations.""" from copy import deepcopy +import uuid from config import * from collaboration_models import ( @@ -15,6 +16,7 @@ MEMBERSHIP_STATUS_DECLINED, MEMBERSHIP_STATUS_PENDING, MEMBERSHIP_STATUS_REMOVED, + MESSAGE_KIND_AI_REQUEST, MESSAGE_KIND_HUMAN, PERSONAL_MULTI_USER_CHAT_TYPE, add_personal_pending_participants, @@ -26,6 +28,7 @@ build_personal_collaboration_conversation, ensure_group_participant_record, get_collaboration_user_state_doc_id, + normalize_collaboration_user, refresh_personal_participant_indexes, remove_personal_participant, utc_now_iso, @@ -38,6 +41,7 @@ get_user_groups, ) from functions_message_artifacts import filter_assistant_artifact_items +from functions_notifications import create_collaboration_message_notification from functions_thoughts import delete_thoughts_for_conversation, get_thoughts_for_message @@ -85,6 +89,27 @@ def get_collaboration_message(message_id): return items[0] +def get_collaboration_message_by_source_message(conversation_id, source_message_id): + normalized_conversation_id = str(conversation_id or '').strip() + normalized_source_message_id = str(source_message_id or '').strip() + if not normalized_conversation_id or not normalized_source_message_id: + return None + + query = ( + 'SELECT TOP 1 * FROM c WHERE c.conversation_id = @conversation_id ' + 'AND c.metadata.source_message_id = @source_message_id' + ) + items = list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_id', 'value': normalized_conversation_id}, + {'name': '@source_message_id', 'value': normalized_source_message_id}, + ], + partition_key=normalized_conversation_id, + )) + return items[0] if items else None + + def get_personal_collaboration_participant(conversation_doc, participant_user_id): normalized_user_id = str(participant_user_id or '').strip() for participant in list((conversation_doc or {}).get('participants', []) or []): @@ -276,6 +301,91 @@ def resolve_collaboration_mentions(conversation_doc, raw_mentions): return normalized_mentions +def _get_group_collaboration_notification_recipient_ids(conversation_doc, sender_user_id): + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + group_id = str(scope.get('group_id') or '').strip() + if not group_id: + return [] + + group_doc = find_group_by_id(group_id) + if not group_doc: + return [] + + recipient_ids = set() + owner_user_id = str((group_doc.get('owner') or {}).get('id') or '').strip() + if owner_user_id: + recipient_ids.add(owner_user_id) + + for member in list(group_doc.get('users', []) or []): + member_user_id = str(member.get('userId') or '').strip() + if member_user_id: + recipient_ids.add(member_user_id) + + normalized_sender_user_id = str(sender_user_id or '').strip() + if normalized_sender_user_id: + recipient_ids.discard(normalized_sender_user_id) + + return sorted(recipient_ids) + + +def list_collaboration_notification_recipient_ids(conversation_doc, sender_user_id): + if is_group_collaboration_conversation(conversation_doc): + return _get_group_collaboration_notification_recipient_ids(conversation_doc, sender_user_id) + + accepted_participant_ids = set(conversation_doc.get('accepted_participant_ids', []) or []) + normalized_sender_user_id = str(sender_user_id or '').strip() + if normalized_sender_user_id: + accepted_participant_ids.discard(normalized_sender_user_id) + + return sorted(user_id for user_id in accepted_participant_ids if str(user_id or '').strip()) + + +def create_collaboration_message_notifications(conversation_doc, message_doc): + """Fan out personal inbox notifications for recipients of a shared message.""" + if not conversation_doc or not message_doc: + return [] + + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + sender = normalize_collaboration_user(metadata.get('sender') or {}) or {} + sender_user_id = str(sender.get('user_id') or '').strip() + recipient_ids = list_collaboration_notification_recipient_ids(conversation_doc, sender_user_id) + if not recipient_ids: + return [] + + mentioned_user_ids = { + str(participant.get('user_id') or '').strip() + for participant in list(metadata.get('mentioned_participants', []) or []) + if str(participant.get('user_id') or '').strip() + } + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + created_notifications = [] + + for recipient_user_id in recipient_ids: + try: + notification_doc = create_collaboration_message_notification( + user_id=recipient_user_id, + conversation_id=message_doc.get('conversation_id'), + message_id=message_doc.get('id'), + conversation_title=conversation_doc.get('title'), + sender_display_name=sender.get('display_name'), + message_preview=message_doc.get('content'), + chat_type=conversation_doc.get('chat_type'), + group_id=scope.get('group_id'), + mentioned_user=recipient_user_id in mentioned_user_ids, + ) + if notification_doc: + created_notifications.append(notification_doc) + except Exception as exc: + log_event( + f'[Collaboration Notifications] Failed to create notification for conversation {message_doc.get("conversation_id")}: {exc}', + level=logging.WARNING, + exceptionTraceback=True, + debug_only=True, + ) + + return created_notifications + + def build_collaboration_message_metadata_payload(message_doc, conversation_doc): source_message_doc = _get_collaboration_source_message(message_doc) message_metadata = deepcopy(message_doc.get('metadata', {}) if isinstance(message_doc.get('metadata'), dict) else {}) @@ -980,6 +1090,8 @@ def persist_collaboration_message( content, reply_to_message_id=None, mentioned_participants=None, + message_kind=MESSAGE_KIND_HUMAN, + extra_metadata=None, ): conversation_id = conversation_doc.get('id') message_doc = build_collaboration_message_doc( @@ -988,12 +1100,32 @@ def persist_collaboration_message( content=content, reply_to_message_id=reply_to_message_id, mentioned_participants=mentioned_participants, - message_kind=MESSAGE_KIND_HUMAN, + message_kind=message_kind, timestamp=utc_now_iso(), ) + if isinstance(extra_metadata, dict) and extra_metadata: + message_doc['metadata'] = { + **dict(message_doc.get('metadata', {}) or {}), + **extra_metadata, + } + + return _save_collaboration_message_doc(conversation_doc, message_doc) + + +def _save_collaboration_message_doc(conversation_doc, message_doc): + sender_summary = normalize_collaboration_user( + ((message_doc or {}).get('metadata', {}) or {}).get('sender') or {}, + ) + if is_group_collaboration_conversation(conversation_doc): - ensure_group_participant_record(conversation_doc, sender_user, joined_at=message_doc.get('timestamp')) + sender_user_id = str((sender_summary or {}).get('user_id') or '').strip() + if sender_user_id and sender_user_id != 'assistant' and str(message_doc.get('role') or '').strip().lower() == 'user': + ensure_group_participant_record( + conversation_doc, + sender_summary, + joined_at=message_doc.get('timestamp'), + ) cosmos_collaboration_messages_container.upsert_item(message_doc) @@ -1011,6 +1143,189 @@ def persist_collaboration_message( return message_doc, conversation_doc +def ensure_collaboration_source_conversation(conversation_doc, current_user): + normalized_current_user = normalize_collaboration_user(current_user) + if not normalized_current_user: + raise PermissionError('User not authenticated') + + source_conversation_id = str((conversation_doc or {}).get('source_conversation_id') or '').strip() + source_conversation_doc = None + source_updated = False + + if source_conversation_id: + try: + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + source_conversation_doc = None + source_conversation_id = '' + + timestamp = utc_now_iso() + if source_conversation_doc is None: + source_conversation_id = str(uuid.uuid4()) + source_conversation_doc = { + 'id': source_conversation_id, + 'user_id': str((conversation_doc or {}).get('created_by_user_id') or normalized_current_user.get('user_id') or '').strip(), + 'last_updated': timestamp, + 'title': str((conversation_doc or {}).get('title') or 'Collaborative Conversation').strip() or 'Collaborative Conversation', + 'context': list((conversation_doc or {}).get('context', []) or []), + 'tags': list((conversation_doc or {}).get('tags', []) or []), + 'strict': bool((conversation_doc or {}).get('strict', False)), + 'chat_type': 'group' if is_group_collaboration_conversation(conversation_doc) else 'personal_single_user', + 'scope_locked': bool((conversation_doc or {}).get('scope_locked', False)), + 'locked_contexts': list((conversation_doc or {}).get('locked_contexts', []) or []), + 'classification': list((conversation_doc or {}).get('classification', []) or []), + 'summary': (conversation_doc or {}).get('summary'), + 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': (conversation_doc or {}).get('id'), + 'is_hidden': True, + } + source_updated = True + else: + synchronized_values = { + 'title': str((conversation_doc or {}).get('title') or source_conversation_doc.get('title') or 'Collaborative Conversation').strip() or 'Collaborative Conversation', + 'context': list((conversation_doc or {}).get('context', []) or source_conversation_doc.get('context', []) or []), + 'tags': list((conversation_doc or {}).get('tags', []) or source_conversation_doc.get('tags', []) or []), + 'strict': bool((conversation_doc or {}).get('strict', source_conversation_doc.get('strict', False))), + 'scope_locked': bool((conversation_doc or {}).get('scope_locked', source_conversation_doc.get('scope_locked', False))), + 'locked_contexts': list((conversation_doc or {}).get('locked_contexts', []) or source_conversation_doc.get('locked_contexts', []) or []), + 'classification': list((conversation_doc or {}).get('classification', []) or source_conversation_doc.get('classification', []) or []), + 'summary': (conversation_doc or {}).get('summary', source_conversation_doc.get('summary')), + 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': (conversation_doc or {}).get('id'), + 'is_hidden': True, + } + for field_name, field_value in synchronized_values.items(): + if source_conversation_doc.get(field_name) != field_value: + source_conversation_doc[field_name] = field_value + source_updated = True + + if source_updated: + source_conversation_doc['last_updated'] = timestamp + cosmos_conversations_container.upsert_item(source_conversation_doc) + + if str((conversation_doc or {}).get('source_conversation_id') or '').strip() != source_conversation_id: + conversation_doc['source_conversation_id'] = source_conversation_id + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + return source_conversation_doc, conversation_doc + + +def mirror_source_message_to_collaboration( + conversation_doc, + source_message_doc, + default_sender_user, + reply_to_message_id=None, + extra_metadata=None, +): + source_message_id = str((source_message_doc or {}).get('id') or '').strip() + if not source_message_id: + raise ValueError('source_message_doc.id is required') + + existing_message = get_collaboration_message_by_source_message( + (conversation_doc or {}).get('id'), + source_message_id, + ) + if existing_message: + return existing_message, conversation_doc, False + + collaboration_message = build_collaboration_message_doc_from_legacy( + (conversation_doc or {}).get('id'), + source_message_doc, + default_sender_user, + ) + if not collaboration_message: + return None, conversation_doc, False + + source_role = str((source_message_doc or {}).get('role') or '').strip().lower() + source_metadata = (source_message_doc or {}).get('metadata', {}) if isinstance((source_message_doc or {}).get('metadata'), dict) else {} + message_metadata = collaboration_message.setdefault('metadata', {}) + message_metadata.setdefault('source_message_id', source_message_id) + message_metadata.setdefault('source_conversation_id', str((conversation_doc or {}).get('source_conversation_id') or '').strip() or None) + message_metadata.setdefault('source_thought_user_id', str((default_sender_user or {}).get('user_id') or (conversation_doc or {}).get('created_by_user_id') or '').strip()) + + if isinstance(extra_metadata, dict) and extra_metadata: + message_metadata.update(extra_metadata) + + if reply_to_message_id: + collaboration_message['reply_to_message_id'] = str(reply_to_message_id or '').strip() or None + + if source_role == 'image' and not bool(source_metadata.get('is_user_upload')): + collaboration_message['role'] = 'image' + collaboration_message['content'] = str((source_message_doc or {}).get('content') or '') + message_metadata['last_message_preview'] = '[Generated image]' + + return (*_save_collaboration_message_doc(conversation_doc, collaboration_message), True) + + +def _refresh_collaboration_conversation_message_summary(conversation_doc): + conversation_id = str((conversation_doc or {}).get('id') or '').strip() + if not conversation_id: + raise ValueError('conversation_id is required') + + remaining_messages = list_collaboration_messages(conversation_id) + conversation_doc['message_count'] = len(remaining_messages) + + if remaining_messages: + last_message_doc = remaining_messages[-1] + last_message_timestamp = last_message_doc.get('timestamp') or utc_now_iso() + conversation_doc['last_message_at'] = last_message_timestamp + conversation_doc['last_message_preview'] = ( + (last_message_doc.get('metadata') or {}).get('last_message_preview') or '' + ) + conversation_doc['updated_at'] = last_message_timestamp + else: + conversation_doc['last_message_at'] = None + conversation_doc['last_message_preview'] = '' + conversation_doc['updated_at'] = utc_now_iso() + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return conversation_doc + + +def delete_collaboration_message(conversation_id, message_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + access_context = assert_user_can_participate_in_collaboration_conversation( + current_user_id, + conversation_doc, + ) + message_doc = get_collaboration_message(message_id) + + if str(message_doc.get('conversation_id') or '').strip() != str(conversation_id or '').strip(): + raise LookupError('Collaborative message not found in this conversation') + + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + sender_user_id = str( + ((metadata.get('sender') or {}).get('user_id')) + or ((metadata.get('user_info') or {}).get('user_id')) + or '' + ).strip() + normalized_current_user_id = str(current_user_id or '').strip() + + can_delete_message = sender_user_id == normalized_current_user_id + if not can_delete_message and is_personal_collaboration_conversation(conversation_doc): + actor_role = get_personal_collaboration_role( + conversation_doc, + normalized_current_user_id, + user_state=access_context.get('user_state'), + ) + can_delete_message = actor_role in PERSONAL_COLLABORATION_MANAGER_ROLES + elif not can_delete_message and is_group_collaboration_conversation(conversation_doc): + can_delete_message = access_context.get('group_role') in ('Owner', 'Admin', 'DocumentManager') + + if not can_delete_message: + raise PermissionError('You can only delete your own shared messages') + + cosmos_collaboration_messages_container.delete_item( + item=message_id, + partition_key=conversation_id, + ) + updated_conversation_doc = _refresh_collaboration_conversation_message_summary(conversation_doc) + return message_doc, updated_conversation_doc + + def update_personal_collaboration_title(conversation_id, current_user_id, new_title): conversation_doc = get_collaboration_conversation(conversation_id) if not is_personal_collaboration_conversation(conversation_doc): diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index 80574160..a3c7e98b 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -32,6 +32,10 @@ 'icon': 'bi-file-earmark-check', 'color': 'success' }, + 'collaboration_message_received': { + 'icon': 'bi-people-fill', + 'color': 'info' + }, 'chat_response_complete': { 'icon': 'bi-chat-dots', 'color': 'success' @@ -395,6 +399,54 @@ def create_chat_response_notification( ) +def create_collaboration_message_notification( + user_id, + conversation_id, + message_id, + conversation_title='', + sender_display_name='', + message_preview='', + chat_type='', + group_id=None, + mentioned_user=False, +): + """Create a personal notification when another participant posts in a shared conversation.""" + normalized_title = str(conversation_title or '').strip() or 'Shared Conversation' + normalized_sender = str(sender_display_name or '').strip() or 'A participant' + normalized_preview = str(message_preview or '').strip() + if len(normalized_preview) > 160: + normalized_preview = f"{normalized_preview[:157]}..." + + notification_title = f"New shared message in {normalized_title}" + if mentioned_user: + notification_title = f"{normalized_sender} tagged you in {normalized_title}" + + notification_message = normalized_preview or f"{normalized_sender} posted in {normalized_title}." + + return create_notification( + user_id=user_id, + notification_type='collaboration_message_received', + title=notification_title, + message=notification_message, + link_url=f'/chats?conversationId={conversation_id}', + link_context={ + 'workspace_type': 'group' if str(chat_type or '').strip().lower().startswith('group') else 'personal', + 'conversation_id': conversation_id, + 'group_id': group_id, + 'conversation_kind': 'collaborative', + }, + metadata={ + 'conversation_id': conversation_id, + 'message_id': message_id, + 'sender_display_name': normalized_sender, + 'mentioned_user': bool(mentioned_user), + 'conversation_kind': 'collaborative', + 'chat_type': chat_type, + 'group_id': group_id, + } + ) + + def get_user_notifications(user_id, page=1, per_page=20, include_read=True, include_dismissed=False, user_roles=None): """ Fetch notifications visible to a user from personal, group, and public workspace scopes. @@ -674,6 +726,46 @@ def mark_chat_response_notifications_read_for_conversation(user_id, conversation return 0 +def mark_collaboration_message_notifications_read_for_conversation(user_id, conversation_id): + """Mark personal collaboration-message notifications read for a conversation.""" + try: + query = """ + SELECT * FROM c + WHERE c.user_id = @user_id + AND c.notification_type = @notification_type + AND c.metadata.conversation_id = @conversation_id + """ + params = [ + {'name': '@user_id', 'value': user_id}, + {'name': '@notification_type', 'value': 'collaboration_message_received'}, + {'name': '@conversation_id', 'value': conversation_id}, + ] + + notifications = list(cosmos_notifications_container.query_items( + query=query, + parameters=params, + partition_key=user_id + )) + + marked_count = 0 + for notification in notifications: + read_by = notification.get('read_by', []) + if user_id in read_by: + continue + + read_by.append(user_id) + notification['read_by'] = read_by + cosmos_notifications_container.upsert_item(notification) + marked_count += 1 + + return marked_count + except Exception as e: + debug_print( + f"Error marking collaboration notifications as read for conversation {conversation_id}: {e}" + ) + return 0 + + def dismiss_notification(notification_id, user_id): """ Dismiss a notification for a specific user (adds to dismissed_by). diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index c801c25c..611d728b 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -5,19 +5,21 @@ import time import app_settings_cache -from flask import Response, jsonify, request, stream_with_context +from flask import Response, current_app, jsonify, request, session, stream_with_context from config import * -from collaboration_models import MEMBERSHIP_STATUS_PENDING, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso +from collaboration_models import MEMBERSHIP_STATUS_PENDING, MESSAGE_KIND_AI_REQUEST, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso from functions_appinsights import log_event from functions_authentication import * from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, assert_user_can_view_collaboration_conversation, - build_collaboration_message_metadata_payload, + create_collaboration_message_notifications, create_group_collaboration_conversation_record, create_personal_collaboration_conversation_record, + delete_collaboration_message, delete_personal_collaboration_conversation, + ensure_collaboration_source_conversation, ensure_personal_collaboration_for_legacy_conversation, get_collaboration_conversation, get_collaboration_user_state, @@ -26,6 +28,7 @@ list_collaboration_messages, list_group_collaboration_conversations_for_user, list_personal_collaboration_conversations_for_user, + mirror_source_message_to_collaboration, persist_collaboration_message, record_personal_invite_response, remove_personal_collaboration_member, @@ -38,6 +41,7 @@ update_personal_collaboration_title, ) from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id +from functions_notifications import mark_collaboration_message_notifications_read_for_conversation from functions_settings import get_settings, get_user_settings from swagger_wrapper import swagger_route, get_auth_security @@ -207,6 +211,62 @@ def _normalize_participant_payload(raw_payload): return normalized_participants +def _read_source_message_doc(source_conversation_id, source_message_id): + normalized_conversation_id = str(source_conversation_id or '').strip() + normalized_message_id = str(source_message_id or '').strip() + if not normalized_conversation_id or not normalized_message_id: + raise CosmosResourceNotFoundError(message='Source message not found') + + try: + return cosmos_messages_container.read_item( + item=normalized_message_id, + partition_key=normalized_conversation_id, + ) + except CosmosResourceNotFoundError: + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': normalized_message_id}], + enable_cross_partition_query=True, + )) + if not items: + raise + return items[0] + + +def _serialize_stream_error(error_message, **extra_fields): + payload = {'error': str(error_message or 'Streaming request failed')} + payload.update({key: value for key, value in extra_fields.items() if value is not None}) + return f'data: {json.dumps(payload)}\n\n' + + +def _build_collaboration_stream_request_payload(data, source_conversation_id, message_content): + return { + 'message': message_content, + 'conversation_id': source_conversation_id, + 'hybrid_search': bool(data.get('hybrid_search')), + 'web_search_enabled': bool(data.get('web_search_enabled')), + 'selected_document_id': data.get('selected_document_id'), + 'selected_document_ids': data.get('selected_document_ids') or [], + 'classifications': data.get('classifications'), + 'tags': data.get('tags') or [], + 'image_generation': bool(data.get('image_generation')), + 'doc_scope': data.get('doc_scope'), + 'chat_type': data.get('chat_type', 'user'), + 'active_group_ids': data.get('active_group_ids') or [], + 'active_group_id': data.get('active_group_id'), + 'active_public_workspace_ids': data.get('active_public_workspace_ids') or [], + 'active_public_workspace_id': data.get('active_public_workspace_id'), + 'model_deployment': data.get('model_deployment'), + 'model_id': data.get('model_id'), + 'model_endpoint_id': data.get('model_endpoint_id'), + 'model_provider': data.get('model_provider'), + 'prompt_info': data.get('prompt_info'), + 'agent_info': data.get('agent_info'), + 'reasoning_effort': data.get('reasoning_effort'), + } + + def register_route_backend_collaboration(app): @app.route('/api/collaboration/conversations', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -915,6 +975,7 @@ def post_collaboration_message_api(conversation_id): reply_to_message_id=reply_to_message_id, mentioned_participants=mentioned_participants, ) + create_collaboration_message_notifications(updated_conversation_doc, message_doc) serialized_message = serialize_collaboration_message(message_doc) serialized_conversation = serialize_collaboration_conversation( updated_conversation_doc, @@ -944,6 +1005,386 @@ def post_collaboration_message_api(conversation_id): ) return jsonify({'error': 'Failed to post collaborative conversation message'}), 500 + @app.route('/api/collaboration/conversations//stream', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def stream_collaboration_message_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + message_content = str(data.get('content') or data.get('message') or '').strip() + reply_to_message_id = str(data.get('reply_to_message_id') or '').strip() or None + if not message_content: + return jsonify({'error': 'content is required'}), 400 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + source_conversation_doc, conversation_doc = ensure_collaboration_source_conversation( + conversation_doc, + current_user, + ) + source_conversation_id = str((source_conversation_doc or {}).get('id') or '').strip() + if not source_conversation_id: + return jsonify({'error': 'Failed to initialize collaboration AI context'}), 500 + + mentioned_participants = resolve_collaboration_mentions( + conversation_doc, + data.get('mentioned_participants'), + ) + invocation_target = data.get('invocation_target') if isinstance(data.get('invocation_target'), dict) else None + extra_metadata = {} + if invocation_target: + extra_metadata['ai_invocation_target'] = invocation_target + + user_message_doc, updated_conversation_doc = persist_collaboration_message( + conversation_doc, + current_user, + message_content, + reply_to_message_id=reply_to_message_id, + mentioned_participants=mentioned_participants, + message_kind=MESSAGE_KIND_AI_REQUEST, + extra_metadata=extra_metadata, + ) + user_message_doc.setdefault('metadata', {})['source_conversation_id'] = source_conversation_id + cosmos_collaboration_messages_container.upsert_item(user_message_doc) + + create_collaboration_message_notifications(updated_conversation_doc, user_message_doc) + serialized_user_message = serialize_collaboration_message(user_message_doc) + serialized_user_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_user_conversation, + 'message': serialized_user_message, + }, + ), + ) + + session_snapshot = dict(session) + source_owner_user = normalize_collaboration_user({ + 'user_id': updated_conversation_doc.get('created_by_user_id'), + 'display_name': updated_conversation_doc.get('created_by_display_name'), + }) or current_user + stream_request_payload = _build_collaboration_stream_request_payload( + data, + source_conversation_id, + message_content, + ) + + def generate_stream(): + try: + internal_stream_view = current_app.view_functions.get('chat_stream_api') + if not callable(internal_stream_view): + yield _serialize_stream_error( + 'Chat streaming endpoint is unavailable', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + return + + buffer = '' + with current_app.test_request_context('/api/chat/stream', method='POST', json=stream_request_payload): + session.clear() + session.update(session_snapshot) + internal_response = current_app.make_response(internal_stream_view()) + + if int(internal_response.status_code or 500) >= 400: + try: + error_payload = internal_response.get_json(silent=True) or {} + except Exception: + error_payload = {} + yield _serialize_stream_error( + error_payload.get('error') or error_payload.get('message') or 'Failed to start collaboration AI workflow', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + return + + def transform_event_block(event_block): + normalized_event_block = str(event_block or '') + if not normalized_event_block.strip(): + return None + + if normalized_event_block.lstrip().startswith(':'): + return normalized_event_block + '\n\n' + + data_lines = [ + line for line in normalized_event_block.split('\n') + if line.startswith('data:') + ] + if not data_lines: + return normalized_event_block + '\n\n' + + json_text = '\n'.join(line[5:].lstrip() for line in data_lines) + try: + stream_payload = json.loads(json_text) + except json.JSONDecodeError: + return normalized_event_block + '\n\n' + + if stream_payload.get('error'): + return _serialize_stream_error( + stream_payload.get('error'), + partial_content=stream_payload.get('partial_content'), + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + if not stream_payload.get('done'): + return normalized_event_block + '\n\n' + + source_message_id = str(stream_payload.get('message_id') or '').strip() + if not source_message_id: + return _serialize_stream_error( + 'AI workflow completed without a source assistant message', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + source_user_message_id = str(stream_payload.get('user_message_id') or '').strip() + if source_user_message_id: + try: + saved_user_message_doc = cosmos_collaboration_messages_container.read_item( + item=serialized_user_message.get('id'), + partition_key=conversation_id, + ) + saved_user_message_doc['metadata'] = { + **dict(saved_user_message_doc.get('metadata', {}) or {}), + 'source_message_id': source_user_message_id, + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': current_user['user_id'], + } + cosmos_collaboration_messages_container.upsert_item(saved_user_message_doc) + except Exception: + pass + + try: + source_message_doc = _read_source_message_doc(source_conversation_id, source_message_id) + except CosmosResourceNotFoundError: + return _serialize_stream_error( + 'Failed to load the generated assistant response', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + mirrored_message_doc, final_conversation_doc, _ = mirror_source_message_to_collaboration( + updated_conversation_doc, + source_message_doc, + source_owner_user, + reply_to_message_id=serialized_user_message.get('id'), + extra_metadata={ + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': current_user['user_id'], + }, + ) + if not mirrored_message_doc: + return _serialize_stream_error( + 'Failed to mirror the assistant response into the collaboration conversation', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + create_collaboration_message_notifications(final_conversation_doc, mirrored_message_doc) + serialized_assistant_message = serialize_collaboration_message(mirrored_message_doc) + serialized_final_conversation = serialize_collaboration_conversation( + final_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_final_conversation, + 'message': serialized_assistant_message, + }, + ), + ) + + transformed_payload = { + **stream_payload, + 'conversation_id': conversation_id, + 'conversation_title': serialized_final_conversation.get('title'), + 'chat_type': serialized_final_conversation.get('chat_type'), + 'classification': serialized_final_conversation.get('classification', []), + 'context': serialized_final_conversation.get('context', []), + 'scope_locked': serialized_final_conversation.get('scope_locked'), + 'locked_contexts': serialized_final_conversation.get('locked_contexts', []), + 'message_id': serialized_assistant_message.get('id'), + 'user_message_id': serialized_user_message.get('id'), + 'model_deployment_name': serialized_assistant_message.get('model_deployment_name') or stream_payload.get('model_deployment_name'), + 'augmented': serialized_assistant_message.get('augmented', False), + 'hybrid_citations': serialized_assistant_message.get('hybrid_citations', []), + 'web_search_citations': serialized_assistant_message.get('web_search_citations', []), + 'agent_citations': serialized_assistant_message.get('agent_citations', []), + 'agent_display_name': serialized_assistant_message.get('agent_display_name'), + 'agent_name': serialized_assistant_message.get('agent_name'), + 'full_content': mirrored_message_doc.get('content') if serialized_assistant_message.get('role') != 'image' else stream_payload.get('full_content', ''), + 'image_url': mirrored_message_doc.get('content') if serialized_assistant_message.get('role') == 'image' else stream_payload.get('image_url'), + 'reload_messages': False, + } + return f'data: {json.dumps(transformed_payload)}\n\n' + + for chunk in internal_response.response: + if chunk is None: + continue + + chunk_text = chunk.decode('utf-8') if isinstance(chunk, (bytes, bytearray)) else str(chunk) + buffer += chunk_text.replace('\r', '') + + while '\n\n' in buffer: + event_block, buffer = buffer.split('\n\n', 1) + transformed_block = transform_event_block(event_block) + if transformed_block: + yield transformed_block + + if buffer.strip(): + transformed_block = transform_event_block(buffer.strip()) + if transformed_block: + yield transformed_block + except Exception as exc: + log_event( + f'[Collaboration] Failed to stream AI message for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + yield _serialize_stream_error( + 'Failed to stream collaborative AI response', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + return Response(stream_with_context(generate_stream()), mimetype='text/event-stream') + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to start AI stream for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to start collaborative AI workflow'}), 500 + + @app.route('/api/collaboration/conversations//messages/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def delete_collaboration_message_api(conversation_id, message_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + deleted_message_doc, updated_conversation_doc = delete_collaboration_message( + conversation_id, + message_id, + current_user['user_id'], + ) + serialized_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.deleted', + { + 'conversation': serialized_conversation, + 'message_id': message_id, + 'deleted_by_user_id': current_user['user_id'], + 'deleted_message': { + 'id': deleted_message_doc.get('id'), + 'sender_user_id': ( + ((deleted_message_doc.get('metadata') or {}).get('sender') or {}).get('user_id') + ), + }, + }, + ), + ) + return jsonify({ + 'success': True, + 'deleted_message_ids': [message_id], + 'archived': False, + 'conversation': serialized_conversation, + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative message not found'}), 404 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to delete message {message_id} for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to delete collaborative conversation message'}), 500 + + @app.route('/api/collaboration/conversations//mark-read', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def mark_collaboration_conversation_read_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + notifications_marked_read = mark_collaboration_message_notifications_read_for_conversation( + current_user['user_id'], + conversation_id, + ) + + return jsonify({ + 'success': True, + 'conversation_id': conversation_id, + 'notifications_marked_read': notifications_marked_read, + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to mark conversation {conversation_id} read: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to mark collaborative conversation read'}), 500 + @app.route('/api/collaboration/conversations//typing', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index dd7f5fe4..2ae4f31d 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1609,6 +1609,34 @@ a.citation-link:hover { font-size: 0.85rem; color: #495057; } +.collaboration-mentions-block { + margin-bottom: 0.5rem; +} +.collaboration-mentions-label { + margin-bottom: 0.25rem; + font-size: 0.78rem; + font-weight: 600; + color: #146c43; +} +.collaboration-mentions-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.collaboration-mention-chip { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem; + border-radius: 999px; + background-color: rgba(25, 135, 84, 0.12); + color: #146c43; + font-size: 0.78rem; + font-weight: 600; +} +.collaboration-mention-chip-current-user { + background-color: rgba(13, 110, 253, 0.15); + color: #0a58ca; +} .collaboration-mention-menu { position: absolute; @@ -1625,6 +1653,17 @@ a.citation-link:hover { .collaboration-mention-menu .list-group-item.active { background-color: #0d6efd; border-color: #0d6efd; +[data-bs-theme="dark"] .collaboration-mentions-label { + color: #8dd7a3; +} +[data-bs-theme="dark"] .collaboration-mention-chip { + background-color: rgba(73, 167, 97, 0.2); + color: #d9f3e2; +} +[data-bs-theme="dark"] .collaboration-mention-chip-current-user { + background-color: rgba(84, 141, 255, 0.25); + color: #d7e4ff; +} } .collaboration-participant-results { diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 73900071..50fc1801 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -4,6 +4,7 @@ import { appendMessage, updateSendButtonVisibility, updateUserMessageId, userInp import { applyConversationMetadataUpdate } from './chat-conversations.js'; import { loadUserSettings, saveUserSetting } from './chat-layout.js'; import { showToast } from './chat-toast.js'; +import { sendMessageWithStreaming } from './chat-streaming.js'; const RECENT_COLLABORATORS_KEY = 'recentCollaborators'; const MAX_RECENT_COLLABORATORS = 12; @@ -39,6 +40,7 @@ const promptedPendingInviteConversationIds = new Set(); const seenCollaborationEventKeys = new Set(); const collaborationMessageCache = new Map(); const collaborationConversationCache = new Map(); +const collaborationMarkReadRequests = new Map(); function isCollaborationEnabled() { return Boolean(window.appSettings?.enable_collaborative_conversations); @@ -67,6 +69,43 @@ function getConversationChatType(conversationId) { return item?.getAttribute('data-chat-type') || null; } +function markCollaborationConversationRead(conversationId, options = {}) { + const { suppressErrorToast = false } = options; + if (!conversationId) { + return Promise.resolve(null); + } + + if (collaborationMarkReadRequests.has(conversationId)) { + return collaborationMarkReadRequests.get(conversationId); + } + + const request = fetch(`/api/collaboration/conversations/${conversationId}/mark-read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(async response => { + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + throw new Error(payload.error || 'Failed to clear shared conversation notifications'); + } + return payload; + }) + .catch(error => { + if (!suppressErrorToast) { + showToast(`Failed to clear shared conversation notifications: ${error.message}`, 'danger'); + } + throw error; + }) + .finally(() => { + collaborationMarkReadRequests.delete(conversationId); + }); + + collaborationMarkReadRequests.set(conversationId, request); + return request; +} + function setConversationDataset(conversationId, metadata = {}) { const conversationSelectors = [ `.conversation-item[data-conversation-id="${conversationId}"]`, @@ -233,13 +272,22 @@ async function loadMentionSuggestions(conversationId, query = '') { limit: DEFAULT_SUGGESTION_LIMIT, }); const inviteSuggestions = collaboratorSuggestions - .map(collaborator => normalizeCollaborator(collaborator)) + .map(collaborator => { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return null; + } + + return { + ...normalizedCollaborator, + source: collaborator.source || 'local', + }; + }) .filter(Boolean) .filter(collaborator => !seenUserIds.has(collaborator.user_id)) .map(collaborator => ({ ...collaborator, action: 'invite', - source: collaborator.source || 'local', })); return [...participantSuggestions, ...inviteSuggestions]; @@ -388,10 +436,13 @@ function replyToMessage(message = {}) { userInput?.focus(); } -function getPendingMessageContext() { +function getPendingMessageContext(options = {}) { const conversationId = window.chatConversations?.getCurrentConversationId?.(); const mentionedParticipants = extractMentionedParticipantsFromMessage(userInput?.value || '', conversationId); - if (!activeReplyContext && mentionedParticipants.length === 0) { + const invocationTarget = options.invocationTarget && typeof options.invocationTarget === 'object' + ? options.invocationTarget + : null; + if (!activeReplyContext && mentionedParticipants.length === 0 && !invocationTarget) { return null; } @@ -405,6 +456,10 @@ function getPendingMessageContext() { metadata.mentioned_participants = mentionedParticipants; metadata.mentioned_user_ids = mentionedParticipants.map(participant => participant.user_id); } + if (invocationTarget) { + metadata.ai_invocation_target = { ...invocationTarget }; + metadata.explicit_ai_invocation = true; + } return { reply_to_message_id: activeReplyContext?.message_id || null, @@ -425,6 +480,24 @@ function cacheCollaborationMessage(message = {}) { }); } +function removeCollaborationMessage(messageId) { + const normalizedMessageId = String(messageId || '').trim(); + if (!normalizedMessageId) { + return; + } + + collaborationMessageCache.delete(normalizedMessageId); + + const messageElement = document.querySelector(`[data-message-id="${normalizedMessageId}"]`); + if (messageElement) { + messageElement.remove(); + } + + if (activeReplyContext?.message_id === normalizedMessageId) { + clearReplyTarget({ focusComposer: false }); + } +} + function clearMessageCache() { collaborationMessageCache.clear(); } @@ -451,7 +524,7 @@ function buildEventKey(eventEnvelope = {}) { return [ eventEnvelope.conversation_id || payload.conversation?.id || '', eventEnvelope.event_type || '', - payload.message?.id || payload.participant?.user_id || payload.user?.user_id || payload.deleted_by_user_id || '', + payload.message?.id || payload.message_id || payload.participant?.user_id || payload.user?.user_id || payload.deleted_by_user_id || '', eventEnvelope.occurred_at || '', ].join('|'); } @@ -622,6 +695,18 @@ function resolveMessageSenderType(message) { return 'AI'; } + if (message.role === 'image') { + return 'image'; + } + + if (message.role === 'file') { + return 'File'; + } + + if (message.role === 'safety') { + return 'safety'; + } + const senderUserId = message.sender?.user_id || message.metadata?.sender?.user_id || null; if (senderUserId && senderUserId === getCurrentUserId()) { return 'You'; @@ -797,6 +882,7 @@ function handleConversationEvent(eventEnvelope = {}) { if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { const senderUserId = String(payload.message?.sender?.user_id || payload.message?.metadata?.sender?.user_id || '').trim(); + const shouldClearNotifications = Boolean(senderUserId && senderUserId !== getCurrentUserId()); if (senderUserId && senderUserId !== getCurrentUserId() && isCurrentUserMentioned(payload.message)) { const senderName = normalizeCollaborator(payload.message.sender || payload.message.metadata?.sender || {})?.display_name || 'A participant'; showToast(`${senderName} tagged you in a shared message.`, 'info'); @@ -805,9 +891,19 @@ function handleConversationEvent(eventEnvelope = {}) { const decoratedMessage = decorateReplyMessage(payload.message); cacheCollaborationMessage(payload.message); if (reconcilePendingCollaborativeUserMessage(payload.message)) { + if (shouldClearNotifications) { + void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { + suppressErrorToast: true, + }).catch(() => {}); + } return; } renderCollaborationMessage(decoratedMessage, { isNewMessage: true }); + if (shouldClearNotifications) { + void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { + suppressErrorToast: true, + }).catch(() => {}); + } return; } @@ -816,6 +912,14 @@ function handleConversationEvent(eventEnvelope = {}) { return; } + if (eventEnvelope.event_type === 'collaboration.message.deleted' && payload.message_id) { + removeCollaborationMessage(payload.message_id); + if (payload.deleted_by_user_id && payload.deleted_by_user_id !== getCurrentUserId()) { + showToast('A shared message was deleted.', 'info'); + } + return; + } + if (eventEnvelope.event_type === 'collaboration.member.invited' && Array.isArray(payload.participants)) { return; } @@ -941,10 +1045,15 @@ async function fetchCollaborationConversationList() { } async function activateConversation(conversationId, metadata = null) { - const conversationMetadata = metadata || await fetchConversationMetadata(conversationId); + const conversationMetadata = metadata + ? cacheCollaborationConversation(metadata) + : await fetchConversationMetadata(conversationId); updateComposerAvailability(conversationMetadata); clearReplyTarget({ focusComposer: false }); await loadConversationMessages(conversationId); + markCollaborationConversationRead(conversationId, { suppressErrorToast: true }).catch(error => { + console.warn('Failed to clear shared conversation notifications:', error); + }); subscribeToConversationEvents(conversationId); if (conversationMetadata.can_accept_invite && !promptedPendingInviteConversationIds.has(conversationId)) { @@ -1005,6 +1114,51 @@ async function sendCollaborativeMessage(messageText, tempMessageId = null) { return payload; } +async function sendCollaborativeAiMessage(messageText, tempMessageId = null, messageData = {}, pendingContext = null) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + throw new Error('No collaborative conversation is active.'); + } + + const mentionedParticipants = extractMentionedParticipantsFromMessage(messageText, conversationId); + const invocationTarget = pendingContext?.metadata?.ai_invocation_target || null; + const requestBody = { + ...messageData, + content: messageText, + reply_to_message_id: activeReplyContext?.message_id || null, + mentioned_participants: mentionedParticipants, + invocation_target: invocationTarget, + }; + + sendMessageWithStreaming( + requestBody, + tempMessageId, + conversationId, + { + endpoint: `/api/collaboration/conversations/${encodeURIComponent(conversationId)}/stream`, + allowRecovery: false, + onError: (errorMessage, errorData = null) => { + if (errorData?.user_message_id && tempMessageId) { + updateUserMessageId(tempMessageId, errorData.user_message_id); + } + + if (errorData?.message_persisted === true) { + return; + } + + const tempMessage = document.querySelector(`[data-message-id="${tempMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + }, + }, + ); + + setTypingState(false, { force: true }); + clearReplyTarget(); + return { started: true }; +} + function setTypingState(isTyping, options = {}) { const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); if (!conversationId || !isCollaborationConversation(conversationId) || !canPostMessages(conversationId)) { @@ -1103,7 +1257,7 @@ function renderMentionMenu(results, mentionState) { } if (!Array.isArray(results) || results.length === 0) { - mentionMenu.innerHTML = '
    No local collaborators found.
    '; + mentionMenu.innerHTML = '
    No participants or collaborators found.
    '; mentionMenu.classList.remove('d-none'); activeMentionState = { ...mentionState, @@ -1309,7 +1463,7 @@ async function addParticipantToConversation(conversationId, collaborator) { }), }); - const normalizedConversation = normalizeCollaborationConversation(payload.conversation || {}); + const normalizedConversation = cacheCollaborationConversation(payload.conversation || {}); await rememberRecentCollaborator(collaborator); if (window.chatConversations?.loadConversations) { @@ -1556,9 +1710,11 @@ window.chatCollaboration = { removeParticipant, replyToMessage, respondToInvite, + sendCollaborativeAiMessage, sendCollaborativeMessage, updateParticipantRole, canUseParticipantFlow, + markConversationRead: markCollaborationConversationRead, }; if (document.readyState === 'loading') { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 848e1f05..7c8ae920 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -766,6 +766,117 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
    `; } + function escapeMentionPattern(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function getMentionedParticipants(fullMessageObject = null) { + const rawMentions = Array.isArray(fullMessageObject?.metadata?.mentioned_participants) + ? fullMessageObject.metadata.mentioned_participants + : []; + + return rawMentions + .map(participant => ({ + user_id: String(participant?.user_id || "").trim(), + display_name: String(participant?.display_name || participant?.name || participant?.email || "").trim(), + email: String(participant?.email || "").trim(), + })) + .filter(participant => participant.user_id && participant.display_name); + } + + function stripMentionTextFromMessageContent(messageContent, fullMessageObject = null) { + let normalizedMessageContent = String(messageContent ?? ""); + if (!normalizedMessageContent.trim()) { + return normalizedMessageContent; + } + + const mentions = getMentionedParticipants(fullMessageObject) + .slice() + .sort((left, right) => right.display_name.length - left.display_name.length); + if (mentions.length === 0) { + return normalizedMessageContent; + } + + mentions.forEach(participant => { + const displayName = String(participant.display_name || "").trim(); + if (!displayName) { + return; + } + + const mentionPattern = new RegExp( + `(^|\\s)@${escapeMentionPattern(displayName)}(?=$|\\s|[.,!?;:])`, + "gi" + ); + normalizedMessageContent = normalizedMessageContent.replace( + mentionPattern, + (match, leadingWhitespace) => leadingWhitespace || "" + ); + }); + + return normalizedMessageContent + .replace(/[ \t]{2,}/g, " ") + .replace(/\s+\n/g, "\n") + .replace(/\n\s+/g, "\n") + .replace(/\s+([.,!?;:])/g, "$1") + .trim(); + } + + function renderMentionTagsHtml(fullMessageObject = null) { + const mentions = getMentionedParticipants(fullMessageObject); + if (mentions.length === 0) { + return ""; + } + + const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || "").trim(); + const mentionChipsHtml = mentions.map(participant => { + const isCurrentUser = currentUserId && participant.user_id === currentUserId; + const currentUserClass = isCurrentUser ? " collaboration-mention-chip-current-user" : ""; + return `@${escapeHtml(participant.display_name)}`; + }).join(""); + + return ` +
    +
    Tagged
    +
    ${mentionChipsHtml}
    +
    `; + } + + function getInvocationTarget(fullMessageObject = null) { + const target = fullMessageObject?.metadata?.ai_invocation_target; + if (!target || typeof target !== "object") { + return null; + } + + const displayName = String(target.display_name || target.label || "").trim(); + if (!displayName) { + return null; + } + + const targetType = String(target.target_type || target.type || "model").trim() || "model"; + const sourceMode = String(target.source_mode || target.mode || "").trim() || null; + return { + target_type: targetType, + display_name: displayName, + mention_text: String(target.mention_text || `@${displayName}`).trim() || `@${displayName}`, + source_mode: sourceMode, + }; + } + + function renderInvocationTargetHtml(fullMessageObject = null) { + const invocationTarget = getInvocationTarget(fullMessageObject); + if (!invocationTarget) { + return ""; + } + + return ` +
    +
    Invoking
    +
    + ${escapeHtml(invocationTarget.mention_text)} +
    +
    `; + } + export function appendMessage( sender, messageContent, @@ -1251,9 +1362,10 @@ export function appendMessage( } else { avatarImg = "/static/images/user-avatar.png"; } - + + const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); const sanitizedUserHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(messageContent)) + marked.parse(escapeHtml(renderedMessageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml); } else if (sender === "Collaborator") { @@ -1263,8 +1375,9 @@ export function appendMessage( || "Participant"; avatarAltText = `${senderLabel} Avatar`; avatarHtml = createCollaboratorAvatarHtml(fullMessageObject, senderLabel); + const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); const sanitizedCollaboratorHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(messageContent)) + marked.parse(escapeHtml(renderedMessageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml); } else if (sender === "File") { @@ -1351,6 +1464,13 @@ export function appendMessage( const replyQuoteHtml = (sender === "You" || sender === "Collaborator") ? renderReplyQuoteHtml(fullMessageObject) : ""; + const invocationTargetHtml = (sender === "You" || sender === "Collaborator") + ? renderInvocationTargetHtml(fullMessageObject) + : ""; + const mentionTagsHtml = (sender === "You" || sender === "Collaborator") + ? renderMentionTagsHtml(fullMessageObject) + : ""; + const hasVisibleMessageText = Boolean(stripHtmlTags(messageContentHtml || "").replace(/\s+/g, " ").trim()); if (sender === "You") { const metadataContainerId = `metadata-${messageId || Date.now()}`; const isMasked = fullMessageObject?.metadata?.masked || (fullMessageObject?.metadata?.masked_ranges && fullMessageObject.metadata.masked_ranges.length > 0); @@ -1397,6 +1517,7 @@ export function appendMessage(
    `; metadataContainerHtml = ``; } else if (sender === "Collaborator") { + const metadataContainerId = `metadata-${messageId || Date.now()}`; messageFooterHtml = `
    -
    +
    + +
    `; + metadataContainerHtml = ``; } else if (sender === "image" || sender === "File") { // Image and file messages get mask button on left, metadata button on right side const metadataContainerId = `metadata-${messageId || Date.now()}`; @@ -1472,7 +1598,9 @@ export function appendMessage( ${fullMessageObject?.metadata?.retried ? 'Retried' : ''} ${replyQuoteHtml} -
    ${messageContentHtml}
    + ${invocationTargetHtml} + ${mentionTagsHtml} + ${hasVisibleMessageText ? `
    ${messageContentHtml}
    ` : ""} ${metadataContainerHtml} ${messageFooterHtml} @@ -1666,44 +1794,12 @@ export function sendMessage() { userInput.focus(); } -export function actuallySendMessage(finalMessageToSend) { - const isCollaborativeConversation = Boolean( - currentConversationId - && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) - ); - - if (isCollaborativeConversation) { - const tempUserMessageId = `temp_user_${Date.now()}`; - const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.() || null; - appendMessage("You", finalMessageToSend, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); - userInput.value = ""; - userInput.style.height = ""; - updateSendButtonVisibility(); - - window.chatCollaboration.sendCollaborativeMessage(finalMessageToSend, tempUserMessageId).catch(error => { - const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); - if (tempMessage) { - tempMessage.remove(); - } - showToast(error.message || 'Failed to send shared message.', 'danger'); - }); - return; - } - - // Generate a temporary message ID for the user message - const tempUserMessageId = `temp_user_${Date.now()}`; - - // Append user message first with temporary ID - appendMessage("You", finalMessageToSend, null, tempUserMessageId); - userInput.value = ""; - userInput.style.height = ""; - // Update send button visibility after clearing input - updateSendButtonVisibility(); - +function getCurrentModelSelection() { let modelDeployment = modelSelect?.value; let modelId = null; let modelEndpointId = null; let modelProvider = null; + if (window.appSettings?.enable_multi_model_endpoints && modelSelect) { const selectedOption = modelSelect.options[modelSelect.selectedIndex]; modelId = selectedOption?.dataset?.modelId || selectedOption?.value || null; @@ -1712,35 +1808,73 @@ export function actuallySendMessage(finalMessageToSend) { modelDeployment = selectedOption?.dataset?.deploymentName || null; } - // ... (keep existing logic for hybridSearchEnabled, selectedDocumentId, classificationsToSend, imageGenEnabled) + return { + modelDeployment, + modelId, + modelEndpointId, + modelProvider, + modelDisplayName: String( + modelSelect?.options?.[modelSelect.selectedIndex]?.dataset?.displayName + || modelSelect?.options?.[modelSelect.selectedIndex]?.textContent + || modelDeployment + || 'Model' + ).trim() || 'Model', + }; +} + +function getCurrentAgentSelection() { + const agentSelectContainer = document.getElementById('agent-select-container'); + const agentSelect = document.getElementById('agent-select'); + if (!areAgentsEnabled() || !agentSelectContainer || agentSelectContainer.style.display === 'none' || !agentSelect) { + return null; + } + + const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; + if (!selectedAgentOption) { + return null; + } + + return { + id: selectedAgentOption.dataset.agentId || null, + name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', + display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, + is_global: selectedAgentOption.dataset.isGlobal === 'true', + is_group: selectedAgentOption.dataset.isGroup === 'true', + group_id: selectedAgentOption.dataset.groupId || null, + group_name: selectedAgentOption.dataset.groupName || null, + }; +} + +export function buildChatRequestPayload(finalMessageToSend, conversationId = currentConversationId) { + const { + modelDeployment, + modelId, + modelEndpointId, + modelProvider, + } = getCurrentModelSelection(); + let hybridSearchEnabled = false; - const sdbtn = document.getElementById("search-documents-btn"); - if (sdbtn && sdbtn.classList.contains("active")) { + const sdbtn = document.getElementById('search-documents-btn'); + if (sdbtn && sdbtn.classList.contains('active')) { hybridSearchEnabled = true; } let selectedDocumentId = null; let selectedDocumentIds = []; - const docSel = document.getElementById("document-select"); - - // Read all selected document IDs (multi-select support) + const docSel = document.getElementById('document-select'); if (docSel) { selectedDocumentIds = Array.from(docSel.selectedOptions) - .map(o => o.value) - .filter(v => v); // Filter out empty strings - // For backwards compat, set single ID to first selected or null + .map(option => option.value) + .filter(value => value); selectedDocumentId = selectedDocumentIds.length > 0 ? selectedDocumentIds[0] : null; } let imageGenEnabled = false; - const igbtn = document.getElementById("image-generate-btn"); - if (igbtn && igbtn.classList.contains("active")) { + const igbtn = document.getElementById('image-generate-btn'); + if (igbtn && igbtn.classList.contains('active')) { imageGenEnabled = true; } - // --- Robust chat_type/group_id logic --- - // Assume: window.activeChatTabType = 'user' | 'group', window.activeGroupId = group id if group tab - // If you add a group chat tab, set window.activeChatTabType and window.activeGroupId accordingly when switching tabs let chat_type = 'user'; let group_id = null; if (window.activeChatTabType === 'group' && window.activeGroupId) { @@ -1748,95 +1882,66 @@ export function actuallySendMessage(finalMessageToSend) { group_id = window.activeGroupId; } - // Collect prompt information if a prompt is selected let promptInfo = null; if ( - promptSelectionContainer && - promptSelectionContainer.style.display !== "none" && - promptSelect && - promptSelect.selectedIndex > 0 + promptSelectionContainer + && promptSelectionContainer.style.display !== 'none' + && promptSelect + && promptSelect.selectedIndex > 0 ) { const selectedOpt = promptSelect.options[promptSelect.selectedIndex]; if (selectedOpt) { promptInfo = { name: selectedOpt.textContent, id: selectedOpt.value, - content: selectedOpt.dataset?.promptContent || "" - }; - } - } - - // Collect agent information if agents are enabled - let agentInfo = null; - const agentSelectContainer = document.getElementById("agent-select-container"); - const agentSelect = document.getElementById("agent-select"); - if (agentSelectContainer && agentSelectContainer.style.display !== "none" && agentSelect) { - const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; - if (selectedAgentOption) { - agentInfo = { - id: selectedAgentOption.dataset.agentId || null, - name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', - display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, - is_global: selectedAgentOption.dataset.isGlobal === 'true', - is_group: selectedAgentOption.dataset.isGroup === 'true', - group_id: selectedAgentOption.dataset.groupId || null, - group_name: selectedAgentOption.dataset.groupName || null + content: selectedOpt.dataset?.promptContent || '', }; } } - // Get effective scopes from multi-select scope dropdown + const agentInfo = getCurrentAgentSelection(); const scopes = getEffectiveScopes(); - // Determine the correct doc_scope based on selected scopes - let effectiveDocScope = "all"; + let effectiveDocScope = 'all'; if (scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length === 0) { - effectiveDocScope = "personal"; + effectiveDocScope = 'personal'; } else if (!scopes.personal && scopes.groupIds.length > 0 && scopes.publicWorkspaceIds.length === 0) { - effectiveDocScope = "group"; + effectiveDocScope = 'group'; } else if (!scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length > 0) { - effectiveDocScope = "public"; + effectiveDocScope = 'public'; } - // If documents are selected, determine the actual scope from the documents themselves if (selectedDocumentIds.length > 0) { const docScopes = new Set(); selectedDocumentIds.forEach(docId => { if (personalDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("personal"); + docScopes.add('personal'); } else if (groupDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("group"); + docScopes.add('group'); } else if (publicDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("public"); + docScopes.add('public'); } }); - // Only narrow scope if ALL selected docs are from the same scope if (docScopes.size === 1) { effectiveDocScope = docScopes.values().next().value; console.log(`All selected documents are from scope: ${effectiveDocScope}`); } else if (docScopes.size > 1) { - effectiveDocScope = "all"; + effectiveDocScope = 'all'; console.log(`Selected documents span ${docScopes.size} scopes (${[...docScopes].join(', ')}), keeping scope as "all"`); } } - // Use group IDs from scope selector; fall back to window.activeGroupId for backwards compat const finalGroupIds = scopes.groupIds.length > 0 ? scopes.groupIds : (window.activeGroupId ? [window.activeGroupId] : []); - const finalGroupId = finalGroupIds[0] || window.activeGroupId || null; - const webSearchToggle = document.getElementById("search-web-btn"); - const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains("active") : false; - - // Prepare message data object - // Get public workspace IDs from scope selector; fall back to window.activePublicWorkspaceId + const finalGroupId = finalGroupIds[0] || group_id || null; + const webSearchToggle = document.getElementById('search-web-btn'); + const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains('active') : false; const finalPublicWorkspaceId = scopes.publicWorkspaceIds[0] || window.activePublicWorkspaceId || null; - - // Get selected tags from chat-documents module const selectedTags = getSelectedTags(); - const messageData = { + return { message: finalMessageToSend, - conversation_id: currentConversationId, + conversation_id: conversationId, hybrid_search: hybridSearchEnabled, web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, @@ -1845,7 +1950,7 @@ export function actuallySendMessage(finalMessageToSend) { tags: selectedTags, image_generation: imageGenEnabled, doc_scope: effectiveDocScope, - chat_type: chat_type, + chat_type, active_group_ids: finalGroupIds, active_group_id: finalGroupId, active_public_workspace_ids: scopes.publicWorkspaceIds, @@ -1856,8 +1961,117 @@ export function actuallySendMessage(finalMessageToSend) { model_provider: modelProvider, prompt_info: promptInfo, agent_info: agentInfo, - reasoning_effort: getCurrentReasoningEffort() + reasoning_effort: getCurrentReasoningEffort(), + }; +} + +export function buildCollaborativeInvocationTarget(messageData = {}) { + if (!messageData || typeof messageData !== 'object') { + return null; + } + + const hasAgentTarget = Boolean( + messageData.agent_info + && (messageData.agent_info.id || messageData.agent_info.name || messageData.agent_info.display_name) + ); + const sourceMode = messageData.image_generation + ? 'image_generation' + : hasAgentTarget + ? 'agent' + : messageData.web_search_enabled + ? 'web_search' + : messageData.hybrid_search + ? 'workspace' + : messageData.prompt_info + ? 'prompt' + : null; + + if (!sourceMode) { + return null; + } + + if (messageData.image_generation) { + return { + target_type: 'image', + display_name: 'Image', + mention_text: '@Image', + source_mode: sourceMode, + }; + } + + if (hasAgentTarget) { + const agentLabel = String( + messageData.agent_info.display_name + || messageData.agent_info.name + || messageData.agent_info.id + || 'Agent' + ).trim() || 'Agent'; + return { + target_type: 'agent', + display_name: agentLabel, + mention_text: `@${agentLabel}`, + source_mode: sourceMode, + }; + } + + const { modelDisplayName } = getCurrentModelSelection(); + return { + target_type: 'model', + display_name: modelDisplayName, + mention_text: `@${modelDisplayName}`, + source_mode: sourceMode, }; +} + +export function shouldUseCollaborativeAiWorkflow(messageData = {}) { + return Boolean(buildCollaborativeInvocationTarget(messageData)); +} + +export function actuallySendMessage(finalMessageToSend) { + const isCollaborativeConversation = Boolean( + currentConversationId + && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) + ); + + if (isCollaborativeConversation) { + const tempUserMessageId = `temp_user_${Date.now()}`; + const collaborativeMessageData = buildChatRequestPayload(finalMessageToSend, currentConversationId); + const invocationTarget = buildCollaborativeInvocationTarget(collaborativeMessageData); + const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.({ invocationTarget }) || null; + appendMessage("You", finalMessageToSend, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); + userInput.value = ""; + userInput.style.height = ""; + updateSendButtonVisibility(); + + const collaborativeSendOperation = shouldUseCollaborativeAiWorkflow(collaborativeMessageData) + ? window.chatCollaboration.sendCollaborativeAiMessage?.( + finalMessageToSend, + tempUserMessageId, + collaborativeMessageData, + pendingCollaborativeContext, + ) + : window.chatCollaboration.sendCollaborativeMessage(finalMessageToSend, tempUserMessageId); + + Promise.resolve(collaborativeSendOperation).catch(error => { + const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + showToast(error.message || 'Failed to send shared message.', 'danger'); + }); + return; + } + + // Generate a temporary message ID for the user message + const tempUserMessageId = `temp_user_${Date.now()}`; + + // Append user message first with temporary ID + appendMessage("You", finalMessageToSend, null, tempUserMessageId); + userInput.value = ""; + userInput.style.height = ""; + // Update send button visibility after clearing input + updateSendButtonVisibility(); + const messageData = buildChatRequestPayload(finalMessageToSend, currentConversationId); sendMessageWithStreaming( messageData, tempUserMessageId, @@ -2204,6 +2418,14 @@ function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, }); } + const metadataToggleBtn = messageDiv.querySelector(".metadata-toggle-btn"); + if (metadataToggleBtn) { + metadataToggleBtn.addEventListener("click", () => { + const currentMessageId = messageDiv.getAttribute("data-message-id"); + toggleUserMessageMetadata(messageDiv, currentMessageId); + }); + } + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); if (dropdownToggle && dropdownMenu) { @@ -2423,6 +2645,144 @@ function formatMetadataForDrawer(metadata) { return `${escapeHtml(classification)} (?)`; } } + + if (metadata.message_details) { + content += '
    '; + content += '
    Message Details
    '; + content += '
    '; + + if (metadata.message_details.message_id) { + content += `
    Message ID: ${escapeHtml(metadata.message_details.message_id)}
    `; + } + if (metadata.message_details.conversation_id) { + content += `
    Conversation ID: ${escapeHtml(metadata.message_details.conversation_id)}
    `; + } + if (metadata.message_details.role) { + content += `
    Stored Role: ${createInfoBadge(metadata.message_details.role, 'primary')}
    `; + } + if (metadata.message_details.display_role) { + content += `
    Display Role: ${createInfoBadge(metadata.message_details.display_role, 'info')}
    `; + } + if (metadata.message_details.message_kind) { + content += `
    Message Kind: ${createInfoBadge(metadata.message_details.message_kind, 'secondary')}
    `; + } + if (metadata.message_details.source_role) { + content += `
    Original Role: ${createInfoBadge(metadata.message_details.source_role, 'warning')}
    `; + } + if (metadata.message_details.timestamp) { + content += `
    Timestamp: ${escapeHtml(new Date(metadata.message_details.timestamp).toLocaleString())}
    `; + } + if (metadata.message_details.explicit_ai_invocation !== undefined) { + content += `
    Explicit AI Invocation: ${createStatusBadge(Boolean(metadata.message_details.explicit_ai_invocation))}
    `; + } + + content += '
    '; + } + + if (metadata.reply_context) { + content += '
    '; + content += '
    Reply Context
    '; + content += '
    '; + if (metadata.reply_context.message_id) { + content += `
    Reply Message ID: ${escapeHtml(metadata.reply_context.message_id)}
    `; + } + if (metadata.reply_context.sender_display_name) { + content += `
    Replying To: ${escapeHtml(metadata.reply_context.sender_display_name)}
    `; + } + if (metadata.reply_context.content_preview) { + content += `
    Preview:
    ${escapeHtml(metadata.reply_context.content_preview)}
    `; + } + content += '
    '; + } + + if (Array.isArray(metadata.mentions) && metadata.mentions.length > 0) { + content += '
    '; + content += '
    Tagged Participants
    '; + content += '
    '; + metadata.mentions.forEach(participant => { + content += `@${escapeHtml(participant.display_name || participant.email || participant.user_id || 'Participant')}`; + }); + content += '
    '; + } + + if (metadata.collaboration) { + content += '
    '; + content += '
    Shared Conversation
    '; + content += '
    '; + if (metadata.collaboration.conversation_title) { + content += `
    Conversation: ${escapeHtml(metadata.collaboration.conversation_title)}
    `; + } + if (metadata.collaboration.chat_type) { + content += `
    Collaboration Type: ${createInfoBadge(metadata.collaboration.chat_type, 'success')}
    `; + } + if (metadata.collaboration.participant_count !== undefined) { + content += `
    Participants: ${escapeHtml(metadata.collaboration.participant_count)}
    `; + } + content += '
    '; + } + + if (metadata.file_details) { + content += '
    '; + content += '
    File Details
    '; + content += '
    '; + if (metadata.file_details.filename) { + content += `
    Filename: ${escapeHtml(metadata.file_details.filename)}
    `; + } + if (metadata.file_details.source_message_id) { + content += `
    Source Message ID: ${escapeHtml(metadata.file_details.source_message_id)}
    `; + } + if (metadata.file_details.is_table !== undefined && metadata.file_details.is_table !== null) { + content += `
    Table Data: ${createStatusBadge(Boolean(metadata.file_details.is_table))}
    `; + } + content += '
    '; + } + + if (metadata.image_details) { + content += '
    '; + content += '
    Image Details
    '; + content += '
    '; + if (metadata.image_details.filename) { + content += `
    Filename: ${escapeHtml(metadata.image_details.filename)}
    `; + } + if (metadata.image_details.image_url) { + content += `
    Image URL: ${escapeHtml(metadata.image_details.image_url)}
    `; + } + if (metadata.image_details.is_user_upload !== undefined) { + content += `
    User Upload: ${createStatusBadge(Boolean(metadata.image_details.is_user_upload))}
    `; + } + if (metadata.image_details.extracted_text) { + content += `
    Extracted Text:
    ${escapeHtml(metadata.image_details.extracted_text)}
    `; + } + if (metadata.image_details.vision_analysis) { + content += `
    Vision Analysis:
    ${escapeHtml(metadata.image_details.vision_analysis)}
    `; + } + content += '
    '; + } + + if (metadata.generation_details) { + content += '
    '; + content += '
    Generation Details
    '; + content += '
    '; + if (metadata.generation_details.selected_model) { + content += `
    Model: ${escapeHtml(metadata.generation_details.selected_model)}
    `; + } + if (metadata.generation_details.agent_display_name || metadata.generation_details.agent_name) { + content += `
    Agent: ${escapeHtml(metadata.generation_details.agent_display_name || metadata.generation_details.agent_name)}
    `; + } + if (metadata.generation_details.augmented !== undefined) { + content += `
    Augmented: ${createStatusBadge(Boolean(metadata.generation_details.augmented))}
    `; + } + if (metadata.generation_details.document_citation_count !== undefined) { + content += `
    Document Citations: ${escapeHtml(metadata.generation_details.document_citation_count)}
    `; + } + if (metadata.generation_details.web_citation_count !== undefined) { + content += `
    Web Citations: ${escapeHtml(metadata.generation_details.web_citation_count)}
    `; + } + if (metadata.generation_details.agent_citation_count !== undefined) { + content += `
    Agent Citations: ${escapeHtml(metadata.generation_details.agent_citation_count)}
    `; + } + content += '
    '; + } // User Information Section if (metadata.user_info) { @@ -2596,7 +2956,7 @@ function formatMetadataForDrawer(metadata) { content += '
    '; metadata.uploaded_images.forEach((image, index) => { - const imageId = `image-${messageId || Date.now()}-${index}`; + const imageId = `image-${metadata.message_details?.message_id || Date.now()}-${index}`; content += `
    + +
    +
    +
    + Simple Chat Action Capabilities +
    +
    +

    Enable only the Simple Chat operations this agent should expose. These settings are saved per agent in additional settings.

    +
    +
    +
    +
    diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 0a5b6f35..a325352d 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -443,6 +443,103 @@
    API Information
    + + +
    + + +
    + + +
    + + +
    Use the Azure Cosmos DB account endpoint for the API for NoSQL account.
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    Enter the container partition key path exactly as configured in Cosmos DB.
    +
    +
    + +
    + + +
    + + +
    Managed identity avoids stored secrets. Account keys can be stored in Key Vault when app secret storage is enabled.
    +
    + +
    + + +
    If this action already uses a Key Vault-backed key, leave the stored value unchanged to preserve the existing secret.
    +
    + +
    + Managed Identity uses Azure AD authentication without storing credentials. Assign an Azure Cosmos DB built-in data reader role to the application identity for the target account. +
    +
    + +
    + + +
    + + +
    Optional. Add one field name per line so the model knows which document properties are relevant.
    +
    + +
    +
    + + +
    Hard cap for documents returned per query.
    +
    +
    + + +
    Absolute timeout for Cosmos client requests.
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    @@ -615,6 +712,61 @@
    + + + + +
    +
    +
    + Microsoft Graph Action Capabilities +
    +
    +

    Enable only the Microsoft Graph operations this agent should expose. These settings are saved per agent in additional settings.

    +
    +
    +
    +
    diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index a325352d..fb83ca2c 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -189,6 +189,119 @@
    API Information
    + +
    +
    + Built-in action: SimpleChat does not require a URL or external credentials. + The capabilities below become the default operations exposed by this action. Agents can narrow them further per assignment. +
    + +
    + +
    +
    +
    + +
    +
    + Built-in action: Microsoft Graph uses the signed-in user's delegated permissions and the standard Graph endpoint. + The capabilities below become the default operations exposed by this action. Agents can narrow them further per assignment. +
    + +
    + +
    +
    +
    + +
    +
    + + This action uses SimpleChat's internal document search and the current user's access. No external URL or stored credentials are required. +
    + +
    +
    + +
    +
    + + +
    Used when the action is called without an explicit scope filter.
    +
    +
    + + +
    Maximum number of ranked results returned when a caller does not set top_n.
    +
    +
    +
    +
    + +
    +
    + +

    Chunk retrieval returns all chunks by default. Configure preferred windowing so downstream tools can plan comparisons and summaries consistently.

    +
    +
    + + +
    +
    + + +
    Optional fixed number of pages or chunks per window. Takes precedence over percent.
    +
    +
    + + +
    Optional percent of the document per window when size is not set.
    +
    +
    +
    +
    + +
    +
    + +
    + + +
    Applied when a summarization call does not include its own focus instructions.
    +
    +
    +
    + + +
    Target length for first-pass summaries over each chunk window.
    +
    +
    + + +
    Target length for the final summary output.
    +
    +
    +
    +
    +
    @@ -767,6 +880,108 @@
    + + + + + + diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index 16028a63..c29d41b6 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -283,6 +283,333 @@
    + {% if session.get('user') %} + + + {% endif %} diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index e4ff9a30..e91d705b 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -266,7 +266,11 @@
    Conversations
    diff --git a/application/single_app/templates/workflow_activity.html b/application/single_app/templates/workflow_activity.html new file mode 100644 index 00000000..239059d0 --- /dev/null +++ b/application/single_app/templates/workflow_activity.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block title %}Workflow Activity - {{ app_settings.app_title }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +
    Workflow Activity
    +
    +

    Loading workflow activity...

    + Loading +
    +

    + Resolving the selected workflow run. +

    +
    + +
    +
    +
    +
    +
    Run
    +
    --
    +
    +
    +
    +
    +
    Activities
    +
    0
    +
    +
    +
    +
    +
    Tool Calls
    +
    0
    +
    +
    +
    +
    +
    Started
    +
    --
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Timeline

    +

    Live events roll in here while a workflow run is active.

    +
    +
    +
    +
    +

    No activity yet

    +

    This conversation does not have a captured workflow run yet, or the run completed before activity tracking was available.

    +
    +
    +
    +
    + + +
    +
    + + +{% endblock %} diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index d79cae14..dc823cc0 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -114,6 +114,46 @@ white-space: nowrap; } + /* --- Workflows Table Styles --- */ + #workflows-table th:nth-child(1), + #workflows-table td:nth-child(1) { + width: 24%; + min-width: 180px; + } + #workflows-table th:nth-child(2), + #workflows-table td:nth-child(2) { + width: 24%; + min-width: 180px; + } + #workflows-table th:nth-child(3), + #workflows-table td:nth-child(3) { + width: 15%; + min-width: 130px; + } + #workflows-table th:nth-child(4), + #workflows-table td:nth-child(4) { + width: 19%; + min-width: 180px; + } + #workflows-table th:nth-child(5), + #workflows-table td:nth-child(5) { + width: 18%; + min-width: 220px; + white-space: nowrap; + } + + .workflow-meta { + color: #6c757d; + font-size: 0.85rem; + } + + .workflow-response-preview { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + /* Style for the classification badge */ .classification-badge { display: inline-block; @@ -528,6 +568,22 @@

    Personal Workspace

    {% endif %} {% endif %} {% endif %} + {% if settings.allow_user_workflows %} + + {% endif %} {% if settings.allow_user_custom_endpoints and settings.enable_multi_model_endpoints %} {% if settings.per_user_semantic_kernel and settings.enable_semantic_kernel %} @@ -305,7 +305,7 @@

    Group Workspace

    aria-controls="group-agents-tab" aria-selected="false" > - Group Agents + Agents @@ -321,7 +321,7 @@

    Group Workspace

    aria-controls="group-plugins-tab" aria-selected="false" > - Group Actions + Actions {% endif %} @@ -338,7 +338,7 @@

    Group Workspace

    aria-controls="group-endpoints-tab" aria-selected="false" > - Group Endpoints + Endpoints {% endif %} diff --git a/application/single_app/templates/public_workspaces.html b/application/single_app/templates/public_workspaces.html index 447410ac..3a3769d2 100644 --- a/application/single_app/templates/public_workspaces.html +++ b/application/single_app/templates/public_workspaces.html @@ -153,10 +153,10 @@ diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index 24a0945f..8bfcae68 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -571,7 +571,7 @@

    Personal Workspace

    aria-controls="documents-tab" aria-selected="true" > - Your Documents + Documents {% if settings.per_user_semantic_kernel and settings.enable_semantic_kernel %} @@ -601,7 +601,7 @@

    Personal Workspace

    aria-controls="agents-tab" aria-selected="false" > - Your Agents + Agents @@ -617,7 +617,7 @@

    Personal Workspace

    aria-controls="plugins-tab" aria-selected="false" > - Your Actions + Actions {% endif %} @@ -635,7 +635,7 @@

    Personal Workspace

    aria-controls="workflows-tab" aria-selected="false" > - Your Workflows + Workflows {% endif %} @@ -651,7 +651,7 @@

    Personal Workspace

    aria-controls="endpoints-tab" aria-selected="false" > - Your Endpoints + Endpoints {% endif %} diff --git a/docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md b/docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md index cc47a43f..d376bdbb 100644 --- a/docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md +++ b/docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md @@ -33,7 +33,7 @@ Workflow execution and chat execution both dispatch through the shared document In chat, choose an action from the `Action` selector beside document selection. -- `Standard Chat` keeps the normal prompt flow. +- `Search Documents` keeps the normal prompt flow while searching the selected documents for relevant context. - `Exhaustive Review` reviews every ordered page or chunk from the selected documents. - `Compare Documents` treats one selected document as the left baseline and compares every other selected document against it. diff --git a/docs/explanation/features/v0.241.076/GLOBAL_AGENT_ACTION_DISABLE_CONTROLS.md b/docs/explanation/features/v0.241.076/GLOBAL_AGENT_ACTION_DISABLE_CONTROLS.md new file mode 100644 index 00000000..788864f2 --- /dev/null +++ b/docs/explanation/features/v0.241.076/GLOBAL_AGENT_ACTION_DISABLE_CONTROLS.md @@ -0,0 +1,48 @@ +# Global Agent Action Disable Controls + +Version: 0.241.076 + +Fixed/Implemented in version: **0.241.076** + +Dependencies: `functions_global_actions.py`, `functions_global_agents.py`, `route_backend_plugins.py`, `route_backend_agents.py`, `static/js/admin/admin_plugins.js`, `static/js/admin/admin_agents.js`, `templates/admin_settings.html` + +## Overview + +This feature adds a reversible enabled-state for global actions and global agents. + +Admins can now disable a global item from Admin Settings without deleting it. Disabled items remain stored for later reuse, but they are excluded from runtime loading and user-facing global selection flows until they are re-enabled. + +## Technical Specifications + +Global action and global agent container helpers now persist an `is_enabled` flag and default new records to `true`. + +Runtime helper reads now filter out disabled records by default while admin-management routes explicitly opt into `include_disabled=True` so the Admin Settings page can still list, edit, re-enable, or delete disabled records. + +Two new admin endpoints handle partial enabled-state updates: + +- `PATCH /api/admin/plugins//enabled` +- `PATCH /api/admin/agents//enabled` + +When an admin disables the currently selected global agent, the backend now attempts to switch `global_selected_agent` to the first remaining enabled global agent. If none remain, the selected-agent setting is cleared so the system does not continue pointing at a disabled record. + +## Usage Instructions + +Open Admin Settings and go to the `Agents` tab. + +- In `Global Agents`, use the new `Enable` or `Disable` button on each row to control whether the global agent is available at runtime. +- In `Global Actions`, use the new `Enable` or `Disable` button on each row to control whether the global action is loaded at runtime. +- Disabled items remain visible to admins with a `Disabled` badge so they can be reviewed and re-enabled later. + +Selected-agent dropdowns only include enabled global agents. + +## Testing And Validation + +Coverage for this feature includes: + +- `functional_tests/test_admin_global_item_enabled_state.py` for helper, route, schema, and admin UI wiring checks +- targeted Python compilation for the modified helper and route modules +- editor diagnostics for the touched Python, JavaScript, HTML, and schema files + +Known limitation: + +- disabling all global agents is allowed; in that case the selected global agent is cleared and runtime falls back to kernel-only behavior until an agent is re-enabled or a new one is created. \ No newline at end of file diff --git a/docs/explanation/fixes/v0.241.075/STANDARD_CHAT_DOCUMENT_ACTION_PAYLOAD_FIX.md b/docs/explanation/fixes/v0.241.075/STANDARD_CHAT_DOCUMENT_ACTION_PAYLOAD_FIX.md new file mode 100644 index 00000000..3225f128 --- /dev/null +++ b/docs/explanation/fixes/v0.241.075/STANDARD_CHAT_DOCUMENT_ACTION_PAYLOAD_FIX.md @@ -0,0 +1,39 @@ +# Standard Chat Document Action Payload Fix + +Version: 0.241.075 + +Fixed/Implemented in version: **0.241.075** + +## Issue Description + +After the chat action selector was added, the default `Search Documents` option still serialized disabled `document_action` and `exhaustive_review` payload blocks. The underlying standard chat route is supposed to keep the legacy prompt flow, but this changed the request shape for normal chat turns and made tabular questions more likely to fall back to schema-only behavior. + +## Root Cause Analysis + +The chat client always built the shared document-action payload, even when the selected action type was `none`. That meant the default document-search path no longer matched the legacy request contract that tabular analysis had already been tuned around. + +## Technical Details + +### Files Modified + +- `application/single_app/static/js/chat/chat-messages.js` +- `application/single_app/config.py` +- `functional_tests/test_standard_chat_document_action_payload_fix.py` +- `functional_tests/test_exhaustive_document_review_feature.py` + +### Code Changes Summary + +- Changed chat payload assembly so `document_action` is only sent when the user explicitly selects an opt-in action. +- Limited the legacy `exhaustive_review` compatibility payload to exhaustive-review runs only. +- Added a focused regression test that verifies `Search Documents` keeps the normal payload shape while opt-in actions still serialize their action-specific fields. + +## Testing And Validation + +- Functional regression: `functional_tests/test_standard_chat_document_action_payload_fix.py` +- Updated wiring check: `functional_tests/test_exhaustive_document_review_feature.py` + +## Impact Analysis + +- `Search Documents` now preserves the legacy request contract again. +- Exhaustive review and comparison continue to use the shared document-action routes. +- Tabular questions in the default document-search path are less likely to degrade into schema-only fallbacks caused by the changed request shape. \ No newline at end of file diff --git a/functional_tests/test_admin_global_item_enabled_state.py b/functional_tests/test_admin_global_item_enabled_state.py new file mode 100644 index 00000000..a36e0c18 --- /dev/null +++ b/functional_tests/test_admin_global_item_enabled_state.py @@ -0,0 +1,129 @@ +# test_admin_global_item_enabled_state.py +#!/usr/bin/env python3 +""" +Functional test for admin-managed global enabled state controls. +Version: 0.241.076 +Implemented in: 0.241.076 + +This test ensures global agents and actions can be disabled without deletion, +that runtime helper reads filter disabled items by default, and that the admin +settings page exposes enable/disable controls for both item types. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG_FILE = REPO_ROOT / "application" / "single_app" / "config.py" +GLOBAL_ACTIONS_FILE = REPO_ROOT / "application" / "single_app" / "functions_global_actions.py" +GLOBAL_AGENTS_FILE = REPO_ROOT / "application" / "single_app" / "functions_global_agents.py" +PLUGIN_ROUTES_FILE = REPO_ROOT / "application" / "single_app" / "route_backend_plugins.py" +AGENT_ROUTES_FILE = REPO_ROOT / "application" / "single_app" / "route_backend_agents.py" +ADMIN_PLUGINS_JS = REPO_ROOT / "application" / "single_app" / "static" / "js" / "admin" / "admin_plugins.js" +ADMIN_AGENTS_JS = REPO_ROOT / "application" / "single_app" / "static" / "js" / "admin" / "admin_agents.js" +PLUGIN_COMMON_JS = REPO_ROOT / "application" / "single_app" / "static" / "js" / "plugin_common.js" +ADMIN_SETTINGS_TEMPLATE = REPO_ROOT / "application" / "single_app" / "templates" / "admin_settings.html" +AGENT_SCHEMA_FILE = REPO_ROOT / "application" / "single_app" / "static" / "json" / "schemas" / "agent.schema.json" +PLUGIN_SCHEMA_FILE = REPO_ROOT / "application" / "single_app" / "static" / "json" / "schemas" / "plugin.schema.json" + + +def test_admin_global_item_enabled_state(): + """Verify global enabled-state wiring across helpers, routes, and admin UI.""" + config_content = CONFIG_FILE.read_text(encoding="utf-8") + global_actions_content = GLOBAL_ACTIONS_FILE.read_text(encoding="utf-8") + global_agents_content = GLOBAL_AGENTS_FILE.read_text(encoding="utf-8") + plugin_routes_content = PLUGIN_ROUTES_FILE.read_text(encoding="utf-8") + agent_routes_content = AGENT_ROUTES_FILE.read_text(encoding="utf-8") + admin_plugins_js = ADMIN_PLUGINS_JS.read_text(encoding="utf-8") + admin_agents_js = ADMIN_AGENTS_JS.read_text(encoding="utf-8") + plugin_common_js = PLUGIN_COMMON_JS.read_text(encoding="utf-8") + admin_settings_template = ADMIN_SETTINGS_TEMPLATE.read_text(encoding="utf-8") + agent_schema = AGENT_SCHEMA_FILE.read_text(encoding="utf-8") + plugin_schema = PLUGIN_SCHEMA_FILE.read_text(encoding="utf-8") + + assert 'VERSION = "0.241.076"' in config_content, "Expected config.py version 0.241.076" + + assert "def get_global_actions(return_type=SecretReturnType.TRIGGER, include_disabled=False):" in global_actions_content, ( + "Expected global actions helper to support admin-only disabled-item reads." + ) + assert 'NOT IS_DEFINED(c.is_enabled) OR c.is_enabled = true' in global_actions_content, ( + "Expected global actions helper to filter disabled items out of runtime reads by default." + ) + assert "def update_global_action_enabled(action_id, is_enabled, user_id=None):" in global_actions_content, ( + "Expected a dedicated helper for toggling global action enabled state." + ) + + assert "def get_global_agents(include_disabled=False):" in global_agents_content, ( + "Expected global agents helper to support admin-only disabled-item reads." + ) + assert 'NOT IS_DEFINED(c.is_enabled) OR c.is_enabled = true' in global_agents_content, ( + "Expected global agents helper to filter disabled items out of runtime reads by default." + ) + assert "def update_global_agent_enabled(agent_id, is_enabled, user_id=None):" in global_agents_content, ( + "Expected a dedicated helper for toggling global agent enabled state." + ) + assert "cleaned_agent['is_enabled'] = True" in global_agents_content, ( + "Expected new global agents to default to enabled." + ) + + assert "@bpap.route('/api/admin/plugins//enabled', methods=['PATCH'])" in plugin_routes_content, ( + "Expected an admin plugin enabled-state endpoint." + ) + assert "plugins = get_global_actions(include_disabled=True)" in plugin_routes_content, ( + "Expected admin plugin routes to load disabled items for management." + ) + + assert "@bpa.route('/api/admin/agents//enabled', methods=['PATCH'])" in agent_routes_content, ( + "Expected an admin agent enabled-state endpoint." + ) + assert "agents = get_global_agents(include_disabled=True)" in agent_routes_content, ( + "Expected admin agent routes to load disabled items for management." + ) + assert "fallback_agent_name" in agent_routes_content, ( + "Expected disabling a selected global agent to compute fallback selection metadata." + ) + + assert "onToggleEnabled: name => togglePluginEnabled(name)" in admin_plugins_js, ( + "Expected admin actions table wiring for enable/disable controls." + ) + assert "async function togglePluginEnabled(name)" in admin_plugins_js, ( + "Expected admin plugins script to toggle global action enabled state." + ) + assert "/api/admin/plugins/${encodeURIComponent(name)}/enabled" in admin_plugins_js, ( + "Expected admin plugins script to call the new enabled-state endpoint." + ) + + assert "const enabledAgents = agentsList.filter(agent => agent.is_enabled !== false);" in admin_agents_js, ( + "Expected selected-agent dropdowns to hide disabled global agents." + ) + assert "async function toggleAgentEnabled(idx)" in admin_agents_js, ( + "Expected admin agents script to toggle global agent enabled state." + ) + assert "/api/admin/agents/${encodeURIComponent(agent.name)}/enabled" in admin_agents_js, ( + "Expected admin agents script to call the new enabled-state endpoint." + ) + + assert "onToggleEnabled" in plugin_common_js, ( + "Expected shared plugin table rendering to support admin toggle controls." + ) + assert "toggle-plugin-btn" in plugin_common_js, ( + "Expected shared plugin table rendering to create toggle buttons." + ) + assert "Enabled" in plugin_common_js and "Disabled" in plugin_common_js, ( + "Expected shared plugin table rendering to expose enabled-state badges." + ) + + assert "Disable a global agent to keep it saved for admins while hiding it from runtime selection until it is re-enabled." in admin_settings_template, ( + "Expected admin settings copy to explain the new global agent disable behavior." + ) + assert "Disable a global action to keep the configuration without exposing it to runtime action loading until it is re-enabled." in admin_settings_template, ( + "Expected admin settings copy to explain the new global action disable behavior." + ) + + assert '"is_enabled"' in agent_schema, "Expected agent schema to persist the enabled-state field." + assert '"is_enabled"' in plugin_schema, "Expected plugin schema to persist the enabled-state field." + + +if __name__ == "__main__": + test_admin_global_item_enabled_state() + print("✅ Admin global item enabled-state controls verified.") \ No newline at end of file diff --git a/functional_tests/test_chart_tool_prompt_handoff.py b/functional_tests/test_chart_tool_prompt_handoff.py new file mode 100644 index 00000000..9d06d62f --- /dev/null +++ b/functional_tests/test_chart_tool_prompt_handoff.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Functional test for chart-tool prompt handoff. +Version: 0.241.077 +Implemented in: 0.241.077 + +This test ensures chart requests sent to a selected agent are nudged toward the +inline chart action instead of ASCII pseudo-charts. +""" + +import ast +import os +import re +import sys + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(ROOT_DIR) +sys.path.append(os.path.join(ROOT_DIR, 'application', 'single_app')) + +ROUTE_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'route_backend_chats.py') +TARGET_FUNCTIONS = { + 'user_requested_chart_visualization', + 'build_chart_tool_usage_system_message', + 'insert_system_message_after_existing_system_messages', + 'maybe_append_chart_tool_system_message', +} + + +def load_prompt_helpers(): + """Load only the chart prompt helpers from the chat route source.""" + with open(ROUTE_FILE, 'r', encoding='utf-8') as file_handle: + route_content = file_handle.read() + + parsed = ast.parse(route_content, filename=ROUTE_FILE) + selected_nodes = [] + for node in parsed.body: + if isinstance(node, ast.FunctionDef) and node.name in TARGET_FUNCTIONS: + selected_nodes.append(node) + + module = ast.Module(body=selected_nodes, type_ignores=[]) + namespace = {'re': re} + exec(compile(module, ROUTE_FILE, 'exec'), namespace) + return namespace, route_content + + +def test_chart_request_detection_distinguishes_visual_requests(): + """Verify chart detection catches visualization asks without matching common non-visual phrases.""" + print('Testing chart request detection...') + + helpers, _ = load_prompt_helpers() + detect_chart_request = helpers['user_requested_chart_visualization'] + + assert detect_chart_request('Which airlines have the shortest gate turnaround times? Include table and chart') is True + assert detect_chart_request('Show a bar chart of monthly revenue and a table') is True + assert detect_chart_request('Plot the latency trend by region') is True + assert detect_chart_request('Show me the chart of accounts for this tenant') is False + assert detect_chart_request('Can you chart out a migration plan?') is False + + print('PASS: chart request detection') + + +def test_chart_handoff_message_is_inserted_once_before_user_messages(): + """Verify chart-tool guidance is inserted once and stays in the system-message prefix.""" + print('Testing chart handoff insertion...') + + helpers, _ = load_prompt_helpers() + maybe_append = helpers['maybe_append_chart_tool_system_message'] + build_message = helpers['build_chart_tool_usage_system_message'] + + history = [ + {'role': 'system', 'content': 'Existing system guidance'}, + {'role': 'user', 'content': 'Which airlines have the shortest gate turnaround times? Include table and chart'}, + ] + + updated_history = maybe_append(history, history[-1]['content'], object()) + expected_message = build_message() + + assert len(updated_history) == 3, updated_history + assert updated_history[0]['role'] == 'system', updated_history + assert updated_history[1]['role'] == 'system', updated_history + assert updated_history[1]['content'] == expected_message, updated_history + assert updated_history[2]['role'] == 'user', updated_history + + maybe_append(updated_history, history[-1]['content'], object()) + assert len(updated_history) == 3, updated_history + + print('PASS: chart handoff insertion') + + +def test_route_uses_chart_handoff_helper_in_agent_paths(): + """Verify both non-streaming and streaming agent paths call the shared helper.""" + print('Testing route helper usage...') + + _, route_content = load_prompt_helpers() + helper_call_count = route_content.count('maybe_append_chart_tool_system_message(') + + assert helper_call_count >= 2, helper_call_count + + print('PASS: route helper usage') + + +if __name__ == '__main__': + tests = [ + test_chart_request_detection_distinguishes_visual_requests, + test_chart_handoff_message_is_inserted_once_before_user_messages, + test_route_uses_chart_handoff_helper_in_agent_paths, + ] + + results = [] + for test in tests: + print(f'Running {test.__name__}...') + try: + test() + results.append(True) + except Exception as exc: + print(f'FAIL: {exc}') + results.append(False) + + sys.exit(0 if all(results) else 1) \ No newline at end of file diff --git a/functional_tests/test_conversation_export_inline_chart_images.py b/functional_tests/test_conversation_export_inline_chart_images.py new file mode 100644 index 00000000..707d82d3 --- /dev/null +++ b/functional_tests/test_conversation_export_inline_chart_images.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Focused regression tests for inline chart graphics in conversation exports.""" + +import io +import os +import sys +import zipfile + +import fitz + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT_DIR, 'application', 'single_app')) + +from route_backend_conversation_export import ( # noqa: E402 + _conversation_to_markdown, + _conversation_to_pdf_bytes, + _message_to_docx_bytes, +) +from semantic_kernel_plugins.chart_plugin import ChartPlugin # noqa: E402 + + +def _build_sample_chart_markdown() -> str: + plugin = ChartPlugin({'chart_capabilities': {'bar': True}}) + result = plugin.create_chart( + chart_type='bar', + chart_data_json='{"rows":[{"airline":"ASA","turnaround":55.86},{"airline":"NKS","turnaround":56.55},{"airline":"DAL","turnaround":56.89}],"xField":"airline","yFields":["turnaround"]}', + title='Average Gate Turnaround Time', + subtitle='Lower is better', + description='Airlines ranked by shortest average gate turnaround time.', + ) + assert result['success'] is True, result + return result['chart_markdown'] + + +def _build_export_entry(chart_markdown: str): + assistant_content = ( + 'Here is the requested chart.\n\n' + f'{chart_markdown}\n\n' + 'ASA has the shortest average gate turnaround time in this sample.' + ) + return { + 'conversation': { + 'id': 'conv-chart-001', + 'title': 'Chart Export Test', + 'last_updated': '2026-04-29T15:00:00Z', + 'chat_type': 'personal', + 'tags': [], + 'classification': [], + 'context': [], + 'strict': False, + 'is_pinned': False, + 'scope_locked': False, + 'locked_contexts': [], + 'message_count': 2, + 'message_counts_by_role': {'user': 1, 'assistant': 1}, + 'citation_counts': {'document': 0, 'web': 0, 'agent_tool': 0, 'legacy': 0, 'total': 0}, + 'thought_count': 0, + }, + 'summary_intro': { + 'enabled': False, + 'generated': False, + 'model_deployment': None, + 'generated_at': None, + 'content': '', + 'error': None, + }, + 'messages': [ + { + 'id': 'u1', + 'role': 'user', + 'speaker_label': 'User', + 'label': 'Turn 1', + 'sequence_index': 1, + 'transcript_index': 1, + 'is_transcript_message': True, + 'timestamp': '2026-04-29T15:00:01Z', + 'content': 'Which airlines have the shortest gate turnaround times? Include table and chart.', + 'content_text': 'Which airlines have the shortest gate turnaround times? Include table and chart.', + 'details': {}, + 'citations': [], + 'citation_counts': {'document': 0, 'web': 0, 'agent_tool': 0, 'legacy': 0, 'total': 0}, + 'thoughts': [], + 'legacy_citations': [], + 'hybrid_citations': [], + 'web_search_citations': [], + 'agent_citations': [], + }, + { + 'id': 'a1', + 'role': 'assistant', + 'speaker_label': 'Assistant', + 'label': 'Turn 2', + 'sequence_index': 2, + 'transcript_index': 2, + 'is_transcript_message': True, + 'timestamp': '2026-04-29T15:00:02Z', + 'content': assistant_content, + 'content_text': assistant_content, + 'details': {}, + 'citations': [], + 'citation_counts': {'document': 0, 'web': 0, 'agent_tool': 0, 'legacy': 0, 'total': 0}, + 'thoughts': [], + 'legacy_citations': [], + 'hybrid_citations': [], + 'web_search_citations': [], + 'agent_citations': [], + }, + ], + } + + +def test_markdown_export_embeds_chart_png_data_uri(): + chart_markdown = _build_sample_chart_markdown() + entry = _build_export_entry(chart_markdown) + + markdown = _conversation_to_markdown(entry) + + assert 'data:image/png;base64,' in markdown, markdown + assert '```simplechart' not in markdown, markdown + assert 'Average Gate Turnaround Time' in markdown, markdown + + +def test_pdf_export_contains_rendered_chart_image(): + chart_markdown = _build_sample_chart_markdown() + entry = _build_export_entry(chart_markdown) + + pdf_bytes = _conversation_to_pdf_bytes(entry) + document = fitz.open(stream=pdf_bytes, filetype='pdf') + try: + image_count = sum(len(page.get_images(full=True)) for page in document) + finally: + document.close() + + assert image_count >= 1, image_count + + +def test_word_message_export_embeds_chart_png_media(): + chart_markdown = _build_sample_chart_markdown() + entry = _build_export_entry(chart_markdown) + assistant_message = entry['messages'][1] + + docx_bytes = _message_to_docx_bytes(assistant_message) + + with zipfile.ZipFile(io.BytesIO(docx_bytes), 'r') as archive: + names = archive.namelist() + media_names = [name for name in names if name.startswith('word/media/')] + document_xml = archive.read('word/document.xml').decode('utf-8') + + assert media_names, names + assert 'simplechart' not in document_xml, document_xml \ No newline at end of file diff --git a/functional_tests/test_exhaustive_document_review_feature.py b/functional_tests/test_exhaustive_document_review_feature.py index f9517c25..113e85c3 100644 --- a/functional_tests/test_exhaustive_document_review_feature.py +++ b/functional_tests/test_exhaustive_document_review_feature.py @@ -1,7 +1,7 @@ # test_exhaustive_document_review_feature.py """ Functional test for exhaustive document review. -Version: 0.241.072 +Version: 0.241.075 Implemented in: 0.241.069 This test ensures workflows and chat share the deterministic exhaustive @@ -31,8 +31,8 @@ def test_exhaustive_document_review_feature_wiring(): feature_index_content = read_text("docs/explanation/features/index.md") feature_doc_content = read_text("docs/explanation/features/v0.241.069/EXHAUSTIVE_DOCUMENT_REVIEW.md") - assert 'VERSION = "0.241.072"' in config_content, ( - "Expected config.py version 0.241.072 for exhaustive document review." + assert 'VERSION = "0.241.075"' in config_content, ( + "Expected config.py version 0.241.075 for exhaustive document review wiring checks." ) assert 'def normalize_exhaustive_review_targets(' in review_service_content, ( "Expected functions_exhaustive_document_review.py to normalize structured review targets." @@ -91,8 +91,14 @@ def test_exhaustive_document_review_feature_wiring(): assert 'document-comparison-left-select' in chat_template_content, ( "Expected chats.html to expose a left-side selector for comparison actions." ) - assert 'document_action: documentAction' in chat_messages_content, ( - "Expected chat message payloads to include the shared document action structure." + assert 'if (documentActionType !== DOCUMENT_ACTION_NONE) {' in chat_messages_content, ( + "Expected standard chat payloads to add document actions only when an opt-in action is selected." + ) + assert 'requestPayload.document_action = documentAction;' in chat_messages_content, ( + "Expected chat message payloads to include the shared document action structure only for opt-in actions." + ) + assert 'if (documentActionType === DOCUMENT_ACTION_EXHAUSTIVE_REVIEW) {' in chat_messages_content, ( + "Expected legacy exhaustive review payloads to be serialized only for exhaustive review runs." ) assert "endpoint: useDocumentAction ? '/api/chat/document-action/stream' : '/api/chat/stream'" in chat_messages_content, ( "Expected chat message sending to route document actions through the shared streaming endpoint." diff --git a/functional_tests/test_standard_chat_document_action_payload_fix.py b/functional_tests/test_standard_chat_document_action_payload_fix.py new file mode 100644 index 00000000..2634b4cd --- /dev/null +++ b/functional_tests/test_standard_chat_document_action_payload_fix.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# test_standard_chat_document_action_payload_fix.py +""" +Functional test for standard chat document action payload fix. +Version: 0.241.078 +Implemented in: 0.241.075 + +This test ensures standard chat omits disabled document-action payload fields +so the default chat path keeps the legacy tabular-analysis request shape. +""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def read_text(relative_path): + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def test_standard_chat_omits_disabled_document_action_payloads(): + """Verify standard chat keeps the legacy payload shape unless an action is selected.""" + print("🔍 Testing standard chat payload shape...") + + config_content = read_text("application/single_app/config.py") + chat_messages_content = read_text("application/single_app/static/js/chat/chat-messages.js") + feature_doc_content = read_text("docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md") + + assert 'VERSION = "0.241.078"' in config_content, ( + "Expected config.py version 0.241.078 for the search-documents label update." + ) + assert '`Search Documents` keeps the normal prompt flow while searching the selected documents for relevant context.' in feature_doc_content, ( + "Expected the document actions feature doc to describe the renamed default search behavior." + ) + assert 'const requestPayload = {' in chat_messages_content, ( + "Expected chat payload assembly to build a mutable request payload before opt-in action fields are added." + ) + assert 'if (documentActionType !== DOCUMENT_ACTION_NONE) {' in chat_messages_content, ( + "Expected standard chat to omit disabled document_action payloads." + ) + assert 'requestPayload.document_action = documentAction;' in chat_messages_content, ( + "Expected document_action payloads to be attached only when an action is selected." + ) + assert 'if (documentActionType === DOCUMENT_ACTION_EXHAUSTIVE_REVIEW) {' in chat_messages_content, ( + "Expected legacy exhaustive review compatibility payloads to be limited to exhaustive review runs." + ) + assert 'requestPayload.exhaustive_review = {' in chat_messages_content, ( + "Expected exhaustive review compatibility payloads to remain available for exhaustive review runs." + ) + assert 'document_action: documentAction,' not in chat_messages_content, ( + "Standard chat should no longer serialize document_action unconditionally." + ) + assert 'return requestPayload;' in chat_messages_content, ( + "Expected buildChatRequestPayload to return the trimmed request payload." + ) + + print("✅ Standard chat payload shape verified") + + +def run_tests(): + tests = [test_standard_chat_omits_disabled_document_action_payloads] + results = [] + + for test in tests: + print(f"\n🧪 Running {test.__name__}...") + try: + test() + print("✅ Test passed") + results.append(True) + except Exception as exc: + print(f"❌ Test failed: {exc}") + import traceback + traceback.print_exc() + results.append(False) + + success = all(results) + print(f"\n📊 Results: {sum(results)}/{len(results)} tests passed") + return success + + +if __name__ == "__main__": + raise SystemExit(0 if run_tests() else 1) \ No newline at end of file diff --git a/functional_tests/test_user_settings_allowlist_keys.py b/functional_tests/test_user_settings_allowlist_keys.py new file mode 100644 index 00000000..b4c8d9ac --- /dev/null +++ b/functional_tests/test_user_settings_allowlist_keys.py @@ -0,0 +1,57 @@ +# test_user_settings_allowlist_keys.py +""" +Functional test for user settings allowlist synchronization. +Version: 0.241.077 +Implemented in: 0.241.077 + +This test ensures that the backend user settings route accepts the +user-setting keys currently managed by microphone, retention policy, +personal model endpoint, and tag workflows. +""" + +import os +import sys + + +def test_user_settings_allowlist_contains_known_keys(): + """Verify that known user settings keys are accepted by the backend route.""" + print("🔍 Checking user settings allowlist keys...") + + try: + route_file = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'application', 'single_app', 'route_backend_users.py' + ) + + with open(route_file, 'r', encoding='utf-8') as file_handle: + content = file_handle.read() + + required_keys = [ + 'microphonePermissionPreference', + 'microphonePermissionState', + 'retention_policy', + 'retention_policy_enabled', + 'retention_policy_days', + 'personal_model_endpoints', + 'tag_definitions', + ] + + missing_keys = [ + key for key in required_keys + if f"'{key}'" not in content and f'"{key}"' not in content + ] + + if missing_keys: + raise Exception(f"Missing allowed_keys entries: {', '.join(missing_keys)}") + + print("✅ User settings allowlist contains the expected keys") + return True + + except Exception as exc: + print(f"❌ Test failed: {exc}") + return False + + +if __name__ == "__main__": + success = test_user_settings_allowlist_contains_known_keys() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/ui_tests/test_chat_sidebar_single_startup_load.py b/ui_tests/test_chat_sidebar_single_startup_load.py new file mode 100644 index 00000000..90e247b0 --- /dev/null +++ b/ui_tests/test_chat_sidebar_single_startup_load.py @@ -0,0 +1,119 @@ +# test_chat_sidebar_single_startup_load.py +""" +UI test for single chat sidebar startup load. +Version: 0.241.076 +Implemented in: 0.241.076 + +This test ensures the chat page bootstrap loads conversation data once for the +visible sidebar and does not trigger duplicate startup requests that cause the +conversation list to flash back to a loading state. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +@pytest.mark.ui +def test_chat_sidebar_uses_single_startup_conversation_load(playwright): + """Validate that chat startup loads the conversation sidebar only once.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + legacy_request_count = 0 + collaboration_request_count = 0 + conversations_payload = { + "conversations": [ + { + "id": "conversation-001", + "title": "Architecture Notes", + "last_updated": "2026-04-29T10:00:00Z", + "classification": [], + "context": [], + "chat_type": "personal_single_user", + "is_pinned": False, + "is_hidden": False, + "has_unread_assistant_response": False, + }, + { + "id": "conversation-002", + "title": "Release Planning", + "last_updated": "2026-04-29T09:45:00Z", + "classification": [], + "context": [], + "chat_type": "personal_single_user", + "is_pinned": False, + "is_hidden": False, + "has_unread_assistant_response": False, + }, + ] + } + + def handle_user_settings(route): + if route.request.method == "GET": + _fulfill_json(route, {"selected_agent": None, "settings": {"enable_agents": False}}) + return + + _fulfill_json(route, {"success": True}) + + def handle_legacy_conversations(route): + nonlocal legacy_request_count + legacy_request_count += 1 + _fulfill_json(route, conversations_payload) + + def handle_collaboration_conversations(route): + nonlocal collaboration_request_count + collaboration_request_count += 1 + _fulfill_json(route, []) + + page.route("**/api/user/settings", handle_user_settings) + page.route("**/api/get_conversations", handle_legacy_conversations) + page.route("**/api/collaboration/conversations?*", handle_collaboration_conversations) + + try: + response = page.goto(f"{BASE_URL}/chats", wait_until="networkidle") + assert response is not None and response.ok + + sidebar_list = page.locator("#sidebar-conversations-list") + page.wait_for_function( + """ + () => { + const sidebarList = document.getElementById('sidebar-conversations-list'); + const text = sidebarList?.textContent || ''; + const itemCount = document.querySelectorAll('#sidebar-conversations-list .sidebar-conversation-item').length; + return itemCount === 2 && !/Loading conversations/i.test(text); + } + """ + ) + + expect(sidebar_list).to_contain_text("Architecture Notes") + expect(sidebar_list).to_contain_text("Release Planning") + assert legacy_request_count == 1 + assert collaboration_request_count == 1 + finally: + context.close() + browser.close() \ No newline at end of file From 0b5332851233a7cc44bf30f683b9c1fca366b398 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 29 Apr 2026 14:13:40 -0400 Subject: [PATCH 23/28] fixed odbc container issue --- application/single_app/Dockerfile | 8 +- application/single_app/config.py | 2 +- deployers/azurecli/README.md | 38 ++ deployers/azurecli/upgrade-simplechat.ps1 | 389 ++++++++++++++++++ docs/explanation/features/index.md | 1 + .../v0.241.079/AZURECLI_UPGRADE_SCRIPT.md | 74 ++++ ...CONTAINER_ODBC_DRIVER_REGISTRY_PATH_FIX.md | 41 ++ docs/how-to/upgrade_paths.md | 2 + .../deploy/azurecli_powershell_deploy.md | 20 +- .../test_azurecli_upgrade_script.py | 97 +++++ .../test_sql_container_odbc_runtime.py | 19 +- ...andard_chat_document_action_payload_fix.py | 6 +- 12 files changed, 685 insertions(+), 12 deletions(-) create mode 100644 deployers/azurecli/upgrade-simplechat.ps1 create mode 100644 docs/explanation/features/v0.241.079/AZURECLI_UPGRADE_SCRIPT.md create mode 100644 docs/explanation/fixes/v0.241.080/SQL_CONTAINER_ODBC_DRIVER_REGISTRY_PATH_FIX.md create mode 100644 functional_tests/test_azurecli_upgrade_script.py diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index b07c5619..c3f9dd5e 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -45,9 +45,13 @@ RUN set -eux; \ RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app/flask_session RUN mkdir /sc-temp-files && chown -R ${UID}:${GID} /sc-temp-files +# Preserve the package-selected unixODBC driver registry path for the distroless runtime. RUN set -eux; \ - mkdir -p /odbc-runtime/usr/lib64 /odbc-runtime/etc /odbc-runtime/opt; \ - cp -a /etc/odbcinst.ini /odbc-runtime/etc/; \ + driver_config_dir="$(odbcinst -j | awk -F': ' '/^DRIVERS/ {print $2}')"; \ + test -f "${driver_config_dir}/odbcinst.ini"; \ + mkdir -p /odbc-runtime/usr/lib64 /odbc-runtime/opt "/odbc-runtime${driver_config_dir}" /odbc-runtime/etc; \ + cp -a "${driver_config_dir}/odbcinst.ini" "/odbc-runtime${driver_config_dir}/"; \ + if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_dir}/odbcinst.ini" /odbc-runtime/etc/; fi; \ cp -a /opt/microsoft /odbc-runtime/opt/; \ cp -a /usr/lib64/libodbc.so* /odbc-runtime/usr/lib64/; \ cp -a /usr/lib64/libodbcinst.so* /odbc-runtime/usr/lib64/; \ diff --git a/application/single_app/config.py b/application/single_app/config.py index 637d3134..6d6f3c9e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.078" +VERSION = "0.241.080" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/deployers/azurecli/README.md b/deployers/azurecli/README.md index c3905cca..0bda9d48 100644 --- a/deployers/azurecli/README.md +++ b/deployers/azurecli/README.md @@ -47,6 +47,7 @@ If you provide only external endpoint information without Azure resource metadat ## Files in this folder - [deploy-simplechat.ps1](deploy-simplechat.ps1) - Main deployment script +- [upgrade-simplechat.ps1](upgrade-simplechat.ps1) - Code-only container upgrade script for existing Azure CLI deployments - [destroy-simplechat.ps1](destroy-simplechat.ps1) - Cleanup script - [appRegistrationRoles.json](appRegistrationRoles.json) - App role definition source - [ai_search-index-group.json](ai_search-index-group.json) - Group search index definition @@ -114,6 +115,43 @@ cd deployers/azurecli pwsh ./deploy-simplechat.ps1 ``` +## Code-only upgrade flow + +For an existing Azure CLI deployment where infrastructure is unchanged, use `upgrade-simplechat.ps1` instead of rerunning the full deployer. + +This script: + +- builds the requested image tag in ACR with `az acr build` +- updates the existing App Service container image with `az webapp config container set` +- verifies the App Service now points to the requested image +- restarts the web app so the new image is pulled + +Example with explicit resource names: + +```powershell +cd deployers/azurecli +./upgrade-simplechat.ps1 ` + -AcrName registrysimplechatprod ` + -ImageName simplechat:2026-04-29_01 ` + -ResourceGroupName sc-contoso-prod-rg ` + -WebAppName contoso-prod-app +``` + +Example using the same base-name and environment naming convention as `deploy-simplechat.ps1`: + +```powershell +cd deployers/azurecli +./upgrade-simplechat.ps1 ` + -AcrName registrysimplechatprod ` + -ImageName simplechat:2026-04-29_01 ` + -BaseName contoso ` + -Environment prod +``` + +This flow is the Azure CLI deployer's PowerShell-first equivalent to a normal container-only `azd deploy`, without invoking the AZD post-configuration Python path. + +If the image already exists in ACR and you only want App Service to move to that tag, add `-SkipAcrBuild`. + ## Configuration areas to review The script contains editable configuration variables for: diff --git a/deployers/azurecli/upgrade-simplechat.ps1 b/deployers/azurecli/upgrade-simplechat.ps1 new file mode 100644 index 00000000..a60c835a --- /dev/null +++ b/deployers/azurecli/upgrade-simplechat.ps1 @@ -0,0 +1,389 @@ +<# +.SYNOPSIS + Performs a code-only SimpleChat container upgrade for the Azure CLI deployer. +.DESCRIPTION + This PowerShell script builds a new container image in Azure Container Registry + using ACR Tasks and updates an existing Azure App Service to pull that image. + + This is the Azure CLI deployer equivalent of an `azd deploy` style code-only + rollout. It does not provision or change infrastructure resources beyond the + target web app's container configuration. + + The script supports either: + - explicit targeting with -ResourceGroupName and -WebAppName + - derived targeting with -BaseName and -Environment to match deploy-simplechat.ps1 + + The deployment model remains a container-based Azure App Service. Gunicorn + startup continues to come from the container entrypoint. + +.NOTES + Author: Microsoft Federal + Date: 2026-04-29 + Version: 1.0 + + Prerequisites: + - Azure CLI installed and authenticated. + - Access to the target subscription, ACR, and App Service. + - The target ACR already exists. + - The target web app already exists and is configured for the SimpleChat container path. + + Azure Commercial login example: + az cloud set --name AzureCloud + az login --scope https://management.azure.com//.default + az account set -s "" + + Azure Government login example: + az cloud set --name AzureUSGovernment + az login --scope https://management.core.usgovcloudapi.net//.default + az account set -s "" +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$AcrName, + + [Parameter(Mandatory = $true)] + [string]$ImageName, + + [string]$BaseName, + + [string]$Environment, + + [string]$ResourceGroupName, + + [string]$WebAppName, + + [string]$SubscriptionId, + + [ValidateSet("AzureCloud", "AzureUSGovernment", "Custom")] + [string]$AzurePlatform = "AzureCloud", + + [string]$AzureCliCustomCloudName = "", + + [string]$DockerfilePath = "application/single_app/Dockerfile", + + [string]$BuildContextPath = "..\..", + + [switch]$SkipAcrBuild, + + [bool]$PublishLatestTag = $true, + + [bool]$RestartWebApp = $true, + + [string]$Slot +) + +$PSModuleAutoloadingPreference = "All" +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-AzureCliCommand { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [switch]$AllowEmptyOutput + ) + + $output = & az @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + $joined = $Arguments -join ' ' + throw "Azure CLI command failed: az $joined`n$output" + } + + if ($output -is [array]) { + $output = $output -join [Environment]::NewLine + } + + if (-not $AllowEmptyOutput -and $null -eq $output) { + return "" + } + + return $output +} + +function Ensure-AzureCliAuthenticated { + if (-not (Get-Command "az" -ErrorAction SilentlyContinue)) { + throw "Azure CLI is not installed. Please install it before running this script." + } + + $expectedCloudName = switch ($AzurePlatform) { + 'AzureCloud' { 'AzureCloud' } + 'AzureUSGovernment' { 'AzureUSGovernment' } + 'Custom' { $AzureCliCustomCloudName } + default { $null } + } + + if ([string]::IsNullOrWhiteSpace($expectedCloudName)) { + throw "Custom cloud usage requires -AzureCliCustomCloudName." + } + + $currentCloudName = Invoke-AzureCliCommand -Arguments @('cloud', 'show', '--query', 'name', '--output', 'tsv') + if ([string]::IsNullOrWhiteSpace($currentCloudName) -or $currentCloudName.Trim() -ne $expectedCloudName) { + Write-Host "Switching Azure CLI cloud to '$expectedCloudName'..." -ForegroundColor Yellow + Invoke-AzureCliCommand -Arguments @('cloud', 'set', '--name', $expectedCloudName) -AllowEmptyOutput | Out-Null + } + + & az account show --output none 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Azure CLI is not authenticated. Run 'az login' first and retry." + } + + if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { + Write-Host "Setting active subscription to '$SubscriptionId'..." -ForegroundColor Yellow + Invoke-AzureCliCommand -Arguments @('account', 'set', '--subscription', $SubscriptionId) -AllowEmptyOutput | Out-Null + } +} + +function Resolve-ContainerImageInfo { + param( + [Parameter(Mandatory = $true)] + [string]$RequestedImageName + ) + + if ([string]::IsNullOrWhiteSpace($RequestedImageName)) { + throw "ImageName must not be empty." + } + + $lastSlashIndex = $RequestedImageName.LastIndexOf('/') + $lastColonIndex = $RequestedImageName.LastIndexOf(':') + + if ($lastColonIndex -gt $lastSlashIndex) { + $repository = $RequestedImageName.Substring(0, $lastColonIndex) + $tag = $RequestedImageName.Substring($lastColonIndex + 1) + } else { + $repository = $RequestedImageName + $tag = 'latest' + } + + if ([string]::IsNullOrWhiteSpace($repository) -or [string]::IsNullOrWhiteSpace($tag)) { + throw "ImageName must use 'repository' or 'repository:tag'. Current value: $RequestedImageName" + } + + return [PSCustomObject]@{ + Repository = $repository + Tag = $tag + FullName = "$repository`:$tag" + } +} + +function Resolve-TargetNames { + if (-not [string]::IsNullOrWhiteSpace($ResourceGroupName) -and -not [string]::IsNullOrWhiteSpace($WebAppName)) { + return [PSCustomObject]@{ + ResourceGroupName = $ResourceGroupName + WebAppName = $WebAppName + } + } + + if (-not [string]::IsNullOrWhiteSpace($ResourceGroupName) -or -not [string]::IsNullOrWhiteSpace($WebAppName)) { + throw "Specify either both -ResourceGroupName and -WebAppName, or both -BaseName and -Environment." + } + + if ([string]::IsNullOrWhiteSpace($BaseName) -or [string]::IsNullOrWhiteSpace($Environment)) { + throw "Specify either both -ResourceGroupName and -WebAppName, or both -BaseName and -Environment." + } + + return [PSCustomObject]@{ + ResourceGroupName = "sc-$($BaseName)-$($Environment)-rg".ToLower() + WebAppName = "$($BaseName)-$($Environment)-app".ToLower() + } +} + +function Get-AcrConnectionInfo { + $registryInfoRaw = Invoke-AzureCliCommand -Arguments @('acr', 'show', '--name', $AcrName, '--query', '{loginServer:loginServer,adminUserEnabled:adminUserEnabled}', '--output', 'json') + $registryInfo = $registryInfoRaw | ConvertFrom-Json + + if ([string]::IsNullOrWhiteSpace($registryInfo.loginServer)) { + throw "Unable to resolve the login server for ACR '$AcrName'." + } + + if (-not $registryInfo.adminUserEnabled) { + Write-Host "Enabling ACR admin user on '$AcrName' to match the Azure CLI deployer flow..." -ForegroundColor Yellow + Invoke-AzureCliCommand -Arguments @('acr', 'update', '--name', $AcrName, '--admin-enabled', 'true') -AllowEmptyOutput | Out-Null + } + + return [PSCustomObject]@{ + LoginServer = $registryInfo.loginServer + RegistryUrl = "https://$($registryInfo.loginServer)" + } +} + +function Invoke-AcrContainerBuild { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$ContainerImageInfo + ) + + $resolvedContextPath = Resolve-Path -Path (Join-Path $PSScriptRoot $BuildContextPath) -ErrorAction Stop + $dockerfileFullPath = Join-Path $resolvedContextPath $DockerfilePath + + if (-not (Test-Path -Path $dockerfileFullPath)) { + throw "Dockerfile not found at '$dockerfileFullPath'." + } + + $arguments = @( + 'acr', 'build', + '--registry', $AcrName, + '--file', $DockerfilePath, + '--image', $ContainerImageInfo.FullName + ) + + if ($PublishLatestTag -and $ContainerImageInfo.Tag -ne 'latest') { + $arguments += @('--image', "$($ContainerImageInfo.Repository):latest") + } + + $arguments += $resolvedContextPath.Path + + Write-Host "`n=====> Building image in Azure Container Registry..." -ForegroundColor Cyan + Write-Host "Registry: $AcrName" + Write-Host "Image: $($ContainerImageInfo.FullName)" + Write-Host "Dockerfile: $DockerfilePath" + Write-Host "Build context: $($resolvedContextPath.Path)" + + Invoke-AzureCliCommand -Arguments $arguments -AllowEmptyOutput | Out-Null +} + +function Ensure-WebAppExists { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$ResolvedWebAppName + ) + + $queryArgs = @('webapp', 'show', '--resource-group', $ResolvedResourceGroupName, '--name', $ResolvedWebAppName) + if (-not [string]::IsNullOrWhiteSpace($Slot)) { + $queryArgs += @('--slot', $Slot) + } + $queryArgs += @('--query', '{name:name,defaultHostName:defaultHostName,state:state}', '--output', 'json') + + $webAppRaw = Invoke-AzureCliCommand -Arguments $queryArgs + return $webAppRaw | ConvertFrom-Json +} + +function Update-WebAppContainerImage { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$ResolvedWebAppName, + + [Parameter(Mandatory = $true)] + [pscustomobject]$AcrConnectionInfo, + + [Parameter(Mandatory = $true)] + [pscustomobject]$ContainerImageInfo + ) + + $acrUsername = Invoke-AzureCliCommand -Arguments @('acr', 'credential', 'show', '--name', $AcrName, '--query', 'username', '--output', 'tsv') + $acrPassword = Invoke-AzureCliCommand -Arguments @('acr', 'credential', 'show', '--name', $AcrName, '--query', 'passwords[0].value', '--output', 'tsv') + $expectedImageName = "$($AcrConnectionInfo.LoginServer)/$($ContainerImageInfo.FullName)" + + $arguments = @( + 'webapp', 'config', 'container', 'set', + '--name', $ResolvedWebAppName, + '--resource-group', $ResolvedResourceGroupName, + '--container-image-name', $expectedImageName, + '--container-registry-url', $AcrConnectionInfo.RegistryUrl, + '--container-registry-user', $acrUsername.Trim(), + '--container-registry-password', $acrPassword.Trim() + ) + + if (-not [string]::IsNullOrWhiteSpace($Slot)) { + $arguments += @('--slot', $Slot) + } + + Write-Host "`n=====> Updating App Service container settings..." -ForegroundColor Cyan + Invoke-AzureCliCommand -Arguments $arguments -AllowEmptyOutput | Out-Null + + return $expectedImageName +} + +function Confirm-WebAppContainerImage { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$ResolvedWebAppName, + + [Parameter(Mandatory = $true)] + [string]$ExpectedImageName + ) + + $arguments = @( + 'webapp', 'config', 'container', 'show', + '--name', $ResolvedWebAppName, + '--resource-group', $ResolvedResourceGroupName, + '--query', '{image:DOCKER_CUSTOM_IMAGE_NAME,registry:DOCKER_REGISTRY_SERVER_URL}', + '--output', 'json' + ) + + if (-not [string]::IsNullOrWhiteSpace($Slot)) { + $arguments += @('--slot', $Slot) + } + + $containerStateRaw = Invoke-AzureCliCommand -Arguments $arguments + $containerState = $containerStateRaw | ConvertFrom-Json + + if ($containerState.image -ne $ExpectedImageName) { + throw "Web app container configuration did not update to '$ExpectedImageName'. Current value: '$($containerState.image)'" + } +} + +function Restart-WebAppIfRequested { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$ResolvedWebAppName + ) + + if (-not $RestartWebApp) { + return + } + + $arguments = @( + 'webapp', 'restart', + '--name', $ResolvedWebAppName, + '--resource-group', $ResolvedResourceGroupName + ) + + if (-not [string]::IsNullOrWhiteSpace($Slot)) { + $arguments += @('--slot', $Slot) + } + + Write-Host "`n=====> Restarting App Service..." -ForegroundColor Cyan + Invoke-AzureCliCommand -Arguments $arguments -AllowEmptyOutput | Out-Null +} + +Write-Host "`nSimpleChat Upgrade Executing" -ForegroundColor Green +Write-Host "This script performs a code-only container rollout for the Azure CLI deployer." -ForegroundColor Green + +Ensure-AzureCliAuthenticated + +$targetNames = Resolve-TargetNames +$containerImageInfo = Resolve-ContainerImageInfo -RequestedImageName $ImageName +$acrConnectionInfo = Get-AcrConnectionInfo +$webApp = Ensure-WebAppExists -ResolvedResourceGroupName $targetNames.ResourceGroupName -ResolvedWebAppName $targetNames.WebAppName + +if (-not $SkipAcrBuild) { + Invoke-AcrContainerBuild -ContainerImageInfo $containerImageInfo +} else { + Write-Host "Skipping ACR build because -SkipAcrBuild was specified." -ForegroundColor Yellow +} + +$expectedImageName = Update-WebAppContainerImage -ResolvedResourceGroupName $targetNames.ResourceGroupName -ResolvedWebAppName $targetNames.WebAppName -AcrConnectionInfo $acrConnectionInfo -ContainerImageInfo $containerImageInfo +Confirm-WebAppContainerImage -ResolvedResourceGroupName $targetNames.ResourceGroupName -ResolvedWebAppName $targetNames.WebAppName -ExpectedImageName $expectedImageName +Restart-WebAppIfRequested -ResolvedResourceGroupName $targetNames.ResourceGroupName -ResolvedWebAppName $targetNames.WebAppName + +$slotSuffix = if ([string]::IsNullOrWhiteSpace($Slot)) { '' } else { " (slot: $Slot)" } +Write-Host "`nUpgrade complete.$slotSuffix" -ForegroundColor Green +Write-Host "Resource Group: $($targetNames.ResourceGroupName)" +Write-Host "Web App: $($targetNames.WebAppName)" +Write-Host "Image: $expectedImageName" +Write-Host "Default Hostname: $($webApp.defaultHostName)" \ No newline at end of file diff --git a/docs/explanation/features/index.md b/docs/explanation/features/index.md index d02da93b..35876518 100644 --- a/docs/explanation/features/index.md +++ b/docs/explanation/features/index.md @@ -17,6 +17,7 @@ category: Version History ## Versioned Features +- [Azure CLI Upgrade Script](v0.241.079/AZURECLI_UPGRADE_SCRIPT.md) - [Core Document Search And Summarization](v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md) - [Exhaustive Document Review](v0.241.069/EXHAUSTIVE_DOCUMENT_REVIEW.md) - [Exhaustive Review Progress And Limits](v0.241.071/EXHAUSTIVE_REVIEW_PROGRESS_AND_LIMITS.md) diff --git a/docs/explanation/features/v0.241.079/AZURECLI_UPGRADE_SCRIPT.md b/docs/explanation/features/v0.241.079/AZURECLI_UPGRADE_SCRIPT.md new file mode 100644 index 00000000..44f81abb --- /dev/null +++ b/docs/explanation/features/v0.241.079/AZURECLI_UPGRADE_SCRIPT.md @@ -0,0 +1,74 @@ +# Azure CLI Upgrade Script + +Version: 0.241.079 + +Fixed/Implemented in version: **0.241.079** + +Dependencies: `deployers/azurecli/upgrade-simplechat.ps1`, `deployers/azurecli/README.md`, `docs/reference/deploy/azurecli_powershell_deploy.md`, `docs/how-to/upgrade_paths.md`, `functional_tests/test_azurecli_upgrade_script.py` + +## Overview + +This feature adds a standalone Azure CLI PowerShell upgrade script for SimpleChat container deployments. + +The new script provides a code-only rollout path for the Azure CLI deployer. It builds the updated SimpleChat image in Azure Container Registry with ACR Tasks, updates the existing Azure App Service container configuration to that image, and restarts the site so the new container is pulled. + +## Technical Specifications + +The new `upgrade-simplechat.ps1` script is intentionally narrower than `deploy-simplechat.ps1`. + +It does not provision or reconfigure the full Azure dependency stack. Instead, it focuses on the day-two operational path that is closest to `azd deploy` for a container-based App Service deployment. + +The script supports two targeting modes: + +- explicit target selection with `-ResourceGroupName` and `-WebAppName` +- derived target names with `-BaseName` and `-Environment`, matching the default `deploy-simplechat.ps1` naming convention of `sc---rg` and `--app` + +The rollout sequence is: + +1. verify Azure CLI authentication and optional subscription selection +2. verify the target Azure Container Registry and enable the admin user if needed to match the existing Azure CLI deployer model +3. build the requested image tag in ACR with `az acr build` +4. update App Service container settings with `az webapp config container set` +5. verify that the web app container configuration now points to the requested image +6. restart the web app with `az webapp restart` + +## Usage Instructions + +Run the script from `deployers/azurecli`. + +Example using explicit resource names: + +```powershell +./upgrade-simplechat.ps1 ` + -AcrName registrysimplechatprod ` + -ImageName simplechat:2026-04-29_01 ` + -ResourceGroupName sc-contoso-prod-rg ` + -WebAppName contoso-prod-app +``` + +Example using deployer-style name derivation: + +```powershell +./upgrade-simplechat.ps1 ` + -AcrName registrysimplechatprod ` + -ImageName simplechat:2026-04-29_01 ` + -BaseName contoso ` + -Environment prod +``` + +Optional switches: + +- `-SkipAcrBuild` when the image tag is already present in ACR and only the App Service needs to move to it +- `-Slot ` to target a deployment slot instead of production + +## Testing And Validation + +Coverage for this feature includes: + +- `functional_tests/test_azurecli_upgrade_script.py` for script and documentation wiring +- direct script syntax validation with PowerShell parsing +- focused functional execution of the new regression test + +Known limitation: + +- the upgrade script follows the current Azure CLI deployer’s ACR admin-credential model for App Service container pulls; it does not yet switch existing web apps to managed-identity-based ACR pull. diff --git a/docs/explanation/fixes/v0.241.080/SQL_CONTAINER_ODBC_DRIVER_REGISTRY_PATH_FIX.md b/docs/explanation/fixes/v0.241.080/SQL_CONTAINER_ODBC_DRIVER_REGISTRY_PATH_FIX.md new file mode 100644 index 00000000..5f59d12c --- /dev/null +++ b/docs/explanation/fixes/v0.241.080/SQL_CONTAINER_ODBC_DRIVER_REGISTRY_PATH_FIX.md @@ -0,0 +1,41 @@ +# SQL Container ODBC Driver Registry Path Fix + +Version: 0.241.080 + +Fixed/Implemented in version: **0.241.080** + +## Issue Description + +`azd deploy` container builds started failing in Azure Container Registry during the ODBC runtime packaging step with `cp: cannot stat '/etc/odbcinst.ini': No such file or directory`. + +## Root Cause Analysis + +The SQL container runtime packaging logic assumed that `msodbcsql18` would always register the unixODBC driver in `/etc/odbcinst.ini`. + +On Azure Linux 3, the package install reports its driver target directory through `odbcinst -j`, and the build log showed that the active location was `/etc/unixODBC`. The Dockerfile hard-coded the older root-level path, so the build failed before the distroless runtime image could be assembled. + +## Technical Details + +### Files Modified + +- `application/single_app/Dockerfile` +- `application/single_app/config.py` +- `functional_tests/test_sql_container_odbc_runtime.py` + +### Code Changes Summary + +- Updated the Dockerfile to detect the unixODBC driver registry directory with `odbcinst -j` instead of assuming `/etc/odbcinst.ini`. +- Copied the detected `odbcinst.ini` path into the `/odbc-runtime` payload so the final distroless image preserves the package-selected layout. +- Kept a compatibility copy under `/etc/odbcinst.ini` when the package uses a subdirectory path. +- Extended the existing SQL container regression test to verify the dynamic path handling and to fail if the Dockerfile regresses to the old hard-coded copy path. + +## Testing And Validation + +- Focused Dockerfile diagnostics in the editor after the packaging-step update. +- Functional regression: `functional_tests/test_sql_container_odbc_runtime.py` + +## Impact Analysis + +- ACR builds no longer fail when Azure Linux 3 registers the SQL Server ODBC driver under `/etc/unixODBC` instead of `/etc`. +- The distroless runtime keeps the unixODBC driver registry in the same location reported by the installed package. +- This change is a follow-up to the earlier SQL runtime packaging work documented in `docs/explanation/fixes/SQL_CONTAINER_ODBC_RUNTIME_FIX.md`. \ No newline at end of file diff --git a/docs/how-to/upgrade_paths.md b/docs/how-to/upgrade_paths.md index f1f4ad62..f31fcb4a 100644 --- a/docs/how-to/upgrade_paths.md +++ b/docs/how-to/upgrade_paths.md @@ -165,6 +165,8 @@ azd provision --preview If your App Service is already configured to pull its image from Azure Container Registry and your goal is to avoid any infrastructure reprovisioning, you can use an image-only rollout. +For the Azure CLI deployer specifically, the repo now includes `deployers/azurecli/upgrade-simplechat.ps1` for that path. It performs the code-only rollout in PowerShell by building the image in ACR and updating the existing App Service container configuration. + The repo already contains an image publish workflow: - [.github/workflows/docker_image_publish.yml](../../.github/workflows/docker_image_publish.yml) diff --git a/docs/reference/deploy/azurecli_powershell_deploy.md b/docs/reference/deploy/azurecli_powershell_deploy.md index f479c484..44ddb4ea 100644 --- a/docs/reference/deploy/azurecli_powershell_deploy.md +++ b/docs/reference/deploy/azurecli_powershell_deploy.md @@ -52,7 +52,7 @@ This deployer keeps you in the repo's container-based deployment model while giv

    Key scripts

    -

    The main flow lives in deployers/azurecli/deploy-simplechat.ps1, with destroy/reset support in the paired cleanup script.

    +

    The main flow lives in deployers/azurecli/deploy-simplechat.ps1, with paired PowerShell scripts for code-only upgrades and cleanup.

    @@ -70,6 +70,7 @@ This deployer keeps you in the repo's container-based deployment model while giv ## Main files - `deployers/azurecli/deploy-simplechat.ps1` +- `deployers/azurecli/upgrade-simplechat.ps1` - `deployers/azurecli/destroy-simplechat.ps1` ## Quick start @@ -83,6 +84,23 @@ cd deployers/azurecli ./deploy-simplechat.ps1 ``` +## Code-only upgrades + +For container-only releases where infrastructure does not change, use `upgrade-simplechat.ps1`. + +This script builds the image in ACR, updates the current App Service container image, verifies the updated image reference, and restarts the site. It gives the Azure CLI deployer a PowerShell-first equivalent to the normal `azd deploy` container rollout path without invoking the AZD post-configuration Python flow. + +Example: + +```powershell +cd deployers/azurecli +./upgrade-simplechat.ps1 ` + -AcrName registrysimplechatprod ` + -ImageName simplechat:2026-04-29_01 ` + -BaseName contoso ` + -Environment prod +``` + ## References - [Setup Instructions](../../setup_instructions.md) diff --git a/functional_tests/test_azurecli_upgrade_script.py b/functional_tests/test_azurecli_upgrade_script.py new file mode 100644 index 00000000..842be9e9 --- /dev/null +++ b/functional_tests/test_azurecli_upgrade_script.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# test_azurecli_upgrade_script.py +""" +Functional test for Azure CLI code-only upgrade script. +Version: 0.241.080 +Implemented in: 0.241.079 + +This test ensures that the Azure CLI deployer includes a standalone upgrade +script and documentation for building a new image in ACR and updating the +existing App Service container configuration. +""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +UPGRADE_SCRIPT = REPO_ROOT / "deployers" / "azurecli" / "upgrade-simplechat.ps1" +DEPLOYER_README = REPO_ROOT / "deployers" / "azurecli" / "README.md" +REFERENCE_DOC = REPO_ROOT / "docs" / "reference" / "deploy" / "azurecli_powershell_deploy.md" +UPGRADE_GUIDE = REPO_ROOT / "docs" / "how-to" / "upgrade_paths.md" + + +def require_contains(content: str, expected: str, description: str) -> None: + if expected not in content: + raise AssertionError(f"Missing {description}: {expected}") + + +def test_azurecli_upgrade_script() -> bool: + print("🧪 Testing Azure CLI PowerShell upgrade script") + print("=" * 70) + + script_content = UPGRADE_SCRIPT.read_text(encoding="utf-8") + readme_content = DEPLOYER_README.read_text(encoding="utf-8") + reference_content = REFERENCE_DOC.read_text(encoding="utf-8") + upgrade_guide_content = UPGRADE_GUIDE.read_text(encoding="utf-8") + + require_contains( + script_content, + "function Invoke-AcrContainerBuild", + "ACR build helper", + ) + require_contains( + script_content, + "az acr build", + "Azure CLI ACR build command", + ) + require_contains( + script_content, + "az webapp config container set", + "Azure CLI web app container update command", + ) + require_contains( + script_content, + "az webapp restart", + "Azure CLI web app restart command", + ) + require_contains( + script_content, + 'ResourceGroupName = "sc-$($BaseName)-$($Environment)-rg".ToLower()', + "default Azure CLI deployer resource-group naming", + ) + require_contains( + script_content, + "Specify either both -ResourceGroupName and -WebAppName, or both -BaseName and -Environment.", + "target resolution guidance", + ) + + require_contains( + readme_content, + "upgrade-simplechat.ps1", + "README upgrade script reference", + ) + require_contains( + reference_content, + "upgrade-simplechat.ps1", + "deployment reference upgrade script reference", + ) + require_contains( + upgrade_guide_content, + "upgrade-simplechat.ps1", + "upgrade guide Azure CLI upgrade script reference", + ) + + print("✅ Azure CLI upgrade script includes ACR build and web app container update flow") + print("✅ Azure CLI deployment docs reference the PowerShell upgrade path") + return True + + +if __name__ == "__main__": + try: + success = test_azurecli_upgrade_script() + except Exception as exc: + print(f"❌ Test failed: {exc}") + raise + + sys.exit(0 if success else 1) diff --git a/functional_tests/test_sql_container_odbc_runtime.py b/functional_tests/test_sql_container_odbc_runtime.py index c9acab99..3a2da258 100644 --- a/functional_tests/test_sql_container_odbc_runtime.py +++ b/functional_tests/test_sql_container_odbc_runtime.py @@ -1,12 +1,13 @@ # test_sql_container_odbc_runtime.py """ Functional test for SQL container ODBC runtime packaging. -Version: 0.241.064 -Implemented in: 0.241.064 +Version: 0.241.080 +Implemented in: 0.241.080 This test ensures that the application container packages the unixODBC runtime -and Microsoft ODBC Driver 18 for SQL Server, and that fresh SQL defaults use -ODBC Driver 18 across the backend and frontend surfaces. +and Microsoft ODBC Driver 18 for SQL Server, preserves the package-selected +unixODBC driver registry path, and keeps fresh SQL defaults on ODBC Driver 18 +across the backend and frontend surfaces. """ from pathlib import Path @@ -29,14 +30,22 @@ def test_dockerfile_packages_odbc_runtime() -> bool: "tdnf install -y unixODBC unixODBC-devel msodbcsql18", "COPY --from=builder /odbc-runtime/ /", 'LD_LIBRARY_PATH="/usr/lib64:/opt/microsoft/msodbcsql18/lib64"', - "cp -a /etc/odbcinst.ini /odbc-runtime/etc/", + 'driver_config_dir="$(odbcinst -j | awk -F\': \' \'/^DRIVERS/ {print $2}\')"', + 'test -f "${driver_config_dir}/odbcinst.ini"', + 'cp -a "${driver_config_dir}/odbcinst.ini" "/odbc-runtime${driver_config_dir}/"', + 'if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_dir}/odbcinst.ini" /odbc-runtime/etc/; fi;', ] missing = [snippet for snippet in expected_snippets if snippet not in dockerfile] if missing: raise AssertionError(f"Dockerfile is missing expected ODBC runtime snippets: {missing}") + assert "cp -a /etc/odbcinst.ini /odbc-runtime/etc/" not in dockerfile, ( + "Dockerfile should not hard-code /etc/odbcinst.ini because Azure Linux 3 registers the driver under the path reported by odbcinst -j" + ) + print("✅ Dockerfile packages unixODBC and msodbcsql18 runtime artifacts.") + print("✅ Dockerfile preserves the package-selected unixODBC driver registry path.") return True diff --git a/functional_tests/test_standard_chat_document_action_payload_fix.py b/functional_tests/test_standard_chat_document_action_payload_fix.py index 2634b4cd..c51b135e 100644 --- a/functional_tests/test_standard_chat_document_action_payload_fix.py +++ b/functional_tests/test_standard_chat_document_action_payload_fix.py @@ -2,7 +2,7 @@ # test_standard_chat_document_action_payload_fix.py """ Functional test for standard chat document action payload fix. -Version: 0.241.078 +Version: 0.241.080 Implemented in: 0.241.075 This test ensures standard chat omits disabled document-action payload fields @@ -27,8 +27,8 @@ def test_standard_chat_omits_disabled_document_action_payloads(): chat_messages_content = read_text("application/single_app/static/js/chat/chat-messages.js") feature_doc_content = read_text("docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md") - assert 'VERSION = "0.241.078"' in config_content, ( - "Expected config.py version 0.241.078 for the search-documents label update." + assert 'VERSION = "0.241.080"' in config_content, ( + "Expected config.py version 0.241.080 for the search-documents label update." ) assert '`Search Documents` keeps the normal prompt flow while searching the selected documents for relevant context.' in feature_doc_content, ( "Expected the document actions feature doc to describe the renamed default search behavior." From bd711fba71666fdda93850ad23e0a5fe1cb9f0cd Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 29 Apr 2026 14:52:32 -0400 Subject: [PATCH 24/28] awk fix --- application/single_app/Dockerfile | 3 ++- functional_tests/test_sql_container_odbc_runtime.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index c3f9dd5e..dc071acc 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -47,7 +47,8 @@ RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app/flask_session RUN mkdir /sc-temp-files && chown -R ${UID}:${GID} /sc-temp-files # Preserve the package-selected unixODBC driver registry path for the distroless runtime. RUN set -eux; \ - driver_config_dir="$(odbcinst -j | awk -F': ' '/^DRIVERS/ {print $2}')"; \ + driver_config_dir="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf '%s\n' "${line##*: }"; break ;; esac; done)"; \ + test -n "${driver_config_dir}"; \ test -f "${driver_config_dir}/odbcinst.ini"; \ mkdir -p /odbc-runtime/usr/lib64 /odbc-runtime/opt "/odbc-runtime${driver_config_dir}" /odbc-runtime/etc; \ cp -a "${driver_config_dir}/odbcinst.ini" "/odbc-runtime${driver_config_dir}/"; \ diff --git a/functional_tests/test_sql_container_odbc_runtime.py b/functional_tests/test_sql_container_odbc_runtime.py index 3a2da258..2977f9b4 100644 --- a/functional_tests/test_sql_container_odbc_runtime.py +++ b/functional_tests/test_sql_container_odbc_runtime.py @@ -30,7 +30,8 @@ def test_dockerfile_packages_odbc_runtime() -> bool: "tdnf install -y unixODBC unixODBC-devel msodbcsql18", "COPY --from=builder /odbc-runtime/ /", 'LD_LIBRARY_PATH="/usr/lib64:/opt/microsoft/msodbcsql18/lib64"', - 'driver_config_dir="$(odbcinst -j | awk -F\': \' \'/^DRIVERS/ {print $2}\')"', + 'driver_config_dir="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf \'%s\\n\' "${line##*: }"; break ;; esac; done)"', + 'test -n "${driver_config_dir}"', 'test -f "${driver_config_dir}/odbcinst.ini"', 'cp -a "${driver_config_dir}/odbcinst.ini" "/odbc-runtime${driver_config_dir}/"', 'if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_dir}/odbcinst.ini" /odbc-runtime/etc/; fi;', From 980a37192fb40da1bfee38ab1420d8a94d29f44c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 29 Apr 2026 15:19:22 -0400 Subject: [PATCH 25/28] updated to fix odbc pathing --- application/single_app/Dockerfile | 15 ++++--- application/single_app/config.py | 2 +- ...NTAINER_ODBCINST_PATH_NORMALIZATION_FIX.md | 40 +++++++++++++++++++ .../test_azurecli_upgrade_script.py | 2 +- .../test_sql_container_odbc_runtime.py | 18 +++++---- ...andard_chat_document_action_payload_fix.py | 6 +-- 6 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 docs/explanation/fixes/v0.241.081/SQL_CONTAINER_ODBCINST_PATH_NORMALIZATION_FIX.md diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index dc071acc..9569371d 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -47,12 +47,17 @@ RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app/flask_session RUN mkdir /sc-temp-files && chown -R ${UID}:${GID} /sc-temp-files # Preserve the package-selected unixODBC driver registry path for the distroless runtime. RUN set -eux; \ - driver_config_dir="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf '%s\n' "${line##*: }"; break ;; esac; done)"; \ - test -n "${driver_config_dir}"; \ - test -f "${driver_config_dir}/odbcinst.ini"; \ + driver_config_path="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf '%s\n' "${line##*: }"; break ;; esac; done)"; \ + test -n "${driver_config_path}"; \ + case "${driver_config_path}" in \ + */odbcinst.ini) driver_config_file="${driver_config_path}" ;; \ + *) driver_config_file="${driver_config_path}/odbcinst.ini" ;; \ + esac; \ + driver_config_dir="${driver_config_file%/odbcinst.ini}"; \ + test -f "${driver_config_file}"; \ mkdir -p /odbc-runtime/usr/lib64 /odbc-runtime/opt "/odbc-runtime${driver_config_dir}" /odbc-runtime/etc; \ - cp -a "${driver_config_dir}/odbcinst.ini" "/odbc-runtime${driver_config_dir}/"; \ - if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_dir}/odbcinst.ini" /odbc-runtime/etc/; fi; \ + cp -a "${driver_config_file}" "/odbc-runtime${driver_config_dir}/"; \ + if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_file}" /odbc-runtime/etc/; fi; \ cp -a /opt/microsoft /odbc-runtime/opt/; \ cp -a /usr/lib64/libodbc.so* /odbc-runtime/usr/lib64/; \ cp -a /usr/lib64/libodbcinst.so* /odbc-runtime/usr/lib64/; \ diff --git a/application/single_app/config.py b/application/single_app/config.py index 6d6f3c9e..a1a1ea9c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.080" +VERSION = "0.241.081" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/docs/explanation/fixes/v0.241.081/SQL_CONTAINER_ODBCINST_PATH_NORMALIZATION_FIX.md b/docs/explanation/fixes/v0.241.081/SQL_CONTAINER_ODBCINST_PATH_NORMALIZATION_FIX.md new file mode 100644 index 00000000..9d599996 --- /dev/null +++ b/docs/explanation/fixes/v0.241.081/SQL_CONTAINER_ODBCINST_PATH_NORMALIZATION_FIX.md @@ -0,0 +1,40 @@ +# SQL Container ODBCINST Path Normalization Fix + +Version: 0.241.081 + +Fixed/Implemented in version: **0.241.081** + +## Issue Description + +After updating the SQL container runtime packaging to use `odbcinst -j`, Azure Container Registry builds still failed while assembling the distroless ODBC payload. + +## Root Cause Analysis + +On Azure Linux 3, the `DRIVERS` entry returned by `odbcinst -j` resolves to the full file path `/etc/unixODBC/odbcinst.ini`, not just the containing directory. + +The previous Dockerfile revision treated that value as a directory and appended another `/odbcinst.ini`, producing an invalid path like `/etc/unixODBC/odbcinst.ini/odbcinst.ini`. + +## Technical Details + +### Files Modified + +- `application/single_app/Dockerfile` +- `application/single_app/config.py` +- `functional_tests/test_sql_container_odbc_runtime.py` + +### Code Changes Summary + +- Normalized the `odbcinst -j` result into a concrete `driver_config_file` first. +- Added shell-only handling for both supported output shapes: a direct `odbcinst.ini` file path or a directory path. +- Derived the runtime copy target directory from the normalized file path before copying the ODBC registry into `/odbc-runtime`. +- Extended the SQL container regression test to assert the file-or-directory normalization logic. + +## Testing And Validation + +- Local functional regression: `functional_tests/test_sql_container_odbc_runtime.py` +- Local container reproduction against `mcr.microsoft.com/azurelinux/base/python:3.12` confirmed that `odbcinst -j` reports `/etc/unixODBC/odbcinst.ini` on Azure Linux 3. + +## Impact Analysis + +- Local Docker builds and ACR task builds no longer depend on guessing whether `odbcinst -j` returns a directory or the full file path. +- The SQL Server ODBC runtime packaging step now works with the actual Azure Linux 3 unixODBC layout used by `msodbcsql18`. \ No newline at end of file diff --git a/functional_tests/test_azurecli_upgrade_script.py b/functional_tests/test_azurecli_upgrade_script.py index 842be9e9..7448711f 100644 --- a/functional_tests/test_azurecli_upgrade_script.py +++ b/functional_tests/test_azurecli_upgrade_script.py @@ -2,7 +2,7 @@ # test_azurecli_upgrade_script.py """ Functional test for Azure CLI code-only upgrade script. -Version: 0.241.080 +Version: 0.241.081 Implemented in: 0.241.079 This test ensures that the Azure CLI deployer includes a standalone upgrade diff --git a/functional_tests/test_sql_container_odbc_runtime.py b/functional_tests/test_sql_container_odbc_runtime.py index 2977f9b4..70322242 100644 --- a/functional_tests/test_sql_container_odbc_runtime.py +++ b/functional_tests/test_sql_container_odbc_runtime.py @@ -1,8 +1,8 @@ # test_sql_container_odbc_runtime.py """ Functional test for SQL container ODBC runtime packaging. -Version: 0.241.080 -Implemented in: 0.241.080 +Version: 0.241.081 +Implemented in: 0.241.081 This test ensures that the application container packages the unixODBC runtime and Microsoft ODBC Driver 18 for SQL Server, preserves the package-selected @@ -30,11 +30,15 @@ def test_dockerfile_packages_odbc_runtime() -> bool: "tdnf install -y unixODBC unixODBC-devel msodbcsql18", "COPY --from=builder /odbc-runtime/ /", 'LD_LIBRARY_PATH="/usr/lib64:/opt/microsoft/msodbcsql18/lib64"', - 'driver_config_dir="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf \'%s\\n\' "${line##*: }"; break ;; esac; done)"', - 'test -n "${driver_config_dir}"', - 'test -f "${driver_config_dir}/odbcinst.ini"', - 'cp -a "${driver_config_dir}/odbcinst.ini" "/odbc-runtime${driver_config_dir}/"', - 'if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_dir}/odbcinst.ini" /odbc-runtime/etc/; fi;', + 'driver_config_path="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf \'%s\\n\' "${line##*: }"; break ;; esac; done)"', + 'test -n "${driver_config_path}"', + 'case "${driver_config_path}" in', + '*/odbcinst.ini) driver_config_file="${driver_config_path}" ;;', + '*) driver_config_file="${driver_config_path}/odbcinst.ini" ;;', + 'driver_config_dir="${driver_config_file%/odbcinst.ini}"', + 'test -f "${driver_config_file}"', + 'cp -a "${driver_config_file}" "/odbc-runtime${driver_config_dir}/"', + 'if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_file}" /odbc-runtime/etc/; fi;', ] missing = [snippet for snippet in expected_snippets if snippet not in dockerfile] diff --git a/functional_tests/test_standard_chat_document_action_payload_fix.py b/functional_tests/test_standard_chat_document_action_payload_fix.py index c51b135e..e4c2e049 100644 --- a/functional_tests/test_standard_chat_document_action_payload_fix.py +++ b/functional_tests/test_standard_chat_document_action_payload_fix.py @@ -2,7 +2,7 @@ # test_standard_chat_document_action_payload_fix.py """ Functional test for standard chat document action payload fix. -Version: 0.241.080 +Version: 0.241.081 Implemented in: 0.241.075 This test ensures standard chat omits disabled document-action payload fields @@ -27,8 +27,8 @@ def test_standard_chat_omits_disabled_document_action_payloads(): chat_messages_content = read_text("application/single_app/static/js/chat/chat-messages.js") feature_doc_content = read_text("docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md") - assert 'VERSION = "0.241.080"' in config_content, ( - "Expected config.py version 0.241.080 for the search-documents label update." + assert 'VERSION = "0.241.081"' in config_content, ( + "Expected config.py version 0.241.081 for the search-documents label update." ) assert '`Search Documents` keeps the normal prompt flow while searching the selected documents for relevant context.' in feature_doc_content, ( "Expected the document actions feature doc to describe the renamed default search behavior." From 7d3f9dd0df475cfeb60111ad57c1b9f6fb099435 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 29 Apr 2026 15:39:54 -0400 Subject: [PATCH 26/28] Delete logs.txt --- deployers/logs.txt | 17675 ------------------------------------------- 1 file changed, 17675 deletions(-) delete mode 100644 deployers/logs.txt diff --git a/deployers/logs.txt b/deployers/logs.txt deleted file mode 100644 index 9617225f..00000000 --- a/deployers/logs.txt +++ /dev/null @@ -1,17675 +0,0 @@ -2026/03/25 11:26:45 main.go:58: azd version: 1.23.12 (commit b37e4350c0229afb71e98bbb5f6c14497659e3b3) -2026/03/25 11:26:45 detect_process.go:56: detect_process.go: Parent process detection: depth=0, pid=85924, ppid=64444, name="pwsh.exe", executable="C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.6.0.0_x64__8wekyb3d8bbwe\\pwsh.exe" -2026/03/25 11:26:45 detect_process.go:56: detect_process.go: Parent process detection: depth=1, pid=64444, ppid=50364, name="Code.exe", executable="C:\\Users\\paullizer\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe" -2026/03/25 11:26:45 detect_process.go:56: detect_process.go: Parent process detection: depth=2, pid=50364, ppid=45912, name="Code.exe", executable="C:\\Users\\paullizer\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe" -2026/03/25 11:26:45 detect_process.go:56: detect_process.go: Parent process detection: depth=3, pid=45912, ppid=64768, name="GitHubDesktop.exe", executable="C:\\Users\\paullizer\\AppData\\Local\\GitHubDesktop\\app-3.5.6\\GitHubDesktop.exe" -2026/03/25 11:26:45 detect_process.go:56: detect_process.go: Parent process detection: depth=4, pid=64768, ppid=47968, name="GitHubDesktop.exe", executable="C:\\Users\\paullizer\\AppData\\Local\\GitHubDesktop\\app-3.5.6\\GitHubDesktop.exe" -2026/03/25 11:26:45 detect_process.go:52: detect_process.go: Failed to get process info for pid 47968: failed to open process 47968: The parameter is incorrect. -2026/03/25 11:26:45 detect_process.go:72: detect_process.go: Parent process detection: no agent found in process tree -2026/03/25 11:26:45 detect.go:25: Agent detection result: detected=false, no AI coding agent detected -2026/03/25 11:26:45 project.go:152: Reading project from file 'C:\repos\simplechat\deployers\azure.yaml' -2026/03/25 11:26:45 middleware.go:100: running middleware 'debug' -2026/03/25 11:26:45 middleware.go:100: running middleware 'ux' -2026/03/25 11:26:45 middleware.go:100: running middleware 'telemetry' -2026/03/25 11:26:45 telemetry.go:68: TraceID: dd6b9467838cd44356a50058eb0073ab -2026/03/25 11:26:45 middleware.go:100: running middleware 'error' -2026/03/25 11:26:45 middleware.go:100: running middleware 'loginGuard' -2026/03/25 11:26:46 middleware.go:100: running middleware 'hooks' -2026/03/25 11:26:46 hooks.go:130: service 'web' does not require any command hooks. -2026/03/25 11:26:46 middleware.go:100: running middleware 'extensions' -2026/03/25 11:26:46 importer.go:324: using infrastructure from C:\repos\simplechat\deployers\bicep directory -Initialize bicep provider -2026/03/25 11:26:46 command_runner.go:325: Run exec: 'C:\Users\paullizer\.azd\bin\bicep.exe --version' , exit code: 0 --------------------------------------stdout------------------------------------------- -Bicep CLI version 0.41.2 (3e403ea7c1) -2026/03/25 11:26:46 bicep.go:112: bicep version: 0.41.2 -2026/03/25 11:26:46 bicep.go:126: using local bicep: C:\Users\paullizer\.azd\bin\bicep.exe -2026/03/25 11:26:50 command_runner.go:325: Run exec: 'C:\Users\paullizer\.azd\bin\bicep.exe build C:\repos\simplechat\deployers\bicep\main.bicep --stdout' , exit code: 0 --------------------------------------stdout------------------------------------------- -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "10060260231818509949" - } - }, - "parameters": { - "location": { - "type": "string", - "minLength": 1, - "metadata": { - "description": "The Azure region where resources will be deployed. \n- Region must align to the target cloud environment" - } - }, - "cloudEnvironment": { - "type": "string", - "defaultValue": "[if(equals(environment().name, 'AzureCloud'), 'public', if(equals(environment().name, 'AzureUSGovernment'), 'usgovernment', 'custom'))]", - "allowedValues": [ - "AzureCloud", - "AzureUSGovernment", - "public", - "usgovernment", - "custom" - ], - "metadata": { - "description": "The target Azure Cloud environment.\n- Accepted values are: AzureCloud, AzureUSGovernment, public, usgovernment, custom\n- Default is based on the ARM cloud name" - } - }, - "appName": { - "type": "string", - "minLength": 3, - "maxLength": 12, - "metadata": { - "description": "The name of the application to be deployed. \n- Name may only contain letters and numbers\n- Between 3 and 12 characters in length \n- No spaces or special characters" - } - }, - "environment": { - "type": "string", - "minLength": 2, - "maxLength": 10, - "metadata": { - "description": "The dev/qa/prod environment or as named in your environment. This will be used to create resource group names and tags.\n- Must be between 2 and 10 characters in length\n- No spaces or special characters" - } - }, - "azdEnvironmentName": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "metadata": { - "description": "Name of the AZD environment" - } - }, - "imageName": { - "type": "string", - "defaultValue": "simplechat:latest", - "metadata": { - "description": "The name of the container image to deploy to the web app.\n- should be in the format :" - } - }, - "enterpriseAppClientId": { - "type": "string", - "metadata": { - "description": "Azure AD Application Client ID for enterprise authentication.\n- Should be the client ID of the registered Azure AD application" - } - }, - "enterpriseAppServicePrincipalId": { - "type": "string", - "metadata": { - "description": "Azure AD Application Service Principal Id for the enterprise application.\n- Should be the Service Principal ID of the registered Azure AD application" - } - }, - "enterpriseAppClientSecret": { - "type": "securestring", - "metadata": { - "description": "Azure AD Application Client Secret for enterprise authentication.\n- Required if enableEnterpriseApp is true\n- Should be created in Azure AD App Registration and passed via environment variable\n- Will be stored securely in Azure Key Vault during deployment" - } - }, - "authenticationType": { - "type": "string", - "allowedValues": [ - "key", - "managed_identity" - ], - "metadata": { - "description": "Authentication type for resources that support Managed Identity or Key authentication.\n- Key: Use access keys for authentication (application keys will be stored in Key Vault)\n- managed_identity: Use Managed Identity for authentication" - } - }, - "configureApplicationPermissions": { - "type": "bool", - "metadata": { - "description": "Configure permissions (based on authenticationType) for the deployed web application to access required resources.\n" - } - }, - "specialTags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional object containing additional tags to apply to all resources." - } - }, - "enableDiagLogging": { - "type": "bool", - "metadata": { - "description": "Enable diagnostic logging for resources deployed in the resource group. \n- All content will be sent to the deployed Log Analytics workspace\n- Default is false" - } - }, - "enablePrivateNetworking": { - "type": "bool", - "metadata": { - "description": "Enable private endpoints and virtual network integration for deployed resources. \n- Default is false" - } - }, - "existingVirtualNetworkId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing virtual network resource ID to reuse when private networking is enabled.\n- May reference a virtual network in the same or another resource group or subscription\n- Leave blank to create a new virtual network" - } - }, - "existingAppServiceSubnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing subnet resource ID to use for App Service VNet integration.\n- May reference a subnet in the same or another resource group or subscription\n- Required when reusing an existing virtual network because subnets are not created in external virtual networks" - } - }, - "existingPrivateEndpointSubnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing subnet resource ID to use for private endpoints.\n- May reference a subnet in the same or another resource group or subscription\n- Required when reusing an existing virtual network because subnets are not created in external virtual networks" - } - }, - "privateDnsZoneConfigs": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional per-zone private DNS configuration for private networking.\n- Leave empty to create all private DNS zones locally and create VNet links automatically\n- For each supported key, provide:\n - zoneResourceId: Optional existing private DNS zone resource ID to reuse\n - createVNetLink: Optional bool, defaults to true. Set to false if the customer manages the VNet link separately\n- Supported keys: keyVault, cosmosDb, containerRegistry, aiSearch, blobStorage, cognitiveServices, openAi, webSites" - } - }, - "existingOpenAIEndpoint": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing Azure OpenAI or Azure AI Foundry OpenAI-compatible endpoint.\n- Leave blank to deploy a new Azure OpenAI resource\n- Public Azure AI Foundry project endpoints are supported for application configuration, but do not support private endpoint automation" - } - }, - "existingOpenAIResourceName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing Azure OpenAI resource name.\n- Provide this when reusing a standard Azure OpenAI resource and you want managed identity permissions or private endpoint integration configured automatically" - } - }, - "existingOpenAIResourceGroup": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional resource group for an existing Azure OpenAI resource.\n- Used when reusing a standard Azure OpenAI resource across resource groups or subscriptions" - } - }, - "existingOpenAISubscriptionId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional subscription ID for an existing Azure OpenAI resource.\n- Used when reusing a standard Azure OpenAI resource across subscriptions" - } - }, - "openAIDeploymentType": { - "type": "string", - "allowedValues": [ - "Standard", - "DatazoneStandard", - "GlobalStandard" - ], - "metadata": { - "description": "Azure OpenAI deployment type used for the default GPT and embedding model deployments.\n- Azure Commercial options: Standard, DatazoneStandard, GlobalStandard\n- Azure Government default model deployments use Standard regardless of this selection\n- Ignored when you provide custom gptModels or embeddingModels arrays" - } - }, - "customBlobStorageSuffix": { - "type": "string", - "defaultValue": "[format('blob.{0}', environment().suffixes.storage)]", - "metadata": { - "description": "Custom blob storage URL suffix, e.g. blob.core.usgovcloudapi.net" - } - }, - "customGraphUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Graph API URL, e.g. https://graph.microsoft.us" - } - }, - "customIdentityUrl": { - "type": "string", - "defaultValue": "[environment().authentication.loginEndpoint]", - "metadata": { - "description": "Custom Identity URL, e.g. https://login.microsoftonline.us/" - } - }, - "customResourceManagerUrl": { - "type": "string", - "defaultValue": "[environment().resourceManager]", - "metadata": { - "description": "Custom Resource Manager URL, e.g. https://management.usgovcloudapi.net" - } - }, - "customCognitiveServicesScope": { - "type": "string", - "defaultValue": "https://cognitiveservices.azure.com/.default", - "metadata": { - "description": "Custom Cognitive Services scope ex: https://cognitiveservices.azure.com/.default" - } - }, - "customSearchResourceUrl": { - "type": "string", - "defaultValue": "https://search.azure.com", - "metadata": { - "description": "Custom search resource URL for token audience, e.g. https://search.azure.us" - } - }, - "gptModels": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Array of GPT model names to deploy to the OpenAI resource." - } - }, - "embeddingModels": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Array of embedding model names to deploy to the OpenAI resource." - } - }, - "allowedIpAddresses": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Comma separated list of IP addresses or ranges to allow access to resources when private networking is enabled.\nLeave blank if not using private networking.\n- Format for single IP: 'x.x.x.x'\n- Format for range: 'x.x.x.x/y'\n- Example: 1.2.3.4, 2.3.4.5/32\n" - } - }, - "deployContentSafety": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Content Safety service and related resources.\n- Default is false" - } - }, - "deployRedisCache": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Azure Cache for Redis and related resources.\n- Default is false" - } - }, - "deploySpeechService": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Azure Speech service and related resources.\n- Default is false" - } - }, - "deployVideoIndexerService": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Azure Video Indexer service and related resources.\n- Default is false" - } - } - }, - "variables": { - "copy": [ - { - "name": "allowedIpAddressesArray", - "count": "[length(variables('allowedIpAddressesSplit'))]", - "input": "[trim(variables('allowedIpAddressesSplit')[copyIndex('allowedIpAddressesArray')])]" - }, - { - "name": "cosmosDbIpRules", - "count": "[length(variables('allowedIpsForCosmos'))]", - "input": { - "ipAddressOrRange": "[variables('allowedIpsForCosmos')[copyIndex('cosmosDbIpRules')]]" - } - }, - { - "name": "acrIpRules", - "count": "[length(variables('allowedIpAddressesArray'))]", - "input": { - "action": "Allow", - "value": "[variables('allowedIpAddressesArray')[copyIndex('acrIpRules')]]" - } - } - ], - "scCloudEnvironment": "[if(equals(parameters('cloudEnvironment'), 'AzureCloud'), 'public', if(equals(parameters('cloudEnvironment'), 'AzureUSGovernment'), 'usgovernment', parameters('cloudEnvironment')))]", - "allowedIpAddressesSplit": "[if(empty(parameters('allowedIpAddresses')), createArray(), split(parameters('allowedIpAddresses'), ','))]", - "rgName": "[format('{0}-{1}-rg', parameters('appName'), parameters('environment'))]", - "requiredTags": { - "application": "[parameters('appName')]", - "environment": "[parameters('environment')]", - "azd-env-name": "[parameters('azdEnvironmentName')]" - }, - "tags": "[union(variables('requiredTags'), parameters('specialTags'))]", - "isPublicCloud": "[equals(variables('scCloudEnvironment'), 'public')]", - "isUsGovernmentCloud": "[equals(variables('scCloudEnvironment'), 'usgovernment')]", - "acrCloudSuffix": "[if(variables('isPublicCloud'), '.azurecr.io', '.azurecr.us')]", - "acrName": "[toLower(format('{0}{1}acr', parameters('appName'), parameters('environment')))]", - "containerRegistry": "[format('{0}{1}', variables('acrName'), variables('acrCloudSuffix'))]", - "containerImageName": "[format('{0}/{1}', variables('containerRegistry'), parameters('imageName'))]", - "vNetName": "[format('{0}-{1}-vnet', parameters('appName'), parameters('environment'))]", - "normalizedLocation": "[toLower(replace(parameters('location'), ' ', ''))]", - "resolvedOpenAIDeploymentType": "[if(variables('isUsGovernmentCloud'), 'Standard', parameters('openAIDeploymentType'))]", - "defaultGptModels": [ - { - "modelName": "gpt-4o", - "modelVersion": "[if(variables('isUsGovernmentCloud'), '2024-05-13', '2024-11-20')]", - "skuName": "[variables('resolvedOpenAIDeploymentType')]", - "skuCapacity": 100 - } - ], - "defaultEmbeddingModels": "[if(variables('isUsGovernmentCloud'), createArray(createObject('modelName', if(equals(variables('normalizedLocation'), 'usgovvirginia'), 'text-embedding-ada-002', 'text-embedding-3-small'), 'modelVersion', if(equals(variables('normalizedLocation'), 'usgovvirginia'), '2', '1'), 'skuName', variables('resolvedOpenAIDeploymentType'), 'skuCapacity', 100)), createArray(createObject('modelName', 'text-embedding-3-small', 'modelVersion', '1', 'skuName', variables('resolvedOpenAIDeploymentType'), 'skuCapacity', 100)))]", - "resolvedGptModels": "[if(empty(parameters('gptModels')), variables('defaultGptModels'), parameters('gptModels'))]", - "resolvedEmbeddingModels": "[if(empty(parameters('embeddingModels')), variables('defaultEmbeddingModels'), parameters('embeddingModels'))]", - "hasExistingAppServiceSubnetId": "[not(empty(parameters('existingAppServiceSubnetId')))]", - "hasExistingPrivateEndpointSubnetId": "[not(empty(parameters('existingPrivateEndpointSubnetId')))]", - "inferredVirtualNetworkId": "[if(variables('hasExistingAppServiceSubnetId'), split(parameters('existingAppServiceSubnetId'), '/subnets/')[0], if(variables('hasExistingPrivateEndpointSubnetId'), split(parameters('existingPrivateEndpointSubnetId'), '/subnets/')[0], ''))]", - "resolvedExistingVirtualNetworkId": "[if(not(empty(parameters('existingVirtualNetworkId'))), parameters('existingVirtualNetworkId'), variables('inferredVirtualNetworkId'))]", - "useExistingVirtualNetwork": "[and(parameters('enablePrivateNetworking'), or(or(not(empty(variables('resolvedExistingVirtualNetworkId'))), variables('hasExistingAppServiceSubnetId')), variables('hasExistingPrivateEndpointSubnetId')))]", - "allowedIpsForCosmos": "[union(createArray('0.0.0.0'), variables('allowedIpAddressesArray'))]" - }, - "resources": { - "rg": { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2022-09-01", - "name": "[variables('rgName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]" - }, - "virtualNetwork": { - "condition": "[and(parameters('enablePrivateNetworking'), not(variables('useExistingVirtualNetwork')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "virtualNetwork", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "vNetName": { - "value": "[variables('vNetName')]" - }, - "addressSpaces": { - "value": [ - "10.0.0.0/21" - ] - }, - "subnetConfigs": { - "value": [ - { - "name": "AppServiceIntegration", - "addressPrefix": "10.0.0.0/24", - "enablePrivateEndpointNetworkPolicies": true, - "enablePrivateLinkServiceNetworkPolicies": true - }, - { - "name": "PrivateEndpoints", - "addressPrefix": "10.0.2.0/24", - "enablePrivateEndpointNetworkPolicies": true, - "enablePrivateLinkServiceNetworkPolicies": true - } - ] - }, - "tags": { - "value": "[variables('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "493906105301296560" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "vNetName": { - "type": "string" - }, - "addressSpaces": { - "type": "array" - }, - "subnetConfigs": { - "type": "array" - }, - "tags": { - "type": "object" - } - }, - "variables": { - "copy": [ - { - "name": "subnetIds", - "count": "[length(parameters('subnetConfigs'))]", - "input": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vNetName'), parameters('subnetConfigs')[copyIndex('subnetIds')].name)]" - }, - { - "name": "subnetNames", - "count": "[length(parameters('subnetConfigs'))]", - "input": "[parameters('subnetConfigs')[copyIndex('subnetNames')].name]" - } - ], - "appServiceIntegrationSubnetIndex": "[indexOf(variables('subnetNames'), 'AppServiceIntegration')]", - "privateEndpointIndex": "[indexOf(variables('subnetNames'), 'PrivateEndpoints')]" - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-05-01", - "name": "[parameters('vNetName')]", - "location": "[parameters('location')]", - "properties": { - "copy": [ - { - "name": "subnets", - "count": "[length(parameters('subnetConfigs'))]", - "input": { - "name": "[parameters('subnetConfigs')[copyIndex('subnets')].name]", - "properties": { - "addressPrefix": "[parameters('subnetConfigs')[copyIndex('subnets')].addressPrefix]", - "privateEndpointNetworkPolicies": "[if(parameters('subnetConfigs')[copyIndex('subnets')].enablePrivateEndpointNetworkPolicies, 'Enabled', 'Disabled')]", - "privateLinkServiceNetworkPolicies": "[if(parameters('subnetConfigs')[copyIndex('subnets')].enablePrivateLinkServiceNetworkPolicies, 'Enabled', 'Disabled')]", - "delegations": "[if(equals(parameters('subnetConfigs')[copyIndex('subnets')].name, 'AppServiceIntegration'), createArray(createObject('name', 'delegation', 'properties', createObject('serviceName', 'Microsoft.Web/serverFarms'))), createArray())]" - } - } - } - ], - "addressSpace": { - "addressPrefixes": "[parameters('addressSpaces')]" - } - }, - "tags": "[parameters('tags')]" - } - ], - "outputs": { - "vNetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vNetName'))]" - }, - "privateNetworkSubnetId": { - "type": "string", - "value": "[if(equals(variables('privateEndpointIndex'), -1), '', variables('subnetIds')[variables('privateEndpointIndex')])]" - }, - "appServiceSubnetId": { - "type": "string", - "value": "[if(equals(variables('appServiceIntegrationSubnetIndex'), -1), '', variables('subnetIds')[variables('appServiceIntegrationSubnetIndex')])]" - } - } - } - }, - "dependsOn": [ - "rg" - ] - }, - "logAnalytics": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "logAnalytics", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3702219330633152864" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2022-10-01", - "name": "[toLower(format('{0}-{1}-la', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "properties": { - "sku": { - "name": "PerGB2018" - } - }, - "tags": "[parameters('tags')]" - } - ], - "outputs": { - "logAnalyticsId": { - "type": "string", - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', toLower(format('{0}-{1}-la', parameters('appName'), parameters('environment'))))]" - } - } - } - }, - "dependsOn": [ - "rg" - ] - }, - "applicationInsights": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "applicationInsights", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16478820448576828524" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[toLower(format('{0}-{1}-ai', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "web", - "properties": { - "Application_Type": "web", - "WorkspaceResourceId": "[parameters('logAnalyticsId')]" - }, - "tags": "[parameters('tags')]" - } - ], - "outputs": { - "appInsightsName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-ai', parameters('appName'), parameters('environment')))]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "keyVault": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyVault", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3020666832422074415" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2024-11-01", - "name": "[toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "properties": { - "tenantId": "[subscription().tenantId]", - "sku": { - "family": "A", - "name": "standard" - }, - "accessPolicies": [], - "enabledForDeployment": false, - "enabledForDiskEncryption": false, - "enabledForTemplateDeployment": false, - "publicNetworkAccess": "Enabled", - "enableRbacAuthorization": true - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "keyVaultId": { - "type": "string", - "value": "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]" - }, - "keyVaultName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))]" - }, - "keyVaultUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))), '2024-11-01').vaultUri]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "storeEnterpriseAppSecret": { - "condition": "[not(empty(parameters('enterpriseAppClientSecret')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storeEnterpriseAppSecret", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "secretName": { - "value": "enterprise-app-client-secret" - }, - "secretValue": { - "value": "[parameters('enterpriseAppClientSecret')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15395992383660736405" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "secretName": { - "type": "string" - }, - "secretValue": { - "type": "securestring" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2025-05-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", - "properties": { - "value": "[parameters('secretValue')]" - } - } - ], - "outputs": { - "SecretUri": { - "type": "string", - "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" - } - } - } - }, - "dependsOn": [ - "keyVault", - "rg" - ] - }, - "cosmosDB": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "cosmosDB", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - }, - "allowedIpAddresses": { - "value": "[variables('cosmosDbIpRules')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "12491761736151044216" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - }, - "allowedIpAddresses": { - "type": "array", - "defaultValue": [] - } - }, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "GlobalDocumentDB", - "properties": { - "publicNetworkAccess": "Enabled", - "databaseAccountOfferType": "Standard", - "capabilities": [ - { - "name": "EnableServerless" - } - ], - "isVirtualNetworkFilterEnabled": "[if(parameters('enablePrivateNetworking'), true(), false())]", - "ipRules": "[if(parameters('enablePrivateNetworking'), parameters('allowedIpAddresses'), createArray())]", - "locations": [ - { - "locationName": "[parameters('location')]", - "failoverPriority": 0, - "isZoneRedundant": false - } - ], - "consistencyPolicy": { - "defaultConsistencyLevel": "Session" - } - }, - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat')]", - "properties": { - "resource": { - "id": "SimpleChat" - }, - "options": {} - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat', 'settings')]", - "properties": { - "resource": { - "id": "settings", - "partitionKey": { - "paths": [ - "/id" - ] - } - }, - "options": {} - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": [] - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "cosmosDbName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))]" - }, - "cosmosDbUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))), '2023-04-15').documentEndpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "acr": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "azureContainerRegistry", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "acrName": { - "value": "[variables('acrName')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - }, - "allowedIpAddresses": { - "value": "[variables('acrIpRules')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "8868053889964006912" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - }, - "allowedIpAddresses": { - "type": "array", - "defaultValue": [] - } - }, - "resources": [ - { - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2025-04-01", - "name": "[parameters('acrName')]", - "location": "[parameters('location')]", - "sku": { - "name": "[if(parameters('enablePrivateNetworking'), 'Premium', 'Standard')]" - }, - "properties": { - "adminUserEnabled": true, - "publicNetworkAccess": "Enabled", - "networkRuleBypassOptions": "[if(parameters('enablePrivateNetworking'), 'AzureServices', 'None')]", - "networkRuleSet": "[if(parameters('enablePrivateNetworking'), createObject('defaultAction', 'Allow', 'ipRules', parameters('allowedIpAddresses')), null())]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "name": "[toLower(format('{0}-diagnostics', parameters('acrName')))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "acrName": { - "type": "string", - "value": "[parameters('acrName')]" - }, - "acrResourceGroup": { - "type": "string", - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "searchService": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "searchService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "433641641835184139" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.Search/searchServices", - "apiVersion": "2025-05-01", - "name": "[toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "sku": { - "name": "basic" - }, - "properties": { - "hostingMode": "default", - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "replicaCount": 1, - "partitionCount": 1, - "authOptions": { - "aadOrApiKey": { - "aadAuthFailureMode": "http403" - } - }, - "disableLocalAuth": false - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "searchServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))]" - }, - "searchServiceEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))), '2025-05-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "docIntel": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "docIntel", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "1751663477897380161" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "FormRecognizer", - "sku": { - "name": "S0" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "documentIntelligenceServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]" - }, - "diagnosticLoggingEnabled": { - "type": "bool", - "value": "[parameters('enableDiagLogging')]" - }, - "documentIntelligenceServiceEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))), '2025-06-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "storageAccount": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storageAccount", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "keyVault": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "configureApplicationPermissions": { - "value": "[parameters('configureApplicationPermissions')]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "811367549199956159" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "keyVault": { - "type": "string" - }, - "authenticationType": { - "type": "string" - }, - "configureApplicationPermissions": { - "type": "bool" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2022-09-01", - "name": "[toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2", - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "accessTier": "Hot", - "allowBlobPublicAccess": false, - "allowSharedKeyAccess": true, - "isHnsEnabled": true - }, - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.Storage/storageAccounts/blobServices", - "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts/blobServices/containers", - "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}/{2}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default', 'user-documents')]", - "properties": { - "publicAccess": "None" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts/blobServices/containers", - "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}/{2}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default', 'group-documents')]", - "properties": { - "publicAccess": "None" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": [], - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", - "name": "[toLower(format('{0}-blob-diagnostics', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - }, - { - "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storeStorageAccountSecret", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVault')]" - }, - "secretName": { - "value": "storage-account-key" - }, - "secretValue": { - "value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))), '2022-09-01').keys[0].value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15395992383660736405" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "secretName": { - "type": "string" - }, - "secretValue": { - "type": "securestring" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2025-05-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", - "properties": { - "value": "[parameters('secretValue')]" - } - } - ], - "outputs": { - "SecretUri": { - "type": "string", - "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - } - ], - "outputs": { - "name": { - "type": "string", - "value": "[toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))]" - }, - "endpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))), '2022-09-01').primaryEndpoints.blob]" - } - } - } - }, - "dependsOn": [ - "keyVault", - "logAnalytics", - "rg" - ] - }, - "openAI": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAI", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "existingOpenAIEndpoint": { - "value": "[parameters('existingOpenAIEndpoint')]" - }, - "existingOpenAIResourceName": { - "value": "[parameters('existingOpenAIResourceName')]" - }, - "existingOpenAIResourceGroup": { - "value": "[parameters('existingOpenAIResourceGroup')]" - }, - "existingOpenAISubscriptionId": { - "value": "[parameters('existingOpenAISubscriptionId')]" - }, - "gptModels": { - "value": "[variables('resolvedGptModels')]" - }, - "embeddingModels": { - "value": "[variables('resolvedEmbeddingModels')]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3132373501615141396" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "existingOpenAIEndpoint": { - "type": "string", - "defaultValue": "" - }, - "existingOpenAIResourceName": { - "type": "string", - "defaultValue": "" - }, - "existingOpenAIResourceGroup": { - "type": "string", - "defaultValue": "" - }, - "existingOpenAISubscriptionId": { - "type": "string", - "defaultValue": "" - }, - "gptModels": { - "type": "array" - }, - "embeddingModels": { - "type": "array" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "variables": { - "useExistingOpenAIEndpoint": "[not(empty(parameters('existingOpenAIEndpoint')))]", - "resolvedOpenAIResourceGroup": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAIResourceGroup'), resourceGroup().name)]", - "resolvedOpenAISubscriptionId": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAISubscriptionId'), subscription().subscriptionId)]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingOpenAIEndpoint'))]", - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2024-10-01", - "name": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "OpenAI", - "sku": { - "name": "S0" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(parameters('enableDiagLogging'), not(variables('useExistingOpenAIEndpoint')))]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - }, - { - "copy": { - "name": "aiModel", - "count": "[length(if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray()))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('model-{0}-{1}', replace(if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].modelName, '.', '-'), copyIndex())]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "parent": { - "value": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" - }, - "modelName": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].modelName]" - }, - "modelVersion": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].modelVersion]" - }, - "skuName": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].skuName]" - }, - "skuCapacity": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].skuCapacity]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "12746224250029736064" - } - }, - "parameters": { - "parent": { - "type": "string" - }, - "modelName": { - "type": "string" - }, - "modelVersion": { - "type": "string" - }, - "skuName": { - "type": "string" - }, - "skuCapacity": { - "type": "int" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('parent'), parameters('modelName'))]", - "properties": { - "model": { - "format": "OpenAI", - "name": "[parameters('modelName')]", - "version": "[parameters('modelVersion')]" - } - }, - "sku": { - "name": "[parameters('skuName')]", - "capacity": "[parameters('skuCapacity')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" - ] - } - ], - "outputs": { - "openAIName": { - "type": "string", - "value": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAIResourceName'), toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" - }, - "openAIResourceGroup": { - "type": "string", - "value": "[variables('resolvedOpenAIResourceGroup')]" - }, - "openAISubscriptionId": { - "type": "string", - "value": "[variables('resolvedOpenAISubscriptionId')]" - }, - "openAIEndpoint": { - "type": "string", - "value": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAIEndpoint'), reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))), '2024-10-01').endpoint)]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "appServicePlan": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "appServicePlan", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "9868911322271424816" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2022-03-01", - "name": "[toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "sku": { - "name": "P1v3", - "tier": "PremiumV3", - "size": "P1v3", - "capacity": 1 - }, - "kind": "app,linux,container", - "properties": { - "reserved": true, - "perSiteScaling": false, - "maximumElasticWorkerCount": 1, - "hyperV": false, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": [], - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "appServicePlanId": { - "type": "string", - "value": "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "appService": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "appService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "acrName": { - "value": "[reference('acr').outputs.acrName.value]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "appServicePlanId": { - "value": "[reference('appServicePlan').outputs.appServicePlanId.value]" - }, - "containerImageName": { - "value": "[variables('containerImageName')]" - }, - "azurePlatform": { - "value": "[variables('scCloudEnvironment')]" - }, - "cosmosDbName": { - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "searchServiceName": { - "value": "[reference('searchService').outputs.searchServiceName.value]" - }, - "openAiServiceName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "openAiEndpoint": { - "value": "[reference('openAI').outputs.openAIEndpoint.value]" - }, - "openAiResourceGroupName": { - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "documentIntelligenceServiceName": { - "value": "[reference('docIntel').outputs.documentIntelligenceServiceName.value]" - }, - "appInsightsName": { - "value": "[reference('applicationInsights').outputs.appInsightsName.value]" - }, - "enterpriseAppClientId": { - "value": "[parameters('enterpriseAppClientId')]" - }, - "enterpriseAppClientSecret": { - "value": "[parameters('enterpriseAppClientSecret')]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "keyVaultUri": { - "value": "[reference('keyVault').outputs.keyVaultUri.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - }, - "appServiceSubnetId": "[if(parameters('enablePrivateNetworking'), if(variables('useExistingVirtualNetwork'), createObject('value', parameters('existingAppServiceSubnetId')), createObject('value', reference('virtualNetwork').outputs.appServiceSubnetId.value)), createObject('value', ''))]", - "customBlobStorageSuffix": { - "value": "[parameters('customBlobStorageSuffix')]" - }, - "customGraphUrl": { - "value": "[parameters('customGraphUrl')]" - }, - "customIdentityUrl": { - "value": "[parameters('customIdentityUrl')]" - }, - "customResourceManagerUrl": { - "value": "[parameters('customResourceManagerUrl')]" - }, - "customCognitiveServicesScope": { - "value": "[parameters('customCognitiveServicesScope')]" - }, - "customSearchResourceUrl": { - "value": "[parameters('customSearchResourceUrl')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "7866543323088689102" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "appServicePlanId": { - "type": "string" - }, - "containerImageName": { - "type": "string" - }, - "azurePlatform": { - "type": "string" - }, - "cosmosDbName": { - "type": "string" - }, - "searchServiceName": { - "type": "string" - }, - "openAiServiceName": { - "type": "string" - }, - "openAiEndpoint": { - "type": "string" - }, - "openAiResourceGroupName": { - "type": "string" - }, - "documentIntelligenceServiceName": { - "type": "string" - }, - "appInsightsName": { - "type": "string" - }, - "enterpriseAppClientId": { - "type": "string", - "defaultValue": "" - }, - "authenticationType": { - "type": "string" - }, - "enterpriseAppClientSecret": { - "type": "securestring", - "defaultValue": "" - }, - "keyVaultUri": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - }, - "appServiceSubnetId": { - "type": "string", - "defaultValue": "" - }, - "customBlobStorageSuffix": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom blob storage URL suffix, e.g. blob.core.usgovcloudapi.net" - } - }, - "customGraphUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Graph API URL, e.g. https://graph.microsoft.us" - } - }, - "customIdentityUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Identity URL, e.g. https://login.microsoftonline.us" - } - }, - "customResourceManagerUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Resource Manager URL, e.g. https://management.usgovcloudapi.net" - } - }, - "customCognitiveServicesScope": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Cognitive Services scope ex: https://cognitiveservices.azure.com/.default" - } - }, - "customSearchResourceUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom search resource URL for token audience, e.g. https://search.azure.us" - } - } - }, - "variables": { - "tenantId": "[tenant().tenantId]", - "openIdMetadataUrl": "[format('{0}{1}/v2.0/.well-known/openid-configuration', environment().authentication.loginEndpoint, variables('tenantId'))]", - "acrDomain": "[environment().suffixes.acrLoginServer]" - }, - "resources": { - "acrService": { - "existing": true, - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2025-04-01", - "name": "[parameters('acrName')]" - }, - "cosmosDb": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('cosmosDbName')]" - }, - "searchService": { - "existing": true, - "type": "Microsoft.Search/searchServices", - "apiVersion": "2025-05-01", - "name": "[parameters('searchServiceName')]" - }, - "documentIntelligence": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('documentIntelligenceServiceName')]" - }, - "appInsights": { - "existing": true, - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[parameters('appInsightsName')]" - }, - "webApp": { - "type": "Microsoft.Web/sites", - "apiVersion": "2022-03-01", - "name": "[toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "app,linux,container", - "properties": { - "serverFarmId": "[parameters('appServicePlanId')]", - "virtualNetworkSubnetId": "[if(not(equals(parameters('appServiceSubnetId'), '')), parameters('appServiceSubnetId'), null())]", - "publicNetworkAccess": "Enabled", - "vnetImagePullEnabled": "[if(parameters('enablePrivateNetworking'), true(), false())]", - "siteConfig": { - "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", - "acrUseManagedIdentityCreds": true, - "acrUserManagedIdentityID": "", - "alwaysOn": true, - "ftpsState": "Disabled", - "healthCheckPath": "/external/healthcheck", - "appSettings": "[flatten(createArray(createArray(createObject('name', 'AZURE_ENVIRONMENT', 'value', parameters('azurePlatform')), createObject('name', 'SCM_DO_BUILD_DURING_DEPLOYMENT', 'value', 'false'), createObject('name', 'AZURE_COSMOS_ENDPOINT', 'value', reference('cosmosDb').documentEndpoint), createObject('name', 'AZURE_COSMOS_AUTHENTICATION_TYPE', 'value', toLower(parameters('authenticationType')))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_COSMOS_KEY', 'value', listKeys('cosmosDb', '2023-04-15').primaryMasterKey)), createArray()), createArray(createObject('name', 'TENANT_ID', 'value', tenant().tenantId), createObject('name', 'CLIENT_ID', 'value', parameters('enterpriseAppClientId')), createObject('name', 'SECRET_KEY', 'value', if(not(empty(parameters('enterpriseAppClientSecret'))), parameters('enterpriseAppClientSecret'), format('@Microsoft.KeyVault(SecretUri={0}secrets/enterprise-app-client-secret)', parameters('keyVaultUri')))), createObject('name', 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri= parameters('keyVaultUri'))), createObject('name', 'DOCKER_REGISTRY_SERVER_URL', 'value', format('https://{0}{1}', parameters('acrName'), variables('acrDomain')))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'DOCKER_REGISTRY_SERVER_USERNAME', 'value', listCredentials('acrService', '2025-04-01').username)), createArray()), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'DOCKER_REGISTRY_SERVER_PASSWORD', 'value', listCredentials('acrService', '2025-04-01').passwords[0].value)), createArray()), createArray(createObject('name', 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS', 'value', tenant().tenantId), createObject('name', 'AZURE_OPENAI_RESOURCE_NAME', 'value', parameters('openAiServiceName')), createObject('name', 'AZURE_OPENAI_RESOURCE_GROUP_NAME', 'value', parameters('openAiResourceGroupName')), createObject('name', 'AZURE_OPENAI_URL', 'value', parameters('openAiEndpoint')), createObject('name', 'AZURE_SEARCH_SERVICE_NAME', 'value', parameters('searchServiceName'))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_SEARCH_API_KEY', 'value', listAdminKeys('searchService', '2025-05-01').primaryKey)), createArray()), createArray(createObject('name', 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT', 'value', reference('documentIntelligence').endpoint)), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_DOCUMENT_INTELLIGENCE_API_KEY', 'value', listKeys('documentIntelligence', '2025-06-01').key1)), createArray()), createArray(createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference('appInsights').InstrumentationKey), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference('appInsights').ConnectionString), createObject('name', 'APPINSIGHTS_PROFILERFEATURE_VERSION', 'value', '1.0.0'), createObject('name', 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION', 'value', '1.0.0'), createObject('name', 'APPLICATIONINSIGHTS_CONFIGURATION_CONTENT', 'value', ''), createObject('name', 'ApplicationInsightsAgent_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'DiagnosticServices_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'InstrumentationEngine_EXTENSION_VERSION', 'value', 'disabled'), createObject('name', 'SnapshotDebugger_EXTENSION_VERSION', 'value', 'disabled'), createObject('name', 'XDT_MicrosoftApplicationInsights_BaseExtensions', 'value', 'disabled'), createObject('name', 'XDT_MicrosoftApplicationInsights_Mode', 'value', 'recommended'), createObject('name', 'XDT_MicrosoftApplicationInsights_PreemptSdk', 'value', 'disabled')), if(equals(parameters('azurePlatform'), 'custom'), createArray(createObject('name', 'CUSTOM_GRAPH_URL_VALUE', 'value', coalesce(parameters('customGraphUrl'), '')), createObject('name', 'CUSTOM_IDENTITY_URL_VALUE', 'value', coalesce(parameters('customIdentityUrl'), '')), createObject('name', 'CUSTOM_RESOURCE_MANAGER_URL_VALUE', 'value', coalesce(parameters('customResourceManagerUrl'), '')), createObject('name', 'CUSTOM_BLOB_STORAGE_URL_VALUE', 'value', coalesce(parameters('customBlobStorageSuffix'), '')), createObject('name', 'CUSTOM_COGNITIVE_SERVICES_URL_VALUE', 'value', coalesce(parameters('customCognitiveServicesScope'), '')), createObject('name', 'CUSTOM_SEARCH_RESOURCE_MANAGER_URL_VALUE', 'value', coalesce(parameters('customSearchResourceUrl'), '')), createObject('name', 'KEY_VAULT_DOMAIN', 'value', environment().suffixes.keyvaultDns), createObject('name', 'CUSTOM_OIDC_METADATA_URL_VALUE', 'value', coalesce(variables('openIdMetadataUrl'), ''))), createArray())))]" - }, - "clientAffinityEnabled": false, - "httpsOnly": true - }, - "identity": { - "type": "SystemAssigned" - }, - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'web'))]", - "dependsOn": [ - "appInsights", - "cosmosDb", - "documentIntelligence" - ] - }, - "webAppLogging": { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2022-03-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))), 'logs')]", - "properties": { - "httpLogs": { - "fileSystem": { - "enabled": true, - "retentionInDays": 7, - "retentionInMb": 35 - } - } - }, - "dependsOn": [ - "webApp" - ] - }, - "webAppDiagnostics": { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference('diagnosticConfigs').outputs.webAppLogCategories.value]", - "metrics": "[reference('diagnosticConfigs').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "diagnosticConfigs", - "webApp" - ] - }, - "authSettings": { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2022-03-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))), 'authsettingsV2')]", - "properties": { - "globalValidation": { - "requireAuthentication": true, - "unauthenticatedClientAction": "RedirectToLoginPage", - "redirectToProvider": "azureActiveDirectory" - }, - "identityProviders": { - "azureActiveDirectory": { - "enabled": true, - "registration": { - "openIdIssuer": "[format('{0}{1}/', environment().authentication.loginEndpoint, tenant().tenantId)]", - "clientId": "[parameters('enterpriseAppClientId')]", - "clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET" - }, - "validation": { - "jwtClaimChecks": {}, - "allowedAudiences": [ - "[format('api://{0}', parameters('enterpriseAppClientId'))]", - "[parameters('enterpriseAppClientId')]" - ] - }, - "isAutoProvisioned": false - } - }, - "login": { - "routes": { - "logoutEndpoint": "/.auth/logout" - }, - "tokenStore": { - "enabled": true, - "tokenRefreshExtensionHours": 72, - "fileSystem": { - "directory": "/home/data/.auth" - } - }, - "preserveUrlFragmentsForLogins": false, - "allowedExternalRedirectUrls": [], - "cookieExpiration": { - "convention": "FixedTime", - "timeToExpiration": "08:00:00" - }, - "nonce": { - "validateNonce": true, - "nonceExpirationInterval": "00:05:00" - } - }, - "httpSettings": { - "requireHttps": true, - "routes": { - "apiPrefix": "/.auth" - }, - "forwardProxy": { - "convention": "NoProxy" - } - } - }, - "dependsOn": [ - "webApp" - ] - }, - "diagnosticConfigs": { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - }, - "outputs": { - "name": { - "type": "string", - "value": "[toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))]" - }, - "defaultHostName": { - "type": "string", - "value": "[reference('webApp').defaultHostName]" - }, - "resourceId": { - "type": "string", - "value": "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]" - } - } - } - }, - "dependsOn": [ - "acr", - "applicationInsights", - "appServicePlan", - "cosmosDB", - "docIntel", - "keyVault", - "logAnalytics", - "openAI", - "rg", - "searchService", - "virtualNetwork" - ] - }, - "contentSafety": { - "condition": "[parameters('deployContentSafety')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "contentSafety", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "7932852522879306913" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "ContentSafety", - "sku": { - "name": "S0" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "contentSafetyName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]" - }, - "contentSafetyEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))), '2025-06-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "redisCache": { - "condition": "[parameters('deployRedisCache')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "redisCache", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "17045519057214581310" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Cache/redis", - "apiVersion": "2024-11-01", - "name": "[toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "properties": { - "sku": { - "name": "Standard", - "family": "C", - "capacity": 0 - }, - "enableNonSslPort": false, - "minimumTlsVersion": "1.2", - "redisConfiguration": { - "aad-enabled": "true" - } - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "redisCacheName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))]" - }, - "redisCacheHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))), '2024-11-01').hostName]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "speechService": { - "condition": "[parameters('deploySpeechService')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "speechService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15886240553047875398" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2024-10-01", - "name": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "SpeechServices", - "sku": { - "name": "S0" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "speechServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]" - }, - "speechServiceEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))), '2024-10-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "videoIndexerService": { - "condition": "[parameters('deployVideoIndexerService')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "videoIndexerService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "storageAccount": { - "value": "[reference('storageAccount').outputs.name.value]" - }, - "openAiServiceName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "4707936164725245117" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "storageAccount": { - "type": "string" - }, - "openAiServiceName": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.VideoIndexer/accounts", - "apiVersion": "2025-04-01", - "name": "[toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "storageServices": { - "resourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccount'))]" - }, - "openAiServices": { - "resourceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAiServiceName'))]" - } - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.limitedLogCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "videoIndexerServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))]" - }, - "videoIndexerAccountId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))), '2025-04-01').accountId]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "openAI", - "rg", - "storageAccount" - ] - }, - "setPermissions": { - "condition": "[parameters('configureApplicationPermissions')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "setPermissions", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "webAppName": { - "value": "[reference('appService').outputs.name.value]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "enterpriseAppServicePrincipalId": { - "value": "[parameters('enterpriseAppServicePrincipalId')]" - }, - "keyVaultName": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "cosmosDBName": { - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "acrName": { - "value": "[reference('acr').outputs.acrName.value]" - }, - "openAIName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "openAIResourceGroupName": { - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "openAISubscriptionId": { - "value": "[reference('openAI').outputs.openAISubscriptionId.value]" - }, - "docIntelName": { - "value": "[reference('docIntel').outputs.documentIntelligenceServiceName.value]" - }, - "storageAccountName": { - "value": "[reference('storageAccount').outputs.name.value]" - }, - "searchServiceName": { - "value": "[reference('searchService').outputs.searchServiceName.value]" - }, - "speechServiceName": "[if(parameters('deploySpeechService'), createObject('value', reference('speechService').outputs.speechServiceName.value), createObject('value', ''))]", - "redisCacheName": "[if(parameters('deployRedisCache'), createObject('value', reference('redisCache').outputs.redisCacheName.value), createObject('value', ''))]", - "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]", - "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerServiceName.value), createObject('value', ''))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "4113398169754187856" - } - }, - "parameters": { - "webAppName": { - "type": "string" - }, - "authenticationType": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "enterpriseAppServicePrincipalId": { - "type": "string" - }, - "cosmosDBName": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "openAIName": { - "type": "string" - }, - "openAIResourceGroupName": { - "type": "string" - }, - "openAISubscriptionId": { - "type": "string" - }, - "docIntelName": { - "type": "string" - }, - "storageAccountName": { - "type": "string" - }, - "speechServiceName": { - "type": "string" - }, - "searchServiceName": { - "type": "string" - }, - "redisCacheName": { - "type": "string" - }, - "contentSafetyName": { - "type": "string" - }, - "videoIndexerName": { - "type": "string" - } - }, - "variables": { - "useExternalOpenAIResource": "[and(and(not(equals(parameters('openAIName'), '')), not(empty(parameters('openAIResourceGroupName')))), not(empty(parameters('openAISubscriptionId'))))]" - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'kv-secrets-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]", - "name": "[guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'cosmos-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('cosmosDBName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'cosmos-data-contributor'))]", - "properties": { - "roleDefinitionId": "[format('{0}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002', resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'acr-pull-role')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(and(equals(parameters('authenticationType'), 'managed_identity'), not(equals(parameters('openAIName'), ''))), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'openai-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(and(equals(parameters('authenticationType'), 'managed_identity'), not(equals(parameters('openAIName'), ''))), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'enterpriseApp-CognitiveServicesOpenAIUserRole')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[parameters('enterpriseAppServicePrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'doc-intel-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'storage-blob-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('speechServiceName'), '')), equals(parameters('authenticationType'), 'managed_identity'))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'speech-service-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]", - "name": "[guid(resourceId('Microsoft.Search/searchServices', parameters('searchServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'search-index-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]", - "name": "[guid(resourceId('Microsoft.Search/searchServices', parameters('searchServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'search-service-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('contentSafetyName'), '')), equals(parameters('authenticationType'), 'managed_identity'))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'content-safety-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[not(equals(parameters('videoIndexerName'), ''))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-storage-blob-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-cog-services-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-cog-services-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[not(equals(parameters('redisCacheName'), ''))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Cache/redis', parameters('redisCacheName'))]", - "name": "[guid(resourceId('Microsoft.Cache/redis', parameters('redisCacheName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'redis-cache-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'e0f68234-74aa-48ed-b826-c38b57376e17')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[variables('useExternalOpenAIResource')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAIExternalPermissions", - "subscriptionId": "[parameters('openAISubscriptionId')]", - "resourceGroup": "[parameters('openAIResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "openAIName": { - "value": "[parameters('openAIName')]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "webAppPrincipalId": { - "value": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]" - }, - "enterpriseAppServicePrincipalId": { - "value": "[parameters('enterpriseAppServicePrincipalId')]" - }, - "videoIndexerPrincipalId": "[if(not(equals(parameters('videoIndexerName'), '')), createObject('value', reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId), createObject('value', ''))]", - "videoIndexerName": { - "value": "[parameters('videoIndexerName')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14586417486337251686" - } - }, - "parameters": { - "openAIName": { - "type": "string" - }, - "authenticationType": { - "type": "string" - }, - "webAppPrincipalId": { - "type": "string" - }, - "enterpriseAppServicePrincipalId": { - "type": "string" - }, - "videoIndexerPrincipalId": { - "type": "string", - "defaultValue": "" - }, - "videoIndexerName": { - "type": "string", - "defaultValue": "" - } - }, - "resources": [ - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('webAppPrincipalId'), 'openai-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[parameters('webAppPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('enterpriseAppServicePrincipalId'), 'enterpriseApp-CognitiveServicesOpenAIUserRole')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[parameters('enterpriseAppServicePrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(empty(parameters('videoIndexerPrincipalId'))))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('videoIndexerPrincipalId'), 'video-indexer-cog-services-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68')]", - "principalId": "[parameters('videoIndexerPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(empty(parameters('videoIndexerPrincipalId'))))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('videoIndexerPrincipalId'), 'video-indexer-cog-services-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[parameters('videoIndexerPrincipalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - } - } - ] - } - }, - "dependsOn": [ - "acr", - "appService", - "contentSafety", - "cosmosDB", - "docIntel", - "keyVault", - "openAI", - "redisCache", - "rg", - "searchService", - "speechService", - "storageAccount", - "videoIndexerService" - ] - }, - "privateNetworking": { - "condition": "[parameters('enablePrivateNetworking')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "privateNetworking", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "virtualNetworkId": "[if(parameters('enablePrivateNetworking'), if(variables('useExistingVirtualNetwork'), createObject('value', variables('resolvedExistingVirtualNetworkId')), createObject('value', reference('virtualNetwork').outputs.vNetId.value)), createObject('value', ''))]", - "privateEndpointSubnetId": "[if(parameters('enablePrivateNetworking'), if(variables('useExistingVirtualNetwork'), createObject('value', parameters('existingPrivateEndpointSubnetId')), createObject('value', reference('virtualNetwork').outputs.privateNetworkSubnetId.value)), createObject('value', ''))]", - "privateDnsZoneConfigs": { - "value": "[parameters('privateDnsZoneConfigs')]" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "keyVaultName": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "cosmosDBName": { - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "acrName": { - "value": "[reference('acr').outputs.acrName.value]" - }, - "searchServiceName": { - "value": "[reference('searchService').outputs.searchServiceName.value]" - }, - "docIntelName": { - "value": "[reference('docIntel').outputs.documentIntelligenceServiceName.value]" - }, - "storageAccountName": { - "value": "[reference('storageAccount').outputs.name.value]" - }, - "openAIName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "openAIResourceGroupName": { - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "openAISubscriptionId": { - "value": "[reference('openAI').outputs.openAISubscriptionId.value]" - }, - "webAppName": { - "value": "[reference('appService').outputs.name.value]" - }, - "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]", - "speechServiceName": "[if(parameters('deploySpeechService'), createObject('value', reference('speechService').outputs.speechServiceName.value), createObject('value', ''))]", - "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerServiceName.value), createObject('value', ''))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "5473171678692396413" - } - }, - "parameters": { - "virtualNetworkId": { - "type": "string" - }, - "privateEndpointSubnetId": { - "type": "string" - }, - "privateDnsZoneConfigs": { - "type": "object", - "defaultValue": {} - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "keyVaultName": { - "type": "string" - }, - "cosmosDBName": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "searchServiceName": { - "type": "string" - }, - "docIntelName": { - "type": "string" - }, - "storageAccountName": { - "type": "string" - }, - "openAIName": { - "type": "string" - }, - "openAIResourceGroupName": { - "type": "string" - }, - "openAISubscriptionId": { - "type": "string" - }, - "webAppName": { - "type": "string" - }, - "contentSafetyName": { - "type": "string" - }, - "speechServiceName": { - "type": "string" - }, - "videoIndexerName": { - "type": "string" - } - }, - "variables": { - "$fxv#0": { - "azurecloud": { - "aisearch": "privatelink.search.windows.net", - "blobStorage": "privatelink.blob.core.windows.net", - "cognitiveServices": "privatelink.cognitiveservices.azure.com", - "containerRegistry": "privatelink.azurecr.io", - "cosmosDb": "privatelink.documents.azure.com", - "keyVault": "privatelink.vaultcore.azure.net", - "openAi": "privatelink.openai.azure.com", - "webSites": "privatelink.azurewebsites.net" - }, - "azureusgovernment": { - "aisearch": "privatelink.search.azure.us", - "blobStorage": "privatelink.blob.core.usgovcloudapi.net", - "cognitiveServices": "privatelink.cognitiveservices.azure.us", - "containerRegistry": "privatelink.azurecr.us", - "cosmosDb": "privatelink.documents.azure.us", - "keyVault": "privatelink.vaultcore.azure.us", - "openAi": "privatelink.openai.azure.us", - "webSites": "privatelink.azurewebsites.us" - } - }, - "useExternalOpenAIResource": "[and(and(not(equals(parameters('openAIName'), '')), not(empty(parameters('openAIResourceGroupName')))), not(empty(parameters('openAISubscriptionId'))))]", - "cloudName": "[toLower(environment().name)]", - "privateDnsZoneData": "[variables('$fxv#0')]", - "aiSearchDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].aisearch]", - "blobStorageDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].blobStorage]", - "cognitiveServicesDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].cognitiveServices]", - "containerRegistryDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].containerRegistry]", - "cosmosDbDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].cosmosDb]", - "keyVaultDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].keyVault]", - "openAiDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].openAi]", - "webSitesDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].webSites]", - "defaultPrivateDnsZoneConfig": { - "zoneResourceId": "", - "createVNetLink": true - }, - "keyVaultPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'keyVault'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').keyVault), variables('defaultPrivateDnsZoneConfig'))]", - "cosmosDbPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'cosmosDb'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').cosmosDb), variables('defaultPrivateDnsZoneConfig'))]", - "containerRegistryPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'containerRegistry'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').containerRegistry), variables('defaultPrivateDnsZoneConfig'))]", - "aiSearchPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'aiSearch'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').aiSearch), variables('defaultPrivateDnsZoneConfig'))]", - "blobStoragePrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'blobStorage'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').blobStorage), variables('defaultPrivateDnsZoneConfig'))]", - "cognitiveServicesPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'cognitiveServices'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').cognitiveServices), variables('defaultPrivateDnsZoneConfig'))]", - "openAiPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'openAi'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').openAi), variables('defaultPrivateDnsZoneConfig'))]", - "webSitesPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'webSites'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').webSites), variables('defaultPrivateDnsZoneConfig'))]" - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyVaultDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('keyVaultDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "kv" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('keyVaultPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('keyVaultPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyVaultPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "kv" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "vault" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'keyVaultDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "cosmosDbDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('cosmosDbDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "cosmosDb" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('cosmosDbPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('cosmosDbPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "cosmosDbPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "cosmosDb" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "sql" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'cosmosDbDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'cosmosDbDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "acrDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('containerRegistryDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "acr" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('containerRegistryPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('containerRegistryPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "acrPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "acr" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "registry" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'acrDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'acrDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "searchServiceDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('aiSearchDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "searchService" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('aiSearchPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('aiSearchPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "searchServicePE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "searchService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "searchService" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'searchServiceDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'searchServiceDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "docIntelDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('cognitiveServicesDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "docIntelService" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('cognitiveServicesPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('cognitiveServicesPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "docIntelPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "docIntelService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storageAccountDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('blobStorageDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "storage" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('blobStoragePrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('blobStoragePrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storageAccountPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "storageAccount" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "blob" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'storageAccountDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'storageAccountDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('openAIName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAiDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('openAiDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "openAiService" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('openAiPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('openAiPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "condition": "[not(equals(parameters('openAIName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAiPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "openAiService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": "[if(variables('useExternalOpenAIResource'), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('openAISubscriptionId'), parameters('openAIResourceGroupName')), 'Microsoft.CognitiveServices/accounts', parameters('openAIName'))), createObject('value', resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))))]", - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'openAiDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'openAiDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "webAppDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('webSitesDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "webApp" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('webSitesPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('webSitesPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "webAppPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "webApp" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.Web/sites', parameters('webAppName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "sites" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'webAppDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'webAppDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('contentSafetyName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "contentSafetyPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "contentSafety" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('speechServiceName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "speechServicePE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "speechService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('videoIndexerName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "videoIndexerPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "videoIndexerService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - } - ] - } - }, - "dependsOn": [ - "acr", - "appService", - "contentSafety", - "cosmosDB", - "docIntel", - "keyVault", - "openAI", - "rg", - "searchService", - "speechService", - "storageAccount", - "videoIndexerService", - "virtualNetwork" - ] - } - }, - "outputs": { - "var_acrName": { - "type": "string", - "value": "[toLower(format('{0}{1}acr', parameters('appName'), parameters('environment')))]" - }, - "var_authenticationType": { - "type": "string", - "value": "[toLower(parameters('authenticationType'))]" - }, - "var_blobStorageEndpoint": { - "type": "string", - "value": "[reference('storageAccount').outputs.endpoint.value]" - }, - "var_configureApplication": { - "type": "bool", - "value": "[parameters('configureApplicationPermissions')]" - }, - "var_contentSafetyEndpoint": { - "type": "string", - "value": "[if(parameters('deployContentSafety'), reference('contentSafety').outputs.contentSafetyEndpoint.value, '')]" - }, - "var_cosmosDb_accountName": { - "type": "string", - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "var_cosmosDb_uri": { - "type": "string", - "value": "[reference('cosmosDB').outputs.cosmosDbUri.value]" - }, - "var_deploymentLocation": { - "type": "string", - "value": "[reference('rg', '2022-09-01', 'full').location]" - }, - "var_documentIntelligenceServiceEndpoint": { - "type": "string", - "value": "[reference('docIntel').outputs.documentIntelligenceServiceEndpoint.value]" - }, - "var_keyVaultName": { - "type": "string", - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "var_keyVaultUri": { - "type": "string", - "value": "[reference('keyVault').outputs.keyVaultUri.value]" - }, - "var_openAIEndpoint": { - "type": "string", - "value": "[reference('openAI').outputs.openAIEndpoint.value]" - }, - "var_openAIGPTModels": { - "type": "array", - "value": "[variables('resolvedGptModels')]" - }, - "var_openAIResourceGroup": { - "type": "string", - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "var_openAIEmbeddingModels": { - "type": "array", - "value": "[variables('resolvedEmbeddingModels')]" - }, - "var_openAISubscriptionId": { - "type": "string", - "value": "[reference('openAI').outputs.openAISubscriptionId.value]" - }, - "var_redisCacheHostName": { - "type": "string", - "value": "[if(parameters('deployRedisCache'), reference('redisCache').outputs.redisCacheHostName.value, '')]" - }, - "var_rgName": { - "type": "string", - "value": "[variables('rgName')]" - }, - "var_searchServiceEndpoint": { - "type": "string", - "value": "[reference('searchService').outputs.searchServiceEndpoint.value]" - }, - "var_speechServiceEndpoint": { - "type": "string", - "value": "[if(parameters('deploySpeechService'), reference('speechService').outputs.speechServiceEndpoint.value, '')]" - }, - "var_subscriptionId": { - "type": "string", - "value": "[subscription().subscriptionId]" - }, - "var_videoIndexerAccountId": { - "type": "string", - "value": "[if(parameters('deployVideoIndexerService'), reference('videoIndexerService').outputs.videoIndexerAccountId.value, '')]" - }, - "var_videoIndexerName": { - "type": "string", - "value": "[if(parameters('deployVideoIndexerService'), reference('videoIndexerService').outputs.videoIndexerServiceName.value, '')]" - }, - "var_containerRegistry": { - "type": "string", - "value": "[variables('containerRegistry')]" - }, - "var_imageName": { - "type": "string", - "value": "[if(contains(parameters('imageName'), ':'), split(parameters('imageName'), ':')[0], parameters('imageName'))]" - }, - "var_imageTag": { - "type": "string", - "value": "[if(contains(parameters('imageName'), ':'), split(parameters('imageName'), ':')[1], 'latest')]" - }, - "var_webService": { - "type": "string", - "value": "[reference('appService').outputs.name.value]" - }, - "var_enablePrivateNetworking": { - "type": "bool", - "value": "[parameters('enablePrivateNetworking')]" - } - } -} -2026/03/25 11:26:51 main.go:54: Retry: =====> Try=1 for GET https://graph.microsoft.com/v1.0/me -2026/03/25 11:26:51 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://graph.microsoft.com/v1.0/me - Authorization: REDACTED - User-Agent: azsdk-go-graph/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: REDACTED - -2026/03/25 11:26:51 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/200.0306ms, OpTime=200.0306ms) -- RESPONSE RECEIVED - GET https://graph.microsoft.com/v1.0/me - Authorization: REDACTED - User-Agent: azsdk-go-graph/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: REDACTED - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Client-Request-Id: REDACTED - Content-Type: application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8 - Date: Wed, 25 Mar 2026 15:26:50 GMT - Odata-Version: REDACTED - Request-Id: 1734b612-8dc5-46a6-81d5-1319062cb67c - Strict-Transport-Security: REDACTED - Vary: REDACTED - X-Cache: REDACTED - X-Ms-Ags-Diagnostic: REDACTED - X-Ms-Resource-Unit: REDACTED - -2026/03/25 11:26:51 main.go:54: Retry: response 200 -2026/03/25 11:26:51 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:26:51 middleware.go:100: running middleware 'debug' -2026/03/25 11:26:51 middleware.go:100: running middleware 'ux' -2026/03/25 11:26:51 middleware.go:100: running middleware 'telemetry' -2026/03/25 11:26:51 telemetry.go:68: TraceID: dd6b9467838cd44356a50058eb0073ab -2026/03/25 11:26:51 middleware.go:100: running middleware 'error' -2026/03/25 11:26:51 middleware.go:100: running middleware 'hooks' - -Packaging services (azd package) - -2026/03/25 11:26:51 hooks.go:130: service 'web' does not require any command hooks. -2026/03/25 11:26:51 middleware.go:100: running middleware 'extensions' -2026/03/25 11:26:51 service_manager.go:677: Attempting to resolve language 'python' for service 'web' -2026/03/25 11:26:51 service_manager.go:696: Successfully resolved language 'python' for service 'web' -2026/03/25 11:26:51 service_manager.go:677: Attempting to resolve language 'python' for service 'web' -2026/03/25 11:26:51 service_manager.go:696: Successfully resolved language 'python' for service 'web' -2026/03/25 11:26:51 command_runner.go:325: Run exec: 'python --version' , exit code: 0 --------------------------------------stdout------------------------------------------- -Python 3.12.10 -2026/03/25 11:26:51 python.go:49: python version: Python 3.12.10 -2026/03/25 11:26:51 service_manager.go:677: Attempting to resolve language 'python' for service 'web' -2026/03/25 11:26:51 service_manager.go:696: Successfully resolved language 'python' for service 'web' -Packaging service web -Packaging service web (Compressing deployment artifacts) - (Γ£ô) Done: Packaging service web - - Build Output: C:\repos\simplechat\application\single_app - - Package Output: C:\Users\PAULLI~1\AppData\Local\Temp\simplechat-web-azddeploy-1774452411.zip - -2026/03/25 11:26:52 middleware.go:100: running middleware 'debug' -2026/03/25 11:26:52 middleware.go:100: running middleware 'ux' -2026/03/25 11:26:52 middleware.go:100: running middleware 'telemetry' -2026/03/25 11:26:52 telemetry.go:68: TraceID: dd6b9467838cd44356a50058eb0073ab -2026/03/25 11:26:52 middleware.go:100: running middleware 'error' -2026/03/25 11:26:52 middleware.go:100: running middleware 'loginGuard' -2026/03/25 11:26:52 middleware.go:100: running middleware 'hooks' -2026/03/25 11:26:52 hooks.go:130: service 'web' does not require any command hooks. -2026/03/25 11:26:52 hooks_runner.go:195: Executing script 'C:\Users\PAULLI~1\AppData\Local\Temp\azd-preprovision-3334517763.ps1' -2026/03/25 11:26:55 command_runner.go:325: Run exec: 'pwsh C:\Users\PAULLI~1\AppData\Local\Temp\azd-preprovision-3334517763.ps1' , exit code: 0 -Additional env: - var_authenticationType= - var_containerRegistry= - var_subscriptionId= - var_cosmosDb_uri= - AZURE_SUBSCRIPTION_ID= - var_imageName= - var_videoIndexerAccountId= - AZURE_ENV_NAME= - var_openAIResourceGroup= - var_documentIntelligenceServiceEndpoint= - var_videoIndexerName= - var_speechServiceEndpoint= - var_configureApplication= - var_openAIEmbeddingModels= - var_enablePrivateNetworking= - var_deploymentLocation= - var_imageTag= - var_webService= - var_contentSafetyEndpoint= - var_cosmosDb_accountName= - var_openAIEndpoint= - AZURE_LOCATION= - var_keyVaultName= - var_keyVaultUri= - var_rgName= - var_openAIGPTModels= - var_acrName= - var_blobStorageEndpoint= - var_openAISubscriptionId= - var_searchServiceEndpoint= - var_redisCacheHostName= --------------------------------------stdout------------------------------------------- -======================================= -PRE-PROVISION: Validating prerequisites -======================================= -2026/03/25 11:26:55 middleware.go:100: running middleware 'extensions' - -Provisioning Azure resources (azd provision) -Provisioning Azure resources can take some time. - -2026/03/25 11:26:55 service_manager.go:677: Attempting to resolve language 'python' for service 'web' -2026/03/25 11:26:55 service_manager.go:696: Successfully resolved language 'python' for service 'web' -2026/03/25 11:26:55 service_manager.go:677: Attempting to resolve language 'python' for service 'web' -2026/03/25 11:26:55 service_manager.go:696: Successfully resolved language 'python' for service 'web' -2026/03/25 11:26:55 ensure.go:52: Skipping install check for 'Python CLI'. It was previously confirmed. -2026/03/25 11:26:55 importer.go:324: using infrastructure from C:\repos\simplechat\deployers\bicep directory -Initialize bicep provider -2026/03/25 11:27:00 command_runner.go:325: Run exec: 'C:\Users\paullizer\.azd\bin\bicep.exe build C:\repos\simplechat\deployers\bicep\main.bicep --stdout' , exit code: 0 --------------------------------------stdout------------------------------------------- -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "10060260231818509949" - } - }, - "parameters": { - "location": { - "type": "string", - "minLength": 1, - "metadata": { - "description": "The Azure region where resources will be deployed. \n- Region must align to the target cloud environment" - } - }, - "cloudEnvironment": { - "type": "string", - "defaultValue": "[if(equals(environment().name, 'AzureCloud'), 'public', if(equals(environment().name, 'AzureUSGovernment'), 'usgovernment', 'custom'))]", - "allowedValues": [ - "AzureCloud", - "AzureUSGovernment", - "public", - "usgovernment", - "custom" - ], - "metadata": { - "description": "The target Azure Cloud environment.\n- Accepted values are: AzureCloud, AzureUSGovernment, public, usgovernment, custom\n- Default is based on the ARM cloud name" - } - }, - "appName": { - "type": "string", - "minLength": 3, - "maxLength": 12, - "metadata": { - "description": "The name of the application to be deployed. \n- Name may only contain letters and numbers\n- Between 3 and 12 characters in length \n- No spaces or special characters" - } - }, - "environment": { - "type": "string", - "minLength": 2, - "maxLength": 10, - "metadata": { - "description": "The dev/qa/prod environment or as named in your environment. This will be used to create resource group names and tags.\n- Must be between 2 and 10 characters in length\n- No spaces or special characters" - } - }, - "azdEnvironmentName": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "metadata": { - "description": "Name of the AZD environment" - } - }, - "imageName": { - "type": "string", - "defaultValue": "simplechat:latest", - "metadata": { - "description": "The name of the container image to deploy to the web app.\n- should be in the format :" - } - }, - "enterpriseAppClientId": { - "type": "string", - "metadata": { - "description": "Azure AD Application Client ID for enterprise authentication.\n- Should be the client ID of the registered Azure AD application" - } - }, - "enterpriseAppServicePrincipalId": { - "type": "string", - "metadata": { - "description": "Azure AD Application Service Principal Id for the enterprise application.\n- Should be the Service Principal ID of the registered Azure AD application" - } - }, - "enterpriseAppClientSecret": { - "type": "securestring", - "metadata": { - "description": "Azure AD Application Client Secret for enterprise authentication.\n- Required if enableEnterpriseApp is true\n- Should be created in Azure AD App Registration and passed via environment variable\n- Will be stored securely in Azure Key Vault during deployment" - } - }, - "authenticationType": { - "type": "string", - "allowedValues": [ - "key", - "managed_identity" - ], - "metadata": { - "description": "Authentication type for resources that support Managed Identity or Key authentication.\n- Key: Use access keys for authentication (application keys will be stored in Key Vault)\n- managed_identity: Use Managed Identity for authentication" - } - }, - "configureApplicationPermissions": { - "type": "bool", - "metadata": { - "description": "Configure permissions (based on authenticationType) for the deployed web application to access required resources.\n" - } - }, - "specialTags": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional object containing additional tags to apply to all resources." - } - }, - "enableDiagLogging": { - "type": "bool", - "metadata": { - "description": "Enable diagnostic logging for resources deployed in the resource group. \n- All content will be sent to the deployed Log Analytics workspace\n- Default is false" - } - }, - "enablePrivateNetworking": { - "type": "bool", - "metadata": { - "description": "Enable private endpoints and virtual network integration for deployed resources. \n- Default is false" - } - }, - "existingVirtualNetworkId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing virtual network resource ID to reuse when private networking is enabled.\n- May reference a virtual network in the same or another resource group or subscription\n- Leave blank to create a new virtual network" - } - }, - "existingAppServiceSubnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing subnet resource ID to use for App Service VNet integration.\n- May reference a subnet in the same or another resource group or subscription\n- Required when reusing an existing virtual network because subnets are not created in external virtual networks" - } - }, - "existingPrivateEndpointSubnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing subnet resource ID to use for private endpoints.\n- May reference a subnet in the same or another resource group or subscription\n- Required when reusing an existing virtual network because subnets are not created in external virtual networks" - } - }, - "privateDnsZoneConfigs": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional per-zone private DNS configuration for private networking.\n- Leave empty to create all private DNS zones locally and create VNet links automatically\n- For each supported key, provide:\n - zoneResourceId: Optional existing private DNS zone resource ID to reuse\n - createVNetLink: Optional bool, defaults to true. Set to false if the customer manages the VNet link separately\n- Supported keys: keyVault, cosmosDb, containerRegistry, aiSearch, blobStorage, cognitiveServices, openAi, webSites" - } - }, - "existingOpenAIEndpoint": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing Azure OpenAI or Azure AI Foundry OpenAI-compatible endpoint.\n- Leave blank to deploy a new Azure OpenAI resource\n- Public Azure AI Foundry project endpoints are supported for application configuration, but do not support private endpoint automation" - } - }, - "existingOpenAIResourceName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional existing Azure OpenAI resource name.\n- Provide this when reusing a standard Azure OpenAI resource and you want managed identity permissions or private endpoint integration configured automatically" - } - }, - "existingOpenAIResourceGroup": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional resource group for an existing Azure OpenAI resource.\n- Used when reusing a standard Azure OpenAI resource across resource groups or subscriptions" - } - }, - "existingOpenAISubscriptionId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional subscription ID for an existing Azure OpenAI resource.\n- Used when reusing a standard Azure OpenAI resource across subscriptions" - } - }, - "openAIDeploymentType": { - "type": "string", - "allowedValues": [ - "Standard", - "DatazoneStandard", - "GlobalStandard" - ], - "metadata": { - "description": "Azure OpenAI deployment type used for the default GPT and embedding model deployments.\n- Azure Commercial options: Standard, DatazoneStandard, GlobalStandard\n- Azure Government default model deployments use Standard regardless of this selection\n- Ignored when you provide custom gptModels or embeddingModels arrays" - } - }, - "customBlobStorageSuffix": { - "type": "string", - "defaultValue": "[format('blob.{0}', environment().suffixes.storage)]", - "metadata": { - "description": "Custom blob storage URL suffix, e.g. blob.core.usgovcloudapi.net" - } - }, - "customGraphUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Graph API URL, e.g. https://graph.microsoft.us" - } - }, - "customIdentityUrl": { - "type": "string", - "defaultValue": "[environment().authentication.loginEndpoint]", - "metadata": { - "description": "Custom Identity URL, e.g. https://login.microsoftonline.us/" - } - }, - "customResourceManagerUrl": { - "type": "string", - "defaultValue": "[environment().resourceManager]", - "metadata": { - "description": "Custom Resource Manager URL, e.g. https://management.usgovcloudapi.net" - } - }, - "customCognitiveServicesScope": { - "type": "string", - "defaultValue": "https://cognitiveservices.azure.com/.default", - "metadata": { - "description": "Custom Cognitive Services scope ex: https://cognitiveservices.azure.com/.default" - } - }, - "customSearchResourceUrl": { - "type": "string", - "defaultValue": "https://search.azure.com", - "metadata": { - "description": "Custom search resource URL for token audience, e.g. https://search.azure.us" - } - }, - "gptModels": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Array of GPT model names to deploy to the OpenAI resource." - } - }, - "embeddingModels": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Array of embedding model names to deploy to the OpenAI resource." - } - }, - "allowedIpAddresses": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Comma separated list of IP addresses or ranges to allow access to resources when private networking is enabled.\nLeave blank if not using private networking.\n- Format for single IP: 'x.x.x.x'\n- Format for range: 'x.x.x.x/y'\n- Example: 1.2.3.4, 2.3.4.5/32\n" - } - }, - "deployContentSafety": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Content Safety service and related resources.\n- Default is false" - } - }, - "deployRedisCache": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Azure Cache for Redis and related resources.\n- Default is false" - } - }, - "deploySpeechService": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Azure Speech service and related resources.\n- Default is false" - } - }, - "deployVideoIndexerService": { - "type": "bool", - "metadata": { - "description": "Enable deployment of Azure Video Indexer service and related resources.\n- Default is false" - } - } - }, - "variables": { - "copy": [ - { - "name": "allowedIpAddressesArray", - "count": "[length(variables('allowedIpAddressesSplit'))]", - "input": "[trim(variables('allowedIpAddressesSplit')[copyIndex('allowedIpAddressesArray')])]" - }, - { - "name": "cosmosDbIpRules", - "count": "[length(variables('allowedIpsForCosmos'))]", - "input": { - "ipAddressOrRange": "[variables('allowedIpsForCosmos')[copyIndex('cosmosDbIpRules')]]" - } - }, - { - "name": "acrIpRules", - "count": "[length(variables('allowedIpAddressesArray'))]", - "input": { - "action": "Allow", - "value": "[variables('allowedIpAddressesArray')[copyIndex('acrIpRules')]]" - } - } - ], - "scCloudEnvironment": "[if(equals(parameters('cloudEnvironment'), 'AzureCloud'), 'public', if(equals(parameters('cloudEnvironment'), 'AzureUSGovernment'), 'usgovernment', parameters('cloudEnvironment')))]", - "allowedIpAddressesSplit": "[if(empty(parameters('allowedIpAddresses')), createArray(), split(parameters('allowedIpAddresses'), ','))]", - "rgName": "[format('{0}-{1}-rg', parameters('appName'), parameters('environment'))]", - "requiredTags": { - "application": "[parameters('appName')]", - "environment": "[parameters('environment')]", - "azd-env-name": "[parameters('azdEnvironmentName')]" - }, - "tags": "[union(variables('requiredTags'), parameters('specialTags'))]", - "isPublicCloud": "[equals(variables('scCloudEnvironment'), 'public')]", - "isUsGovernmentCloud": "[equals(variables('scCloudEnvironment'), 'usgovernment')]", - "acrCloudSuffix": "[if(variables('isPublicCloud'), '.azurecr.io', '.azurecr.us')]", - "acrName": "[toLower(format('{0}{1}acr', parameters('appName'), parameters('environment')))]", - "containerRegistry": "[format('{0}{1}', variables('acrName'), variables('acrCloudSuffix'))]", - "containerImageName": "[format('{0}/{1}', variables('containerRegistry'), parameters('imageName'))]", - "vNetName": "[format('{0}-{1}-vnet', parameters('appName'), parameters('environment'))]", - "normalizedLocation": "[toLower(replace(parameters('location'), ' ', ''))]", - "resolvedOpenAIDeploymentType": "[if(variables('isUsGovernmentCloud'), 'Standard', parameters('openAIDeploymentType'))]", - "defaultGptModels": [ - { - "modelName": "gpt-4o", - "modelVersion": "[if(variables('isUsGovernmentCloud'), '2024-05-13', '2024-11-20')]", - "skuName": "[variables('resolvedOpenAIDeploymentType')]", - "skuCapacity": 100 - } - ], - "defaultEmbeddingModels": "[if(variables('isUsGovernmentCloud'), createArray(createObject('modelName', if(equals(variables('normalizedLocation'), 'usgovvirginia'), 'text-embedding-ada-002', 'text-embedding-3-small'), 'modelVersion', if(equals(variables('normalizedLocation'), 'usgovvirginia'), '2', '1'), 'skuName', variables('resolvedOpenAIDeploymentType'), 'skuCapacity', 100)), createArray(createObject('modelName', 'text-embedding-3-small', 'modelVersion', '1', 'skuName', variables('resolvedOpenAIDeploymentType'), 'skuCapacity', 100)))]", - "resolvedGptModels": "[if(empty(parameters('gptModels')), variables('defaultGptModels'), parameters('gptModels'))]", - "resolvedEmbeddingModels": "[if(empty(parameters('embeddingModels')), variables('defaultEmbeddingModels'), parameters('embeddingModels'))]", - "hasExistingAppServiceSubnetId": "[not(empty(parameters('existingAppServiceSubnetId')))]", - "hasExistingPrivateEndpointSubnetId": "[not(empty(parameters('existingPrivateEndpointSubnetId')))]", - "inferredVirtualNetworkId": "[if(variables('hasExistingAppServiceSubnetId'), split(parameters('existingAppServiceSubnetId'), '/subnets/')[0], if(variables('hasExistingPrivateEndpointSubnetId'), split(parameters('existingPrivateEndpointSubnetId'), '/subnets/')[0], ''))]", - "resolvedExistingVirtualNetworkId": "[if(not(empty(parameters('existingVirtualNetworkId'))), parameters('existingVirtualNetworkId'), variables('inferredVirtualNetworkId'))]", - "useExistingVirtualNetwork": "[and(parameters('enablePrivateNetworking'), or(or(not(empty(variables('resolvedExistingVirtualNetworkId'))), variables('hasExistingAppServiceSubnetId')), variables('hasExistingPrivateEndpointSubnetId')))]", - "allowedIpsForCosmos": "[union(createArray('0.0.0.0'), variables('allowedIpAddressesArray'))]" - }, - "resources": { - "rg": { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2022-09-01", - "name": "[variables('rgName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]" - }, - "virtualNetwork": { - "condition": "[and(parameters('enablePrivateNetworking'), not(variables('useExistingVirtualNetwork')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "virtualNetwork", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "vNetName": { - "value": "[variables('vNetName')]" - }, - "addressSpaces": { - "value": [ - "10.0.0.0/21" - ] - }, - "subnetConfigs": { - "value": [ - { - "name": "AppServiceIntegration", - "addressPrefix": "10.0.0.0/24", - "enablePrivateEndpointNetworkPolicies": true, - "enablePrivateLinkServiceNetworkPolicies": true - }, - { - "name": "PrivateEndpoints", - "addressPrefix": "10.0.2.0/24", - "enablePrivateEndpointNetworkPolicies": true, - "enablePrivateLinkServiceNetworkPolicies": true - } - ] - }, - "tags": { - "value": "[variables('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "493906105301296560" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "vNetName": { - "type": "string" - }, - "addressSpaces": { - "type": "array" - }, - "subnetConfigs": { - "type": "array" - }, - "tags": { - "type": "object" - } - }, - "variables": { - "copy": [ - { - "name": "subnetIds", - "count": "[length(parameters('subnetConfigs'))]", - "input": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vNetName'), parameters('subnetConfigs')[copyIndex('subnetIds')].name)]" - }, - { - "name": "subnetNames", - "count": "[length(parameters('subnetConfigs'))]", - "input": "[parameters('subnetConfigs')[copyIndex('subnetNames')].name]" - } - ], - "appServiceIntegrationSubnetIndex": "[indexOf(variables('subnetNames'), 'AppServiceIntegration')]", - "privateEndpointIndex": "[indexOf(variables('subnetNames'), 'PrivateEndpoints')]" - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-05-01", - "name": "[parameters('vNetName')]", - "location": "[parameters('location')]", - "properties": { - "copy": [ - { - "name": "subnets", - "count": "[length(parameters('subnetConfigs'))]", - "input": { - "name": "[parameters('subnetConfigs')[copyIndex('subnets')].name]", - "properties": { - "addressPrefix": "[parameters('subnetConfigs')[copyIndex('subnets')].addressPrefix]", - "privateEndpointNetworkPolicies": "[if(parameters('subnetConfigs')[copyIndex('subnets')].enablePrivateEndpointNetworkPolicies, 'Enabled', 'Disabled')]", - "privateLinkServiceNetworkPolicies": "[if(parameters('subnetConfigs')[copyIndex('subnets')].enablePrivateLinkServiceNetworkPolicies, 'Enabled', 'Disabled')]", - "delegations": "[if(equals(parameters('subnetConfigs')[copyIndex('subnets')].name, 'AppServiceIntegration'), createArray(createObject('name', 'delegation', 'properties', createObject('serviceName', 'Microsoft.Web/serverFarms'))), createArray())]" - } - } - } - ], - "addressSpace": { - "addressPrefixes": "[parameters('addressSpaces')]" - } - }, - "tags": "[parameters('tags')]" - } - ], - "outputs": { - "vNetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vNetName'))]" - }, - "privateNetworkSubnetId": { - "type": "string", - "value": "[if(equals(variables('privateEndpointIndex'), -1), '', variables('subnetIds')[variables('privateEndpointIndex')])]" - }, - "appServiceSubnetId": { - "type": "string", - "value": "[if(equals(variables('appServiceIntegrationSubnetIndex'), -1), '', variables('subnetIds')[variables('appServiceIntegrationSubnetIndex')])]" - } - } - } - }, - "dependsOn": [ - "rg" - ] - }, - "logAnalytics": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "logAnalytics", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3702219330633152864" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2022-10-01", - "name": "[toLower(format('{0}-{1}-la', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "properties": { - "sku": { - "name": "PerGB2018" - } - }, - "tags": "[parameters('tags')]" - } - ], - "outputs": { - "logAnalyticsId": { - "type": "string", - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', toLower(format('{0}-{1}-la', parameters('appName'), parameters('environment'))))]" - } - } - } - }, - "dependsOn": [ - "rg" - ] - }, - "applicationInsights": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "applicationInsights", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16478820448576828524" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[toLower(format('{0}-{1}-ai', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "web", - "properties": { - "Application_Type": "web", - "WorkspaceResourceId": "[parameters('logAnalyticsId')]" - }, - "tags": "[parameters('tags')]" - } - ], - "outputs": { - "appInsightsName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-ai', parameters('appName'), parameters('environment')))]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "keyVault": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyVault", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3020666832422074415" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2024-11-01", - "name": "[toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "properties": { - "tenantId": "[subscription().tenantId]", - "sku": { - "family": "A", - "name": "standard" - }, - "accessPolicies": [], - "enabledForDeployment": false, - "enabledForDiskEncryption": false, - "enabledForTemplateDeployment": false, - "publicNetworkAccess": "Enabled", - "enableRbacAuthorization": true - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "keyVaultId": { - "type": "string", - "value": "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]" - }, - "keyVaultName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))]" - }, - "keyVaultUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))), '2024-11-01').vaultUri]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "storeEnterpriseAppSecret": { - "condition": "[not(empty(parameters('enterpriseAppClientSecret')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storeEnterpriseAppSecret", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "secretName": { - "value": "enterprise-app-client-secret" - }, - "secretValue": { - "value": "[parameters('enterpriseAppClientSecret')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15395992383660736405" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "secretName": { - "type": "string" - }, - "secretValue": { - "type": "securestring" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2025-05-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", - "properties": { - "value": "[parameters('secretValue')]" - } - } - ], - "outputs": { - "SecretUri": { - "type": "string", - "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" - } - } - } - }, - "dependsOn": [ - "keyVault", - "rg" - ] - }, - "cosmosDB": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "cosmosDB", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - }, - "allowedIpAddresses": { - "value": "[variables('cosmosDbIpRules')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "12491761736151044216" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - }, - "allowedIpAddresses": { - "type": "array", - "defaultValue": [] - } - }, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "GlobalDocumentDB", - "properties": { - "publicNetworkAccess": "Enabled", - "databaseAccountOfferType": "Standard", - "capabilities": [ - { - "name": "EnableServerless" - } - ], - "isVirtualNetworkFilterEnabled": "[if(parameters('enablePrivateNetworking'), true(), false())]", - "ipRules": "[if(parameters('enablePrivateNetworking'), parameters('allowedIpAddresses'), createArray())]", - "locations": [ - { - "locationName": "[parameters('location')]", - "failoverPriority": 0, - "isZoneRedundant": false - } - ], - "consistencyPolicy": { - "defaultConsistencyLevel": "Session" - } - }, - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat')]", - "properties": { - "resource": { - "id": "SimpleChat" - }, - "options": {} - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat', 'settings')]", - "properties": { - "resource": { - "id": "settings", - "partitionKey": { - "paths": [ - "/id" - ] - } - }, - "options": {} - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": [] - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "cosmosDbName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))]" - }, - "cosmosDbUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))), '2023-04-15').documentEndpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "acr": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "azureContainerRegistry", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "acrName": { - "value": "[variables('acrName')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - }, - "allowedIpAddresses": { - "value": "[variables('acrIpRules')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "8868053889964006912" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - }, - "allowedIpAddresses": { - "type": "array", - "defaultValue": [] - } - }, - "resources": [ - { - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2025-04-01", - "name": "[parameters('acrName')]", - "location": "[parameters('location')]", - "sku": { - "name": "[if(parameters('enablePrivateNetworking'), 'Premium', 'Standard')]" - }, - "properties": { - "adminUserEnabled": true, - "publicNetworkAccess": "Enabled", - "networkRuleBypassOptions": "[if(parameters('enablePrivateNetworking'), 'AzureServices', 'None')]", - "networkRuleSet": "[if(parameters('enablePrivateNetworking'), createObject('defaultAction', 'Allow', 'ipRules', parameters('allowedIpAddresses')), null())]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "name": "[toLower(format('{0}-diagnostics', parameters('acrName')))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "acrName": { - "type": "string", - "value": "[parameters('acrName')]" - }, - "acrResourceGroup": { - "type": "string", - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "searchService": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "searchService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "433641641835184139" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.Search/searchServices", - "apiVersion": "2025-05-01", - "name": "[toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "sku": { - "name": "basic" - }, - "properties": { - "hostingMode": "default", - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "replicaCount": 1, - "partitionCount": 1, - "authOptions": { - "aadOrApiKey": { - "aadAuthFailureMode": "http403" - } - }, - "disableLocalAuth": false - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "searchServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))]" - }, - "searchServiceEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))), '2025-05-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "docIntel": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "docIntel", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "1751663477897380161" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "FormRecognizer", - "sku": { - "name": "S0" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "documentIntelligenceServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]" - }, - "diagnosticLoggingEnabled": { - "type": "bool", - "value": "[parameters('enableDiagLogging')]" - }, - "documentIntelligenceServiceEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))), '2025-06-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "storageAccount": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storageAccount", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "keyVault": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "configureApplicationPermissions": { - "value": "[parameters('configureApplicationPermissions')]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "811367549199956159" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "keyVault": { - "type": "string" - }, - "authenticationType": { - "type": "string" - }, - "configureApplicationPermissions": { - "type": "bool" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2022-09-01", - "name": "[toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2", - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "accessTier": "Hot", - "allowBlobPublicAccess": false, - "allowSharedKeyAccess": true, - "isHnsEnabled": true - }, - "tags": "[parameters('tags')]" - }, - { - "type": "Microsoft.Storage/storageAccounts/blobServices", - "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts/blobServices/containers", - "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}/{2}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default', 'user-documents')]", - "properties": { - "publicAccess": "None" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts/blobServices/containers", - "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}/{2}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default', 'group-documents')]", - "properties": { - "publicAccess": "None" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": [], - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", - "name": "[toLower(format('{0}-blob-diagnostics', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - }, - { - "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storeStorageAccountSecret", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[parameters('keyVault')]" - }, - "secretName": { - "value": "storage-account-key" - }, - "secretValue": { - "value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))), '2022-09-01').keys[0].value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15395992383660736405" - } - }, - "parameters": { - "keyVaultName": { - "type": "string" - }, - "secretName": { - "type": "string" - }, - "secretValue": { - "type": "securestring" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2025-05-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", - "properties": { - "value": "[parameters('secretValue')]" - } - } - ], - "outputs": { - "SecretUri": { - "type": "string", - "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" - ] - } - ], - "outputs": { - "name": { - "type": "string", - "value": "[toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))]" - }, - "endpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))), '2022-09-01').primaryEndpoints.blob]" - } - } - } - }, - "dependsOn": [ - "keyVault", - "logAnalytics", - "rg" - ] - }, - "openAI": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAI", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "existingOpenAIEndpoint": { - "value": "[parameters('existingOpenAIEndpoint')]" - }, - "existingOpenAIResourceName": { - "value": "[parameters('existingOpenAIResourceName')]" - }, - "existingOpenAIResourceGroup": { - "value": "[parameters('existingOpenAIResourceGroup')]" - }, - "existingOpenAISubscriptionId": { - "value": "[parameters('existingOpenAISubscriptionId')]" - }, - "gptModels": { - "value": "[variables('resolvedGptModels')]" - }, - "embeddingModels": { - "value": "[variables('resolvedEmbeddingModels')]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3132373501615141396" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "existingOpenAIEndpoint": { - "type": "string", - "defaultValue": "" - }, - "existingOpenAIResourceName": { - "type": "string", - "defaultValue": "" - }, - "existingOpenAIResourceGroup": { - "type": "string", - "defaultValue": "" - }, - "existingOpenAISubscriptionId": { - "type": "string", - "defaultValue": "" - }, - "gptModels": { - "type": "array" - }, - "embeddingModels": { - "type": "array" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "variables": { - "useExistingOpenAIEndpoint": "[not(empty(parameters('existingOpenAIEndpoint')))]", - "resolvedOpenAIResourceGroup": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAIResourceGroup'), resourceGroup().name)]", - "resolvedOpenAISubscriptionId": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAISubscriptionId'), subscription().subscriptionId)]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingOpenAIEndpoint'))]", - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2024-10-01", - "name": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "OpenAI", - "sku": { - "name": "S0" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(parameters('enableDiagLogging'), not(variables('useExistingOpenAIEndpoint')))]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - }, - { - "copy": { - "name": "aiModel", - "count": "[length(if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray()))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('model-{0}-{1}', replace(if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].modelName, '.', '-'), copyIndex())]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "parent": { - "value": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" - }, - "modelName": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].modelName]" - }, - "modelVersion": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].modelVersion]" - }, - "skuName": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].skuName]" - }, - "skuCapacity": { - "value": "[if(not(variables('useExistingOpenAIEndpoint')), concat(parameters('gptModels'), parameters('embeddingModels')), createArray())[copyIndex()].skuCapacity]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "12746224250029736064" - } - }, - "parameters": { - "parent": { - "type": "string" - }, - "modelName": { - "type": "string" - }, - "modelVersion": { - "type": "string" - }, - "skuName": { - "type": "string" - }, - "skuCapacity": { - "type": "int" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-06-01", - "name": "[format('{0}/{1}', parameters('parent'), parameters('modelName'))]", - "properties": { - "model": { - "format": "OpenAI", - "name": "[parameters('modelName')]", - "version": "[parameters('modelVersion')]" - } - }, - "sku": { - "name": "[parameters('skuName')]", - "capacity": "[parameters('skuCapacity')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" - ] - } - ], - "outputs": { - "openAIName": { - "type": "string", - "value": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAIResourceName'), toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" - }, - "openAIResourceGroup": { - "type": "string", - "value": "[variables('resolvedOpenAIResourceGroup')]" - }, - "openAISubscriptionId": { - "type": "string", - "value": "[variables('resolvedOpenAISubscriptionId')]" - }, - "openAIEndpoint": { - "type": "string", - "value": "[if(variables('useExistingOpenAIEndpoint'), parameters('existingOpenAIEndpoint'), reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))), '2024-10-01').endpoint)]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "appServicePlan": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "appServicePlan", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "9868911322271424816" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2022-03-01", - "name": "[toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "sku": { - "name": "P1v3", - "tier": "PremiumV3", - "size": "P1v3", - "capacity": 1 - }, - "kind": "app,linux,container", - "properties": { - "reserved": true, - "perSiteScaling": false, - "maximumElasticWorkerCount": 1, - "hyperV": false, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": [], - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "appServicePlanId": { - "type": "string", - "value": "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "appService": { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "appService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "acrName": { - "value": "[reference('acr').outputs.acrName.value]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "appServicePlanId": { - "value": "[reference('appServicePlan').outputs.appServicePlanId.value]" - }, - "containerImageName": { - "value": "[variables('containerImageName')]" - }, - "azurePlatform": { - "value": "[variables('scCloudEnvironment')]" - }, - "cosmosDbName": { - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "searchServiceName": { - "value": "[reference('searchService').outputs.searchServiceName.value]" - }, - "openAiServiceName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "openAiEndpoint": { - "value": "[reference('openAI').outputs.openAIEndpoint.value]" - }, - "openAiResourceGroupName": { - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "documentIntelligenceServiceName": { - "value": "[reference('docIntel').outputs.documentIntelligenceServiceName.value]" - }, - "appInsightsName": { - "value": "[reference('applicationInsights').outputs.appInsightsName.value]" - }, - "enterpriseAppClientId": { - "value": "[parameters('enterpriseAppClientId')]" - }, - "enterpriseAppClientSecret": { - "value": "[parameters('enterpriseAppClientSecret')]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "keyVaultUri": { - "value": "[reference('keyVault').outputs.keyVaultUri.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - }, - "appServiceSubnetId": "[if(parameters('enablePrivateNetworking'), if(variables('useExistingVirtualNetwork'), createObject('value', parameters('existingAppServiceSubnetId')), createObject('value', reference('virtualNetwork').outputs.appServiceSubnetId.value)), createObject('value', ''))]", - "customBlobStorageSuffix": { - "value": "[parameters('customBlobStorageSuffix')]" - }, - "customGraphUrl": { - "value": "[parameters('customGraphUrl')]" - }, - "customIdentityUrl": { - "value": "[parameters('customIdentityUrl')]" - }, - "customResourceManagerUrl": { - "value": "[parameters('customResourceManagerUrl')]" - }, - "customCognitiveServicesScope": { - "value": "[parameters('customCognitiveServicesScope')]" - }, - "customSearchResourceUrl": { - "value": "[parameters('customSearchResourceUrl')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "7866543323088689102" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "appServicePlanId": { - "type": "string" - }, - "containerImageName": { - "type": "string" - }, - "azurePlatform": { - "type": "string" - }, - "cosmosDbName": { - "type": "string" - }, - "searchServiceName": { - "type": "string" - }, - "openAiServiceName": { - "type": "string" - }, - "openAiEndpoint": { - "type": "string" - }, - "openAiResourceGroupName": { - "type": "string" - }, - "documentIntelligenceServiceName": { - "type": "string" - }, - "appInsightsName": { - "type": "string" - }, - "enterpriseAppClientId": { - "type": "string", - "defaultValue": "" - }, - "authenticationType": { - "type": "string" - }, - "enterpriseAppClientSecret": { - "type": "securestring", - "defaultValue": "" - }, - "keyVaultUri": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - }, - "appServiceSubnetId": { - "type": "string", - "defaultValue": "" - }, - "customBlobStorageSuffix": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom blob storage URL suffix, e.g. blob.core.usgovcloudapi.net" - } - }, - "customGraphUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Graph API URL, e.g. https://graph.microsoft.us" - } - }, - "customIdentityUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Identity URL, e.g. https://login.microsoftonline.us" - } - }, - "customResourceManagerUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Resource Manager URL, e.g. https://management.usgovcloudapi.net" - } - }, - "customCognitiveServicesScope": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom Cognitive Services scope ex: https://cognitiveservices.azure.com/.default" - } - }, - "customSearchResourceUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Custom search resource URL for token audience, e.g. https://search.azure.us" - } - } - }, - "variables": { - "tenantId": "[tenant().tenantId]", - "openIdMetadataUrl": "[format('{0}{1}/v2.0/.well-known/openid-configuration', environment().authentication.loginEndpoint, variables('tenantId'))]", - "acrDomain": "[environment().suffixes.acrLoginServer]" - }, - "resources": { - "acrService": { - "existing": true, - "type": "Microsoft.ContainerRegistry/registries", - "apiVersion": "2025-04-01", - "name": "[parameters('acrName')]" - }, - "cosmosDb": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('cosmosDbName')]" - }, - "searchService": { - "existing": true, - "type": "Microsoft.Search/searchServices", - "apiVersion": "2025-05-01", - "name": "[parameters('searchServiceName')]" - }, - "documentIntelligence": { - "existing": true, - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[parameters('documentIntelligenceServiceName')]" - }, - "appInsights": { - "existing": true, - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[parameters('appInsightsName')]" - }, - "webApp": { - "type": "Microsoft.Web/sites", - "apiVersion": "2022-03-01", - "name": "[toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "app,linux,container", - "properties": { - "serverFarmId": "[parameters('appServicePlanId')]", - "virtualNetworkSubnetId": "[if(not(equals(parameters('appServiceSubnetId'), '')), parameters('appServiceSubnetId'), null())]", - "publicNetworkAccess": "Enabled", - "vnetImagePullEnabled": "[if(parameters('enablePrivateNetworking'), true(), false())]", - "siteConfig": { - "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", - "acrUseManagedIdentityCreds": true, - "acrUserManagedIdentityID": "", - "alwaysOn": true, - "ftpsState": "Disabled", - "healthCheckPath": "/external/healthcheck", - "appSettings": "[flatten(createArray(createArray(createObject('name', 'AZURE_ENVIRONMENT', 'value', parameters('azurePlatform')), createObject('name', 'SCM_DO_BUILD_DURING_DEPLOYMENT', 'value', 'false'), createObject('name', 'AZURE_COSMOS_ENDPOINT', 'value', reference('cosmosDb').documentEndpoint), createObject('name', 'AZURE_COSMOS_AUTHENTICATION_TYPE', 'value', toLower(parameters('authenticationType')))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_COSMOS_KEY', 'value', listKeys('cosmosDb', '2023-04-15').primaryMasterKey)), createArray()), createArray(createObject('name', 'TENANT_ID', 'value', tenant().tenantId), createObject('name', 'CLIENT_ID', 'value', parameters('enterpriseAppClientId')), createObject('name', 'SECRET_KEY', 'value', if(not(empty(parameters('enterpriseAppClientSecret'))), parameters('enterpriseAppClientSecret'), format('@Microsoft.KeyVault(SecretUri={0}secrets/enterprise-app-client-secret)', parameters('keyVaultUri')))), createObject('name', 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri= parameters('keyVaultUri'))), createObject('name', 'DOCKER_REGISTRY_SERVER_URL', 'value', format('https://{0}{1}', parameters('acrName'), variables('acrDomain')))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'DOCKER_REGISTRY_SERVER_USERNAME', 'value', listCredentials('acrService', '2025-04-01').username)), createArray()), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'DOCKER_REGISTRY_SERVER_PASSWORD', 'value', listCredentials('acrService', '2025-04-01').passwords[0].value)), createArray()), createArray(createObject('name', 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS', 'value', tenant().tenantId), createObject('name', 'AZURE_OPENAI_RESOURCE_NAME', 'value', parameters('openAiServiceName')), createObject('name', 'AZURE_OPENAI_RESOURCE_GROUP_NAME', 'value', parameters('openAiResourceGroupName')), createObject('name', 'AZURE_OPENAI_URL', 'value', parameters('openAiEndpoint')), createObject('name', 'AZURE_SEARCH_SERVICE_NAME', 'value', parameters('searchServiceName'))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_SEARCH_API_KEY', 'value', listAdminKeys('searchService', '2025-05-01').primaryKey)), createArray()), createArray(createObject('name', 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT', 'value', reference('documentIntelligence').endpoint)), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_DOCUMENT_INTELLIGENCE_API_KEY', 'value', listKeys('documentIntelligence', '2025-06-01').key1)), createArray()), createArray(createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference('appInsights').InstrumentationKey), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference('appInsights').ConnectionString), createObject('name', 'APPINSIGHTS_PROFILERFEATURE_VERSION', 'value', '1.0.0'), createObject('name', 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION', 'value', '1.0.0'), createObject('name', 'APPLICATIONINSIGHTS_CONFIGURATION_CONTENT', 'value', ''), createObject('name', 'ApplicationInsightsAgent_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'DiagnosticServices_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'InstrumentationEngine_EXTENSION_VERSION', 'value', 'disabled'), createObject('name', 'SnapshotDebugger_EXTENSION_VERSION', 'value', 'disabled'), createObject('name', 'XDT_MicrosoftApplicationInsights_BaseExtensions', 'value', 'disabled'), createObject('name', 'XDT_MicrosoftApplicationInsights_Mode', 'value', 'recommended'), createObject('name', 'XDT_MicrosoftApplicationInsights_PreemptSdk', 'value', 'disabled')), if(equals(parameters('azurePlatform'), 'custom'), createArray(createObject('name', 'CUSTOM_GRAPH_URL_VALUE', 'value', coalesce(parameters('customGraphUrl'), '')), createObject('name', 'CUSTOM_IDENTITY_URL_VALUE', 'value', coalesce(parameters('customIdentityUrl'), '')), createObject('name', 'CUSTOM_RESOURCE_MANAGER_URL_VALUE', 'value', coalesce(parameters('customResourceManagerUrl'), '')), createObject('name', 'CUSTOM_BLOB_STORAGE_URL_VALUE', 'value', coalesce(parameters('customBlobStorageSuffix'), '')), createObject('name', 'CUSTOM_COGNITIVE_SERVICES_URL_VALUE', 'value', coalesce(parameters('customCognitiveServicesScope'), '')), createObject('name', 'CUSTOM_SEARCH_RESOURCE_MANAGER_URL_VALUE', 'value', coalesce(parameters('customSearchResourceUrl'), '')), createObject('name', 'KEY_VAULT_DOMAIN', 'value', environment().suffixes.keyvaultDns), createObject('name', 'CUSTOM_OIDC_METADATA_URL_VALUE', 'value', coalesce(variables('openIdMetadataUrl'), ''))), createArray())))]" - }, - "clientAffinityEnabled": false, - "httpsOnly": true - }, - "identity": { - "type": "SystemAssigned" - }, - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'web'))]", - "dependsOn": [ - "appInsights", - "cosmosDb", - "documentIntelligence" - ] - }, - "webAppLogging": { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2022-03-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))), 'logs')]", - "properties": { - "httpLogs": { - "fileSystem": { - "enabled": true, - "retentionInDays": 7, - "retentionInMb": 35 - } - } - }, - "dependsOn": [ - "webApp" - ] - }, - "webAppDiagnostics": { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference('diagnosticConfigs').outputs.webAppLogCategories.value]", - "metrics": "[reference('diagnosticConfigs').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "diagnosticConfigs", - "webApp" - ] - }, - "authSettings": { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2022-03-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))), 'authsettingsV2')]", - "properties": { - "globalValidation": { - "requireAuthentication": true, - "unauthenticatedClientAction": "RedirectToLoginPage", - "redirectToProvider": "azureActiveDirectory" - }, - "identityProviders": { - "azureActiveDirectory": { - "enabled": true, - "registration": { - "openIdIssuer": "[format('{0}{1}/', environment().authentication.loginEndpoint, tenant().tenantId)]", - "clientId": "[parameters('enterpriseAppClientId')]", - "clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET" - }, - "validation": { - "jwtClaimChecks": {}, - "allowedAudiences": [ - "[format('api://{0}', parameters('enterpriseAppClientId'))]", - "[parameters('enterpriseAppClientId')]" - ] - }, - "isAutoProvisioned": false - } - }, - "login": { - "routes": { - "logoutEndpoint": "/.auth/logout" - }, - "tokenStore": { - "enabled": true, - "tokenRefreshExtensionHours": 72, - "fileSystem": { - "directory": "/home/data/.auth" - } - }, - "preserveUrlFragmentsForLogins": false, - "allowedExternalRedirectUrls": [], - "cookieExpiration": { - "convention": "FixedTime", - "timeToExpiration": "08:00:00" - }, - "nonce": { - "validateNonce": true, - "nonceExpirationInterval": "00:05:00" - } - }, - "httpSettings": { - "requireHttps": true, - "routes": { - "apiPrefix": "/.auth" - }, - "forwardProxy": { - "convention": "NoProxy" - } - } - }, - "dependsOn": [ - "webApp" - ] - }, - "diagnosticConfigs": { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - }, - "outputs": { - "name": { - "type": "string", - "value": "[toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))]" - }, - "defaultHostName": { - "type": "string", - "value": "[reference('webApp').defaultHostName]" - }, - "resourceId": { - "type": "string", - "value": "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]" - } - } - } - }, - "dependsOn": [ - "acr", - "applicationInsights", - "appServicePlan", - "cosmosDB", - "docIntel", - "keyVault", - "logAnalytics", - "openAI", - "rg", - "searchService", - "virtualNetwork" - ] - }, - "contentSafety": { - "condition": "[parameters('deployContentSafety')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "contentSafety", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "7932852522879306913" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-06-01", - "name": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "ContentSafety", - "sku": { - "name": "S0" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]", - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "contentSafetyName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]" - }, - "contentSafetyEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))), '2025-06-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "redisCache": { - "condition": "[parameters('deployRedisCache')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "redisCache", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "17045519057214581310" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Cache/redis", - "apiVersion": "2024-11-01", - "name": "[toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "properties": { - "sku": { - "name": "Standard", - "family": "C", - "capacity": 0 - }, - "enableNonSslPort": false, - "minimumTlsVersion": "1.2", - "redisConfiguration": { - "aad-enabled": "true" - } - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "redisCacheName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))]" - }, - "redisCacheHostName": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))), '2024-11-01').hostName]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "speechService": { - "condition": "[parameters('deploySpeechService')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "speechService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15886240553047875398" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2024-10-01", - "name": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "kind": "SpeechServices", - "sku": { - "name": "S0" - }, - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "customSubDomainName": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]" - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", - "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "speechServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]" - }, - "speechServiceEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))), '2024-10-01').endpoint]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "rg" - ] - }, - "videoIndexerService": { - "condition": "[parameters('deployVideoIndexerService')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "videoIndexerService", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "enableDiagLogging": { - "value": "[parameters('enableDiagLogging')]" - }, - "logAnalyticsId": { - "value": "[reference('logAnalytics').outputs.logAnalyticsId.value]" - }, - "storageAccount": { - "value": "[reference('storageAccount').outputs.name.value]" - }, - "openAiServiceName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "enablePrivateNetworking": { - "value": "[parameters('enablePrivateNetworking')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "4707936164725245117" - } - }, - "parameters": { - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "enableDiagLogging": { - "type": "bool" - }, - "logAnalyticsId": { - "type": "string" - }, - "storageAccount": { - "type": "string" - }, - "openAiServiceName": { - "type": "string" - }, - "enablePrivateNetworking": { - "type": "bool" - } - }, - "resources": [ - { - "type": "Microsoft.VideoIndexer/accounts", - "apiVersion": "2025-04-01", - "name": "[toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))]", - "location": "[parameters('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", - "storageServices": { - "resourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccount'))]" - }, - "openAiServices": { - "resourceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAiServiceName'))]" - } - }, - "tags": "[parameters('tags')]" - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment'))))]", - "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))))]", - "properties": { - "workspaceId": "[parameters('logAnalyticsId')]", - "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.limitedLogCategories.value]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", - "[resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment'))))]" - ] - }, - { - "condition": "[parameters('enableDiagLogging')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "diagnosticConfigs", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14011201271499176208" - } - }, - "variables": { - "standardRetentionPolicy": { - "enabled": false, - "days": 0 - }, - "standardLogCategories": [ - { - "categoryGroup": "Audit", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "limitedLogCategories": [ - { - "categoryGroup": "allLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "standardMetricsCategories": [ - { - "category": "AllMetrics", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "transactionMetricsCategories": [ - { - "category": "Transaction", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ], - "webAppLogCategories": [ - { - "category": "AppServiceAntivirusScanAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceHTTPLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceFileAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServicePlatformLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - }, - { - "category": "AppServiceAuthenticationLogs", - "enabled": true, - "retentionPolicy": "[variables('standardRetentionPolicy')]" - } - ] - }, - "resources": [], - "outputs": { - "limitedLogCategories": { - "type": "array", - "value": "[variables('limitedLogCategories')]" - }, - "standardRetentionPolicy": { - "type": "object", - "value": "[variables('standardRetentionPolicy')]" - }, - "standardLogCategories": { - "type": "array", - "value": "[variables('standardLogCategories')]" - }, - "standardMetricsCategories": { - "type": "array", - "value": "[variables('standardMetricsCategories')]" - }, - "transactionMetricsCategories": { - "type": "array", - "value": "[variables('transactionMetricsCategories')]" - }, - "webAppLogCategories": { - "type": "array", - "value": "[variables('webAppLogCategories')]" - } - } - } - } - } - ], - "outputs": { - "videoIndexerServiceName": { - "type": "string", - "value": "[toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))]" - }, - "videoIndexerAccountId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))), '2025-04-01').accountId]" - } - } - } - }, - "dependsOn": [ - "logAnalytics", - "openAI", - "rg", - "storageAccount" - ] - }, - "setPermissions": { - "condition": "[parameters('configureApplicationPermissions')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "setPermissions", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "webAppName": { - "value": "[reference('appService').outputs.name.value]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "enterpriseAppServicePrincipalId": { - "value": "[parameters('enterpriseAppServicePrincipalId')]" - }, - "keyVaultName": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "cosmosDBName": { - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "acrName": { - "value": "[reference('acr').outputs.acrName.value]" - }, - "openAIName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "openAIResourceGroupName": { - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "openAISubscriptionId": { - "value": "[reference('openAI').outputs.openAISubscriptionId.value]" - }, - "docIntelName": { - "value": "[reference('docIntel').outputs.documentIntelligenceServiceName.value]" - }, - "storageAccountName": { - "value": "[reference('storageAccount').outputs.name.value]" - }, - "searchServiceName": { - "value": "[reference('searchService').outputs.searchServiceName.value]" - }, - "speechServiceName": "[if(parameters('deploySpeechService'), createObject('value', reference('speechService').outputs.speechServiceName.value), createObject('value', ''))]", - "redisCacheName": "[if(parameters('deployRedisCache'), createObject('value', reference('redisCache').outputs.redisCacheName.value), createObject('value', ''))]", - "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]", - "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerServiceName.value), createObject('value', ''))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "4113398169754187856" - } - }, - "parameters": { - "webAppName": { - "type": "string" - }, - "authenticationType": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "enterpriseAppServicePrincipalId": { - "type": "string" - }, - "cosmosDBName": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "openAIName": { - "type": "string" - }, - "openAIResourceGroupName": { - "type": "string" - }, - "openAISubscriptionId": { - "type": "string" - }, - "docIntelName": { - "type": "string" - }, - "storageAccountName": { - "type": "string" - }, - "speechServiceName": { - "type": "string" - }, - "searchServiceName": { - "type": "string" - }, - "redisCacheName": { - "type": "string" - }, - "contentSafetyName": { - "type": "string" - }, - "videoIndexerName": { - "type": "string" - } - }, - "variables": { - "useExternalOpenAIResource": "[and(and(not(equals(parameters('openAIName'), '')), not(empty(parameters('openAIResourceGroupName')))), not(empty(parameters('openAISubscriptionId'))))]" - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'kv-secrets-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]", - "name": "[guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'cosmos-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('cosmosDBName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'cosmos-data-contributor'))]", - "properties": { - "roleDefinitionId": "[format('{0}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002', resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", - "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'acr-pull-role')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(and(equals(parameters('authenticationType'), 'managed_identity'), not(equals(parameters('openAIName'), ''))), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'openai-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(and(equals(parameters('authenticationType'), 'managed_identity'), not(equals(parameters('openAIName'), ''))), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'enterpriseApp-CognitiveServicesOpenAIUserRole')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[parameters('enterpriseAppServicePrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'doc-intel-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'storage-blob-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('speechServiceName'), '')), equals(parameters('authenticationType'), 'managed_identity'))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'speech-service-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]", - "name": "[guid(resourceId('Microsoft.Search/searchServices', parameters('searchServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'search-index-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]", - "name": "[guid(resourceId('Microsoft.Search/searchServices', parameters('searchServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'search-service-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('contentSafetyName'), '')), equals(parameters('authenticationType'), 'managed_identity'))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'content-safety-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[not(equals(parameters('videoIndexerName'), ''))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-storage-blob-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-cog-services-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(variables('useExternalOpenAIResource')))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-cog-services-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[not(equals(parameters('redisCacheName'), ''))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Cache/redis', parameters('redisCacheName'))]", - "name": "[guid(resourceId('Microsoft.Cache/redis', parameters('redisCacheName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'redis-cache-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'e0f68234-74aa-48ed-b826-c38b57376e17')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[variables('useExternalOpenAIResource')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAIExternalPermissions", - "subscriptionId": "[parameters('openAISubscriptionId')]", - "resourceGroup": "[parameters('openAIResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "openAIName": { - "value": "[parameters('openAIName')]" - }, - "authenticationType": { - "value": "[parameters('authenticationType')]" - }, - "webAppPrincipalId": { - "value": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]" - }, - "enterpriseAppServicePrincipalId": { - "value": "[parameters('enterpriseAppServicePrincipalId')]" - }, - "videoIndexerPrincipalId": "[if(not(equals(parameters('videoIndexerName'), '')), createObject('value', reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId), createObject('value', ''))]", - "videoIndexerName": { - "value": "[parameters('videoIndexerName')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14586417486337251686" - } - }, - "parameters": { - "openAIName": { - "type": "string" - }, - "authenticationType": { - "type": "string" - }, - "webAppPrincipalId": { - "type": "string" - }, - "enterpriseAppServicePrincipalId": { - "type": "string" - }, - "videoIndexerPrincipalId": { - "type": "string", - "defaultValue": "" - }, - "videoIndexerName": { - "type": "string", - "defaultValue": "" - } - }, - "resources": [ - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('webAppPrincipalId'), 'openai-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[parameters('webAppPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('enterpriseAppServicePrincipalId'), 'enterpriseApp-CognitiveServicesOpenAIUserRole')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", - "principalId": "[parameters('enterpriseAppServicePrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(empty(parameters('videoIndexerPrincipalId'))))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('videoIndexerPrincipalId'), 'video-indexer-cog-services-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68')]", - "principalId": "[parameters('videoIndexerPrincipalId')]", - "principalType": "ServicePrincipal" - } - }, - { - "condition": "[and(not(equals(parameters('videoIndexerName'), '')), not(empty(parameters('videoIndexerPrincipalId'))))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), parameters('videoIndexerPrincipalId'), 'video-indexer-cog-services-user')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", - "principalId": "[parameters('videoIndexerPrincipalId')]", - "principalType": "ServicePrincipal" - } - } - ] - } - } - } - ] - } - }, - "dependsOn": [ - "acr", - "appService", - "contentSafety", - "cosmosDB", - "docIntel", - "keyVault", - "openAI", - "redisCache", - "rg", - "searchService", - "speechService", - "storageAccount", - "videoIndexerService" - ] - }, - "privateNetworking": { - "condition": "[parameters('enablePrivateNetworking')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "privateNetworking", - "resourceGroup": "[variables('rgName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "virtualNetworkId": "[if(parameters('enablePrivateNetworking'), if(variables('useExistingVirtualNetwork'), createObject('value', variables('resolvedExistingVirtualNetworkId')), createObject('value', reference('virtualNetwork').outputs.vNetId.value)), createObject('value', ''))]", - "privateEndpointSubnetId": "[if(parameters('enablePrivateNetworking'), if(variables('useExistingVirtualNetwork'), createObject('value', parameters('existingPrivateEndpointSubnetId')), createObject('value', reference('virtualNetwork').outputs.privateNetworkSubnetId.value)), createObject('value', ''))]", - "privateDnsZoneConfigs": { - "value": "[parameters('privateDnsZoneConfigs')]" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "tags": { - "value": "[variables('tags')]" - }, - "keyVaultName": { - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "cosmosDBName": { - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "acrName": { - "value": "[reference('acr').outputs.acrName.value]" - }, - "searchServiceName": { - "value": "[reference('searchService').outputs.searchServiceName.value]" - }, - "docIntelName": { - "value": "[reference('docIntel').outputs.documentIntelligenceServiceName.value]" - }, - "storageAccountName": { - "value": "[reference('storageAccount').outputs.name.value]" - }, - "openAIName": { - "value": "[reference('openAI').outputs.openAIName.value]" - }, - "openAIResourceGroupName": { - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "openAISubscriptionId": { - "value": "[reference('openAI').outputs.openAISubscriptionId.value]" - }, - "webAppName": { - "value": "[reference('appService').outputs.name.value]" - }, - "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]", - "speechServiceName": "[if(parameters('deploySpeechService'), createObject('value', reference('speechService').outputs.speechServiceName.value), createObject('value', ''))]", - "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerServiceName.value), createObject('value', ''))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "5473171678692396413" - } - }, - "parameters": { - "virtualNetworkId": { - "type": "string" - }, - "privateEndpointSubnetId": { - "type": "string" - }, - "privateDnsZoneConfigs": { - "type": "object", - "defaultValue": {} - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "keyVaultName": { - "type": "string" - }, - "cosmosDBName": { - "type": "string" - }, - "acrName": { - "type": "string" - }, - "searchServiceName": { - "type": "string" - }, - "docIntelName": { - "type": "string" - }, - "storageAccountName": { - "type": "string" - }, - "openAIName": { - "type": "string" - }, - "openAIResourceGroupName": { - "type": "string" - }, - "openAISubscriptionId": { - "type": "string" - }, - "webAppName": { - "type": "string" - }, - "contentSafetyName": { - "type": "string" - }, - "speechServiceName": { - "type": "string" - }, - "videoIndexerName": { - "type": "string" - } - }, - "variables": { - "$fxv#0": { - "azurecloud": { - "aisearch": "privatelink.search.windows.net", - "blobStorage": "privatelink.blob.core.windows.net", - "cognitiveServices": "privatelink.cognitiveservices.azure.com", - "containerRegistry": "privatelink.azurecr.io", - "cosmosDb": "privatelink.documents.azure.com", - "keyVault": "privatelink.vaultcore.azure.net", - "openAi": "privatelink.openai.azure.com", - "webSites": "privatelink.azurewebsites.net" - }, - "azureusgovernment": { - "aisearch": "privatelink.search.azure.us", - "blobStorage": "privatelink.blob.core.usgovcloudapi.net", - "cognitiveServices": "privatelink.cognitiveservices.azure.us", - "containerRegistry": "privatelink.azurecr.us", - "cosmosDb": "privatelink.documents.azure.us", - "keyVault": "privatelink.vaultcore.azure.us", - "openAi": "privatelink.openai.azure.us", - "webSites": "privatelink.azurewebsites.us" - } - }, - "useExternalOpenAIResource": "[and(and(not(equals(parameters('openAIName'), '')), not(empty(parameters('openAIResourceGroupName')))), not(empty(parameters('openAISubscriptionId'))))]", - "cloudName": "[toLower(environment().name)]", - "privateDnsZoneData": "[variables('$fxv#0')]", - "aiSearchDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].aisearch]", - "blobStorageDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].blobStorage]", - "cognitiveServicesDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].cognitiveServices]", - "containerRegistryDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].containerRegistry]", - "cosmosDbDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].cosmosDb]", - "keyVaultDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].keyVault]", - "openAiDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].openAi]", - "webSitesDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].webSites]", - "defaultPrivateDnsZoneConfig": { - "zoneResourceId": "", - "createVNetLink": true - }, - "keyVaultPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'keyVault'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').keyVault), variables('defaultPrivateDnsZoneConfig'))]", - "cosmosDbPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'cosmosDb'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').cosmosDb), variables('defaultPrivateDnsZoneConfig'))]", - "containerRegistryPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'containerRegistry'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').containerRegistry), variables('defaultPrivateDnsZoneConfig'))]", - "aiSearchPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'aiSearch'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').aiSearch), variables('defaultPrivateDnsZoneConfig'))]", - "blobStoragePrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'blobStorage'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').blobStorage), variables('defaultPrivateDnsZoneConfig'))]", - "cognitiveServicesPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'cognitiveServices'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').cognitiveServices), variables('defaultPrivateDnsZoneConfig'))]", - "openAiPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'openAi'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').openAi), variables('defaultPrivateDnsZoneConfig'))]", - "webSitesPrivateDnsZoneConfig": "[if(contains(parameters('privateDnsZoneConfigs'), 'webSites'), union(variables('defaultPrivateDnsZoneConfig'), parameters('privateDnsZoneConfigs').webSites), variables('defaultPrivateDnsZoneConfig'))]" - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyVaultDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('keyVaultDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "kv" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('keyVaultPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('keyVaultPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "keyVaultPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "kv" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "vault" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'keyVaultDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "cosmosDbDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('cosmosDbDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "cosmosDb" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('cosmosDbPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('cosmosDbPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "cosmosDbPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "cosmosDb" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "sql" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'cosmosDbDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'cosmosDbDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "acrDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('containerRegistryDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "acr" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('containerRegistryPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('containerRegistryPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "acrPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "acr" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "registry" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'acrDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'acrDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "searchServiceDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('aiSearchDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "searchService" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('aiSearchPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('aiSearchPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "searchServicePE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "searchService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "searchService" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'searchServiceDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'searchServiceDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "docIntelDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('cognitiveServicesDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "docIntelService" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('cognitiveServicesPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('cognitiveServicesPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "docIntelPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "docIntelService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storageAccountDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('blobStorageDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "storage" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('blobStoragePrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('blobStoragePrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "storageAccountPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "storageAccount" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "blob" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'storageAccountDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'storageAccountDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('openAIName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAiDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('openAiDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "openAiService" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('openAiPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('openAiPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "condition": "[not(equals(parameters('openAIName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "openAiPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "openAiService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": "[if(variables('useExternalOpenAIResource'), createObject('value', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('openAISubscriptionId'), parameters('openAIResourceGroupName')), 'Microsoft.CognitiveServices/accounts', parameters('openAIName'))), createObject('value', resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))))]", - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'openAiDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'openAiDNSZone')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "webAppDNSZone", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('webSitesDnsZoneName')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "name": { - "value": "webApp" - }, - "vNetId": { - "value": "[parameters('virtualNetworkId')]" - }, - "tags": { - "value": "[parameters('tags')]" - }, - "existingZoneResourceId": { - "value": "[variables('webSitesPrivateDnsZoneConfig').zoneResourceId]" - }, - "createVNetLink": { - "value": "[variables('webSitesPrivateDnsZoneConfig').createVNetLink]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3501998361364242008" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "name": { - "type": "string" - }, - "vNetId": { - "type": "string" - }, - "tags": { - "type": "object" - }, - "existingZoneResourceId": { - "type": "string", - "defaultValue": "" - }, - "createVNetLink": { - "type": "bool", - "defaultValue": true - } - }, - "variables": { - "useExistingZone": "[not(empty(parameters('existingZoneResourceId')))]", - "linkName": "[toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name')))]", - "existingZoneSubscriptionId": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[2], '')]", - "existingZoneResourceGroupName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[4], '')]", - "existingZoneName": "[if(variables('useExistingZone'), split(parameters('existingZoneResourceId'), '/')[8], '')]" - }, - "resources": [ - { - "condition": "[not(variables('useExistingZone'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[parameters('zoneName')]", - "location": "global", - "tags": "[parameters('tags')]" - }, - { - "condition": "[and(not(variables('useExistingZone')), parameters('createVNetLink'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), variables('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" - ] - }, - { - "condition": "[and(variables('useExistingZone'), parameters('createVNetLink'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('existing-{0}-privateDnsZoneLink', parameters('name'))]", - "subscriptionId": "[variables('existingZoneSubscriptionId')]", - "resourceGroup": "[variables('existingZoneResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "zoneName": { - "value": "[variables('existingZoneName')]" - }, - "linkName": { - "value": "[variables('linkName')]" - }, - "vNetId": { - "value": "[parameters('vNetId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16255331454976703178" - } - }, - "parameters": { - "zoneName": { - "type": "string" - }, - "linkName": { - "type": "string" - }, - "vNetId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2020-06-01", - "name": "[format('{0}/{1}', parameters('zoneName'), parameters('linkName'))]", - "location": "global", - "properties": { - "registrationEnabled": false, - "virtualNetwork": { - "id": "[parameters('vNetId')]" - } - } - } - ] - } - } - } - ], - "outputs": { - "privateDnsZoneId": { - "type": "string", - "value": "[if(variables('useExistingZone'), parameters('existingZoneResourceId'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')))]" - }, - "privateDnsZoneName": { - "type": "string", - "value": "[if(variables('useExistingZone'), variables('existingZoneName'), parameters('zoneName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "webAppPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "webApp" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.Web/sites', parameters('webAppName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "sites" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'webAppDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'webAppDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('contentSafetyName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "contentSafetyPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "contentSafety" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('speechServiceName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "speechServicePE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "speechService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - }, - { - "condition": "[not(equals(parameters('videoIndexerName'), ''))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "videoIndexerPE", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "videoIndexerService" - }, - "location": { - "value": "[parameters('location')]" - }, - "appName": { - "value": "[parameters('appName')]" - }, - "environment": { - "value": "[parameters('environment')]" - }, - "serviceResourceID": { - "value": "[resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName'))]" - }, - "subnetId": { - "value": "[parameters('privateEndpointSubnetId')]" - }, - "groupIDs": { - "value": [ - "account" - ] - }, - "privateDnsZoneIds": { - "value": [ - "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" - ] - }, - "tags": { - "value": "[parameters('tags')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18226293550364152784" - } - }, - "parameters": { - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "appName": { - "type": "string" - }, - "environment": { - "type": "string" - }, - "serviceResourceID": { - "type": "string" - }, - "subnetId": { - "type": "string" - }, - "groupIDs": { - "type": "array" - }, - "privateDnsZoneIds": { - "type": "array", - "defaultValue": [] - }, - "tags": { - "type": "object" - } - }, - "resources": [ - { - "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2021-05-01", - "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", - "properties": { - "copy": [ - { - "name": "privateDnsZoneConfigs", - "count": "[length(parameters('privateDnsZoneIds'))]", - "input": { - "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" - } - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2021-05-01", - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "location": "[parameters('location')]", - "properties": { - "subnet": { - "id": "[parameters('subnetId')]" - }, - "privateLinkServiceConnections": [ - { - "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", - "properties": { - "privateLinkServiceId": "[parameters('serviceResourceID')]", - "groupIds": "[parameters('groupIDs')]" - } - } - ], - "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" - }, - "tags": "[parameters('tags')]" - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" - ] - } - ] - } - }, - "dependsOn": [ - "acr", - "appService", - "contentSafety", - "cosmosDB", - "docIntel", - "keyVault", - "openAI", - "rg", - "searchService", - "speechService", - "storageAccount", - "videoIndexerService", - "virtualNetwork" - ] - } - }, - "outputs": { - "var_acrName": { - "type": "string", - "value": "[toLower(format('{0}{1}acr', parameters('appName'), parameters('environment')))]" - }, - "var_authenticationType": { - "type": "string", - "value": "[toLower(parameters('authenticationType'))]" - }, - "var_blobStorageEndpoint": { - "type": "string", - "value": "[reference('storageAccount').outputs.endpoint.value]" - }, - "var_configureApplication": { - "type": "bool", - "value": "[parameters('configureApplicationPermissions')]" - }, - "var_contentSafetyEndpoint": { - "type": "string", - "value": "[if(parameters('deployContentSafety'), reference('contentSafety').outputs.contentSafetyEndpoint.value, '')]" - }, - "var_cosmosDb_accountName": { - "type": "string", - "value": "[reference('cosmosDB').outputs.cosmosDbName.value]" - }, - "var_cosmosDb_uri": { - "type": "string", - "value": "[reference('cosmosDB').outputs.cosmosDbUri.value]" - }, - "var_deploymentLocation": { - "type": "string", - "value": "[reference('rg', '2022-09-01', 'full').location]" - }, - "var_documentIntelligenceServiceEndpoint": { - "type": "string", - "value": "[reference('docIntel').outputs.documentIntelligenceServiceEndpoint.value]" - }, - "var_keyVaultName": { - "type": "string", - "value": "[reference('keyVault').outputs.keyVaultName.value]" - }, - "var_keyVaultUri": { - "type": "string", - "value": "[reference('keyVault').outputs.keyVaultUri.value]" - }, - "var_openAIEndpoint": { - "type": "string", - "value": "[reference('openAI').outputs.openAIEndpoint.value]" - }, - "var_openAIGPTModels": { - "type": "array", - "value": "[variables('resolvedGptModels')]" - }, - "var_openAIResourceGroup": { - "type": "string", - "value": "[reference('openAI').outputs.openAIResourceGroup.value]" - }, - "var_openAIEmbeddingModels": { - "type": "array", - "value": "[variables('resolvedEmbeddingModels')]" - }, - "var_openAISubscriptionId": { - "type": "string", - "value": "[reference('openAI').outputs.openAISubscriptionId.value]" - }, - "var_redisCacheHostName": { - "type": "string", - "value": "[if(parameters('deployRedisCache'), reference('redisCache').outputs.redisCacheHostName.value, '')]" - }, - "var_rgName": { - "type": "string", - "value": "[variables('rgName')]" - }, - "var_searchServiceEndpoint": { - "type": "string", - "value": "[reference('searchService').outputs.searchServiceEndpoint.value]" - }, - "var_speechServiceEndpoint": { - "type": "string", - "value": "[if(parameters('deploySpeechService'), reference('speechService').outputs.speechServiceEndpoint.value, '')]" - }, - "var_subscriptionId": { - "type": "string", - "value": "[subscription().subscriptionId]" - }, - "var_videoIndexerAccountId": { - "type": "string", - "value": "[if(parameters('deployVideoIndexerService'), reference('videoIndexerService').outputs.videoIndexerAccountId.value, '')]" - }, - "var_videoIndexerName": { - "type": "string", - "value": "[if(parameters('deployVideoIndexerService'), reference('videoIndexerService').outputs.videoIndexerServiceName.value, '')]" - }, - "var_containerRegistry": { - "type": "string", - "value": "[variables('containerRegistry')]" - }, - "var_imageName": { - "type": "string", - "value": "[if(contains(parameters('imageName'), ':'), split(parameters('imageName'), ':')[0], parameters('imageName'))]" - }, - "var_imageTag": { - "type": "string", - "value": "[if(contains(parameters('imageName'), ':'), split(parameters('imageName'), ':')[1], 'latest')]" - }, - "var_webService": { - "type": "string", - "value": "[reference('appService').outputs.name.value]" - }, - "var_enablePrivateNetworking": { - "type": "bool", - "value": "[parameters('enablePrivateNetworking')]" - } - } -} -2026/03/25 11:27:00 main.go:54: Retry: =====> Try=1 for GET https://graph.microsoft.com/v1.0/me -2026/03/25 11:27:00 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://graph.microsoft.com/v1.0/me - Authorization: REDACTED - User-Agent: azsdk-go-graph/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: REDACTED - -2026/03/25 11:27:00 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/92.7349ms, OpTime=92.7349ms) -- RESPONSE RECEIVED - GET https://graph.microsoft.com/v1.0/me - Authorization: REDACTED - User-Agent: azsdk-go-graph/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: REDACTED - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Client-Request-Id: REDACTED - Content-Type: application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8 - Date: Wed, 25 Mar 2026 15:26:59 GMT - Odata-Version: REDACTED - Request-Id: 5ab22ea1-ede5-4086-bddd-2055a09cf554 - Strict-Transport-Security: REDACTED - Vary: REDACTED - X-Cache: REDACTED - X-Ms-Ags-Diagnostic: REDACTED - X-Ms-Resource-Unit: REDACTED - -2026/03/25 11:27:00 main.go:54: Retry: response 200 -2026/03/25 11:27:00 main.go:54: Retry: exit due to non-retriable status code -Reading subscription and location from environment... -2026/03/25 11:27:00 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/locations?api-version=2022-12-01 -2026/03/25 11:27:00 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/locations?api-version=2022-12-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armsubscriptions/v1.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:27:01 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/1.185824s, OpTime=1.1868244s) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/locations?api-version=2022-12-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armsubscriptions/v1.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 47870 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:27:01 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 41fec410-15ce-48ee-b137-00e1f31b8a7b - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:27:01 main.go:54: Retry: response 200 -2026/03/25 11:27:01 main.go:54: Retry: exit due to non-retriable status code -Subscription: TeamOne-AppAlpha-Dev (9698dd71-9367-49c2-bede-fd0deecfad62) -Location: North Central US - -Creating a deployment plan -2026/03/25 11:27:01 main.go:54: Retry: =====> Try=1 for GET https://graph.microsoft.com/v1.0/me -2026/03/25 11:27:01 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://graph.microsoft.com/v1.0/me - Authorization: REDACTED - User-Agent: azsdk-go-graph/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: REDACTED - -2026/03/25 11:27:01 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/90.9621ms, OpTime=90.9621ms) -- RESPONSE RECEIVED - GET https://graph.microsoft.com/v1.0/me - Authorization: REDACTED - User-Agent: azsdk-go-graph/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: REDACTED - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Client-Request-Id: REDACTED - Content-Type: application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8 - Date: Wed, 25 Mar 2026 15:27:01 GMT - Odata-Version: REDACTED - Request-Id: 9a51504a-7e5b-4179-a59b-f419816ac21b - Strict-Transport-Security: REDACTED - Vary: REDACTED - X-Cache: REDACTED - X-Ms-Ags-Diagnostic: REDACTED - X-Ms-Resource-Unit: REDACTED - -2026/03/25 11:27:01 main.go:54: Retry: response 200 -2026/03/25 11:27:01 main.go:54: Retry: exit due to non-retriable status code -Comparing deployment state -2026/03/25 11:27:01 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/providers/Microsoft.Resources/deployments/?api-version=2021-04-01 -2026/03/25 11:27:01 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/providers/Microsoft.Resources/deployments/?api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:27:06 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/4.6774449s, OpTime=4.6774449s) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/providers/Microsoft.Resources/deployments/?api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 5368968 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:27:05 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: b7473750-b912-461e-be99-023d72950045 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:27:06 main.go:54: Retry: response 200 -2026/03/25 11:27:06 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:27:06 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/providers/Microsoft.Resources/deployments/?%24skiptoken=REDACTED&api-version=2021-04-01 -2026/03/25 11:27:06 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/providers/Microsoft.Resources/deployments/?%24skiptoken=REDACTED&api-version=2021-04-01 - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:27:08 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/1.2315903s, OpTime=1.2315903s) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/providers/Microsoft.Resources/deployments/?%24skiptoken=REDACTED&api-version=2021-04-01 - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 553183 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:27:07 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: b97d201b-dc4e-48b5-a613-5e0b38ff1c29 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:27:08 main.go:54: Retry: response 200 -2026/03/25 11:27:08 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:27:08 deployment_manager.go:124: completedDeployments: matched deployment 'test-1774451207' using layerName: -2026/03/25 11:27:08 main.go:54: Retry: =====> Try=1 for POST https://management.azure.com/providers/Microsoft.Resources/calculateTemplateHash?api-version=2021-04-01 -2026/03/25 11:27:08 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - POST https://management.azure.com/providers/Microsoft.Resources/calculateTemplateHash?api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - Content-Length: 211849 - Content-Type: application/json - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:27:08 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/194.0207ms, OpTime=194.0207ms) -- RESPONSE RECEIVED - POST https://management.azure.com/providers/Microsoft.Resources/calculateTemplateHash?api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - Content-Length: 211849 - Content-Type: application/json - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 229612 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:27:07 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Tenant-Writes: REDACTED - X-Ms-Request-Id: 06b94a5c-3b18-445f-8b3b-ae20db6eb189 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:27:08 main.go:54: Retry: response 200 -2026/03/25 11:27:08 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:27:08 bicep_provider.go:607: deployment-state: : Previous deployment state is equal to current deployment. Deployment can be skipped. -2026/03/25 11:27:08 main.go:54: Retry: =====> Try=1 for HEAD https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg?api-version=2025-03-01 -2026/03/25 11:27:08 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - HEAD https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg?api-version=2025-03-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:27:08 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/82.8467ms, OpTime=82.8467ms) -- RESPONSE RECEIVED - HEAD https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg?api-version=2025-03-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 204 No Content - Cache-Control: no-cache - Content-Length: 0 - Date: Wed, 25 Mar 2026 15:27:07 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: aa98a7a9-0955-4ee9-9127-aac1078a4cf0 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:27:08 main.go:54: Retry: response 204 -2026/03/25 11:27:08 main.go:54: Retry: exit due to non-retriable status code - (-) Skipped: Didn't find new changes. -2026/03/25 11:27:10 hooks_runner.go:195: Executing script 'C:\Users\PAULLI~1\AppData\Local\Temp\azd-postprovision-83652536.ps1' -2026/03/25 11:28:04 command_runner.go:325: Run exec: 'pwsh C:\Users\PAULLI~1\AppData\Local\Temp\azd-postprovision-83652536.ps1' , exit code: 0 -Additional env: - var_keyVaultName= - AZURE_SUBSCRIPTION_ID= - var_openAIEmbeddingModels= - var_videoIndexerAccountId= - var_deploymentLocation= - AZURE_LOCATION= - var_keyVaultUri= - var_openAIEndpoint= - var_imageName= - var_openAIGPTModels= - var_openAISubscriptionId= - var_cosmosDb_accountName= - var_imageTag= - var_acrName= - var_containerRegistry= - var_contentSafetyEndpoint= - var_webService= - var_openAIResourceGroup= - var_rgName= - var_videoIndexerName= - var_speechServiceEndpoint= - var_cosmosDb_uri= - var_enablePrivateNetworking= - var_configureApplication= - var_searchServiceEndpoint= - var_subscriptionId= - var_blobStorageEndpoint= - var_authenticationType= - var_documentIntelligenceServiceEndpoint= - AZURE_ENV_NAME= - var_redisCacheHostName= --------------------------------------stdout------------------------------------------- -======================================= -POST-PROVISION: Starting configuration -======================================= - -[1/4] Granting permissions to CosmosDB... -WARNING: Azure CLI requires multi-factor authentication before it can create the Cosmos DB control-plane role assignment. -WARNING: Continuing with Cosmos DB key-based post-deployment configuration. -WARNING: Azure CLI requires multi-factor authentication before it can create the Cosmos DB data-plane role assignment. -WARNING: Continuing with Cosmos DB key-based post-deployment configuration. -Γ£ô CosmosDB permissions granted successfully - -[2/4] Installing Python dependencies... -Γ£ô Dependencies installed successfully -Γ£ô Deployment runner already has CosmosDB data-plane access - -[3/4] Running post-deployment configuration... -Found existing app_setting document -Retrieved Azure OpenAI key -Retrieved Azure AI Search admin key -Retrieved Azure Document Intelligence key -Retrieved Redis cache primary key -Retrieved Content Safety key -Retrieved Speech Service key -Updated item: app_settings with enable_external_healthcheck = True -Γ£ô Post-deployment configuration completed - -[4/4] Restarting web service to apply settings... -Γ£ô Web service restarted successfully - -======================================= -POST-PROVISION: Completed successfully -======================================= -2026/03/25 11:28:04 middleware.go:100: running middleware 'debug' -2026/03/25 11:28:04 middleware.go:100: running middleware 'ux' -2026/03/25 11:28:04 middleware.go:100: running middleware 'telemetry' -2026/03/25 11:28:04 telemetry.go:68: TraceID: dd6b9467838cd44356a50058eb0073ab -2026/03/25 11:28:04 middleware.go:100: running middleware 'error' -2026/03/25 11:28:04 middleware.go:100: running middleware 'loginGuard' -2026/03/25 11:28:04 middleware.go:100: running middleware 'hooks' -2026/03/25 11:28:04 hooks.go:130: service 'web' does not require any command hooks. -2026/03/25 11:28:04 hooks_runner.go:195: Executing script 'C:\Users\PAULLI~1\AppData\Local\Temp\azd-predeploy-2815995305.ps1' -2026/03/25 11:31:43 command_runner.go:325: Run exec: 'pwsh C:\Users\PAULLI~1\AppData\Local\Temp\azd-predeploy-2815995305.ps1' , exit code: 0 -Additional env: - var_configureApplication= - var_containerRegistry= - var_searchServiceEndpoint= - var_cosmosDb_accountName= - var_webService= - var_blobStorageEndpoint= - var_acrName= - var_openAIGPTModels= - var_openAIEmbeddingModels= - var_imageName= - AZURE_ENV_NAME= - var_redisCacheHostName= - var_subscriptionId= - var_authenticationType= - var_speechServiceEndpoint= - var_openAISubscriptionId= - AZURE_SUBSCRIPTION_ID= - var_imageTag= - var_keyVaultName= - var_rgName= - var_cosmosDb_uri= - var_deploymentLocation= - AZURE_LOCATION= - var_openAIEndpoint= - var_enablePrivateNetworking= - var_keyVaultUri= - var_openAIResourceGroup= - var_videoIndexerAccountId= - var_documentIntelligenceServiceEndpoint= - var_videoIndexerName= - var_contentSafetyEndpoint= --------------------------------------stdout------------------------------------------- -======================================= -PRE-DEPLOY: Building and pushing image -======================================= - -Deployment timestamp: 20260325-112809 -Image: simplechattestacr.azurecr.io/simplechat:20260325-112809 - -[1/4] Stopping web service... -Γ£ô Web service stopped successfully - -[2/4] Building image in ACR Tasks... -Context: C:\repos\simplechat -Dockerfile: application/single_app/Dockerfile -Queued ACR build run ID: cp6 -Γ£ô ACR build run cp6 completed successfully -Γ£ô ACR image build completed successfully - -[3/4] Restarting web service... -Γ£ô Web service restarted successfully - -[4/4] Image build and restart complete - -> Published simplechat:20260325-112809 - -> Published simplechat:latest - -======================================= -PRE-DEPLOY: Completed successfully -======================================= --------------------------------------stderr------------------------------------------- -WARNING: Packing source code into tar to upload... -WARNING: Excluding '.git' based on default ignore rules -WARNING: Excluding '.gitignore' based on default ignore rules -WARNING: Excluding '.venv' directory for performance -WARNING: Excluding 'application/external_apps/bulkloader/.gitignore' based on default ignore rules -WARNING: Excluding 'application/external_apps/databaseseeder/.gitignore' based on default ignore rules -WARNING: Excluding 'application/single_app/.gitignore' based on default ignore rules -WARNING: Excluding 'application/single_app/uploaded_openapi_files/.gitignore' based on default ignore rules -WARNING: Excluding 'deployers/.azure/.gitignore' based on default ignore rules -WARNING: Excluding 'deployers/terraform/.gitignore' based on default ignore rules -WARNING: Excluding 'docs/.gitignore' based on default ignore rules -WARNING: Uploading archived source code from 'C:\Users\PAULLI~1\AppData\Local\Temp\build_archive_be4ddbe76f214cf7b6a4ddc12e7a4397.tar.gz'... -WARNING: Sending context (37.775 MiB) to registry: simplechattestacr... -WARNING: Queued a build with ID: cp6 -WARNING: Waiting for an agent... -2026/03/25 11:31:43 middleware.go:100: running middleware 'extensions' -2026/03/25 11:31:43 service_manager.go:677: Attempting to resolve language 'python' for service 'web' -2026/03/25 11:31:43 service_manager.go:696: Successfully resolved language 'python' for service 'web' - -Deploying services (azd deploy) - -Deploying service web -2026/03/25 11:31:43 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 -2026/03/25 11:31:43 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:43 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/237.4544ms, OpTime=237.4544ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 355 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:42 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 377f4db1-f2d8-4742-b6ed-678e051e8166 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:31:43 main.go:54: Retry: response 200 -2026/03/25 11:31:43 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:44 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/resources?%24filter=REDACTED&api-version=2021-04-01 -2026/03/25 11:31:44 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/resources?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:44 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/326.1582ms, OpTime=326.1582ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/resources?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 553 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:43 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 1065b58a-e562-4f4f-a59b-6046f8164af4 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:31:44 main.go:54: Retry: response 200 -2026/03/25 11:31:44 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:44 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 -2026/03/25 11:31:44 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:44 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/216.588ms, OpTime=216.588ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 355 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:43 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 2a03bd82-4600-4ccf-9040-287ffc7c44ff - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:31:44 main.go:54: Retry: response 200 -2026/03/25 11:31:44 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:44 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/resources?%24filter=REDACTED&api-version=2021-04-01 -2026/03/25 11:31:44 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/resources?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:44 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/214.1772ms, OpTime=214.1772ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/resources?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 553 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:43 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 5c8a81da-3a20-46f2-a8cd-6a28c8b80237 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:31:44 main.go:54: Retry: response 200 -2026/03/25 11:31:44 main.go:54: Retry: exit due to non-retriable status code -Deploying service web (Checking deployment history) -2026/03/25 11:31:44 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/deployments?api-version=2023-01-01 -2026/03/25 11:31:44 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/deployments?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:45 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/918.6488ms, OpTime=918.6488ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/deployments?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 5502 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:44 GMT - Etag: "8de8a82d6ef09c3" - Expires: -1 - Pragma: no-cache - Set-Cookie: REDACTED - Strict-Transport-Security: REDACTED - Vary: REDACTED - X-Aspnet-Version: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Operation-Identifier: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 305370c3-9ddf-4fd9-8669-5e72240651ec - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - X-Powered-By: REDACTED - -2026/03/25 11:31:45 main.go:54: Retry: response 200 -2026/03/25 11:31:45 main.go:54: Retry: exit due to non-retriable status code -Deploying service web (Checking deployment slots) -2026/03/25 11:31:45 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/slots?api-version=2023-01-01 -2026/03/25 11:31:45 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/slots?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:45 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/183.0457ms, OpTime=183.6845ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/slots?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 12 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:44 GMT - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Aspnet-Version: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Original-Request-Ids: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: caeb3f81-9860-4933-8adf-3ae37a61103d - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - X-Powered-By: REDACTED - -2026/03/25 11:31:45 main.go:54: Retry: response 200 -2026/03/25 11:31:45 main.go:54: Retry: exit due to non-retriable status code -Deploying service web (Uploading deployment package) -2026/03/25 11:31:45 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app?api-version=2023-01-01 -2026/03/25 11:31:45 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:46 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/200.3026ms, OpTime=200.3026ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 9431 - Content-Type: application/json - Date: Wed, 25 Mar 2026 15:31:44 GMT - Etag: 1DCBC6C7B49594B - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Aspnet-Version: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 5c985d08-5e74-4206-b9bb-2b963c7523d2 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - X-Powered-By: REDACTED - -2026/03/25 11:31:46 main.go:54: Retry: response 200 -2026/03/25 11:31:46 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:46 main.go:54: Retry: =====> Try=1 for POST https://simplechat-test-app.scm.azurewebsites.net/api/zipdeploy?isAsync=REDACTED -2026/03/25 11:31:46 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - POST https://simplechat-test-app.scm.azurewebsites.net/api/zipdeploy?isAsync=REDACTED - Accept: application/json - Authorization: REDACTED - Content-Length: 5267162 - Content-Type: application/octet-stream - User-Agent: azsdk-go-zip-deploy/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:47 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/855.1784ms, OpTime=855.1784ms) -- RESPONSE RECEIVED - POST https://simplechat-test-app.scm.azurewebsites.net/api/zipdeploy?isAsync=REDACTED - Accept: application/json - Authorization: REDACTED - Content-Length: 5267162 - Content-Type: application/octet-stream - User-Agent: azsdk-go-zip-deploy/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 202 Accepted - Content-Length: 0 - Date: Wed, 25 Mar 2026 15:31:46 GMT - Location: REDACTED - Retryafter: REDACTED - Scm-Deployment-Id: REDACTED - Server: Kestrel - Set-Cookie: REDACTED - -2026/03/25 11:31:47 main.go:54: Retry: response 202 -2026/03/25 11:31:47 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:47 main.go:54: LongRunningOperation: BEGIN PollUntilDone() for *azsdk.deployPollingHandler -2026/03/25 11:31:47 main.go:54: Retry: =====> Try=1 for GET https://simplechat-test-app.scm.azurewebsites.net:443/api/deployments/latest?deployer=REDACTED&time=REDACTED -2026/03/25 11:31:47 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://simplechat-test-app.scm.azurewebsites.net:443/api/deployments/latest?deployer=REDACTED&time=REDACTED - Authorization: REDACTED - User-Agent: azsdk-go-zip-deploy/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:47 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/738.3633ms, OpTime=738.3633ms) -- RESPONSE RECEIVED - GET https://simplechat-test-app.scm.azurewebsites.net:443/api/deployments/latest?deployer=REDACTED&time=REDACTED - Authorization: REDACTED - User-Agent: azsdk-go-zip-deploy/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 202 Accepted - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:47 GMT - Location: REDACTED - Server: Kestrel - Set-Cookie: REDACTED - Vary: REDACTED - -2026/03/25 11:31:47 main.go:54: Retry: response 202 -2026/03/25 11:31:47 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:47 main.go:54: LongRunningOperation: delay for 10s -2026/03/25 11:31:57 main.go:54: Retry: =====> Try=1 for GET https://simplechat-test-app.scm.azurewebsites.net:443/api/deployments/latest?deployer=REDACTED&time=REDACTED -2026/03/25 11:31:57 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://simplechat-test-app.scm.azurewebsites.net:443/api/deployments/latest?deployer=REDACTED&time=REDACTED - Authorization: REDACTED - User-Agent: azsdk-go-zip-deploy/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:58 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/545.0668ms, OpTime=545.0668ms) -- RESPONSE RECEIVED - GET https://simplechat-test-app.scm.azurewebsites.net:443/api/deployments/latest?deployer=REDACTED&time=REDACTED - Authorization: REDACTED - User-Agent: azsdk-go-zip-deploy/1.0.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:57 GMT - Server: Kestrel - Set-Cookie: REDACTED - Vary: REDACTED - -2026/03/25 11:31:58 main.go:54: Retry: response 200 -2026/03/25 11:31:58 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:58 main.go:54: LongRunningOperation: END PollUntilDone() for *azsdk.deployPollingHandler: succeeded, total time: 11.2853145s -Deploying service web (Fetching endpoints for app service) -2026/03/25 11:31:58 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app?api-version=2023-01-01 -2026/03/25 11:31:58 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:59 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/1.0207185s, OpTime=1.0207185s) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 9431 - Content-Type: application/json - Date: Wed, 25 Mar 2026 15:31:58 GMT - Etag: 1DCBC6C7B49594B - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Aspnet-Version: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 63ac1b17-ddbd-4502-acf5-19ac8946e0a1 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - X-Powered-By: REDACTED - -2026/03/25 11:31:59 main.go:54: Retry: response 200 -2026/03/25 11:31:59 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:59 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/slots?api-version=2023-01-01 -2026/03/25 11:31:59 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/slots?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:59 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/107.9592ms, OpTime=107.9592ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourceGroups/simplechat-test-rg/providers/Microsoft.Web/sites/simplechat-test-app/slots?api-version=2023-01-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armappservice/v2.3.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 12 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:58 GMT - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Aspnet-Version: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Original-Request-Ids: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: 03f223a9-5640-484d-b8ab-56c6e855c962 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - X-Powered-By: REDACTED - -2026/03/25 11:31:59 main.go:54: Retry: response 200 -2026/03/25 11:31:59 main.go:54: Retry: exit due to non-retriable status code - (Γ£ô) Done: Deploying service web - - Endpoint: https://simplechat-test-app.azurewebsites.net/ - -2026/03/25 11:31:59 main.go:54: Retry: =====> Try=1 for GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 -2026/03/25 11:31:59 main.go:54: Request: ==> OUTGOING REQUEST (Try=1) - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -2026/03/25 11:31:59 main.go:54: Response: ==> REQUEST/RESPONSE (Try=1/199.4013ms, OpTime=199.4013ms) -- RESPONSE RECEIVED - GET https://management.azure.com/subscriptions/9698dd71-9367-49c2-bede-fd0deecfad62/resourcegroups?%24filter=REDACTED&api-version=2021-04-01 - Accept: application/json - Authorization: REDACTED - User-Agent: azsdk-go-armresources/v1.2.0 (go1.26.0-X:loopvar; Windows_NT),azdev/1.23.12 (Go go1.26.0-X:loopvar; windows/amd64) - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - -------------------------------------------------------------------------------- - RESPONSE Status: 200 OK - Cache-Control: no-cache - Content-Length: 355 - Content-Type: application/json; charset=utf-8 - Date: Wed, 25 Mar 2026 15:31:58 GMT - Expires: -1 - Pragma: no-cache - Strict-Transport-Security: REDACTED - X-Cache: REDACTED - X-Content-Type-Options: REDACTED - X-Ms-Correlation-Request-Id: dd6b9467838cd44356a50058eb0073ab - X-Ms-Ratelimit-Remaining-Subscription-Global-Reads: REDACTED - X-Ms-Ratelimit-Remaining-Subscription-Reads: REDACTED - X-Ms-Request-Id: b04b1fc9-1f4f-4122-b727-ca1726135276 - X-Ms-Routing-Request-Id: REDACTED - X-Msedge-Ref: REDACTED - -2026/03/25 11:31:59 main.go:54: Retry: response 200 -2026/03/25 11:31:59 main.go:54: Retry: exit due to non-retriable status code -2026/03/25 11:31:59 hooks_runner.go:195: Executing script 'C:\Users\PAULLI~1\AppData\Local\Temp\azd-postup-2869181376.ps1' -2026/03/25 11:32:05 command_runner.go:325: Run exec: 'pwsh C:\Users\PAULLI~1\AppData\Local\Temp\azd-postup-2869181376.ps1' , exit code: 0 -Additional env: - var_documentIntelligenceServiceEndpoint= - var_speechServiceEndpoint= - var_keyVaultName= - var_enablePrivateNetworking= - var_redisCacheHostName= - var_contentSafetyEndpoint= - var_videoIndexerAccountId= - var_keyVaultUri= - var_acrName= - var_imageTag= - var_cosmosDb_accountName= - var_blobStorageEndpoint= - var_searchServiceEndpoint= - AZURE_ENV_NAME= - var_webService= - var_openAIResourceGroup= - AZURE_LOCATION= - AZURE_SUBSCRIPTION_ID= - var_imageName= - var_videoIndexerName= - var_openAISubscriptionId= - var_subscriptionId= - var_openAIEmbeddingModels= - var_openAIEndpoint= - var_authenticationType= - var_configureApplication= - var_openAIGPTModels= - var_containerRegistry= - var_deploymentLocation= - var_rgName= - var_cosmosDb_uri= --------------------------------------stdout------------------------------------------- -======================================= -POST-UP: Final configuration -======================================= - -Skipping private networking configuration (var_enablePrivateNetworking is not true) - -======================================= -Γ£ô DEPLOYMENT COMPLETED SUCCESSFULLY -======================================= - -SUCCESS: Your up workflow to provision and deploy to Azure completed in 5 minutes 8 seconds. From 3e31570872cf7feadf256bf445920c3dc76c5e8a Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 29 Apr 2026 15:54:19 -0400 Subject: [PATCH 27/28] deployer versioning --- .github/copilot-instructions.md | 6 +- .../update_deployer_version.instructions.md | 19 +++++ CLAUDE.md | 2 + application/single_app/config.py | 2 +- deployers/version.txt | 1 + docs/explanation/features/index.md | 1 + .../v0.241.082/DEPLOYER_VERSION_TRACKING.md | 59 ++++++++++++++ .../test_azurecli_upgrade_script.py | 2 +- .../test_deployer_version_tracking.py | 80 +++++++++++++++++++ .../test_sql_container_odbc_runtime.py | 2 +- ...andard_chat_document_action_payload_fix.py | 6 +- 11 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 .github/instructions/update_deployer_version.instructions.md create mode 100644 deployers/version.txt create mode 100644 docs/explanation/features/v0.241.082/DEPLOYER_VERSION_TRACKING.md create mode 100644 functional_tests/test_deployer_version_tracking.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e6be37d..859fa74e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,4 +50,8 @@ Make code changes only if you have high confidence they can solve the problem. W Confirm the root cause is fixed. Review your solution for logic correctness and robustness. Iterate until you are extremely confident the fix is complete and all tests pass. 7. Final Reflection and Additional Testing -Reflect carefully on the original intent of the user and the problem statement. Think about potential edge cases or scenarios that may not be covered by existing tests. Write additional tests that would need to pass to fully validate the correctness of your solution. Run these new tests and ensure they all pass. Be aware that there are additional hidden tests that must also pass for the solution to be successful. Do not assume the task is complete just because the visible tests pass; continue refining until you are confident the fix is robust and comprehensive. \ No newline at end of file +Reflect carefully on the original intent of the user and the problem statement. Think about potential edge cases or scenarios that may not be covered by existing tests. Write additional tests that would need to pass to fully validate the correctness of your solution. Run these new tests and ensure they all pass. Be aware that there are additional hidden tests that must also pass for the solution to be successful. Do not assume the task is complete just because the visible tests pass; continue refining until you are confident the fix is robust and comprehensive. + +VERSIONING +Application versioning remains in `application/single_app/config.py`. +Deployer and CI/CD versioning lives separately in `deployers/version.txt`; when files under `deployers/` are modified, increment `deployers/version.txt` as part of the same change, defaulting to a patch bump unless a deliberate minor or major compatibility change is intended. \ No newline at end of file diff --git a/.github/instructions/update_deployer_version.instructions.md b/.github/instructions/update_deployer_version.instructions.md new file mode 100644 index 00000000..a68861ba --- /dev/null +++ b/.github/instructions/update_deployer_version.instructions.md @@ -0,0 +1,19 @@ +--- +applyTo: 'deployers/**' +--- + +# Deployer Version Management + +When a change modifies files under `deployers/`, include an update to `deployers/version.txt` in the same change. + +## Rules + +- Keep the deployer version separate from `application/single_app/config.py`. +- `deployers/version.txt` must contain only a plain semantic version string in the format `X.Y.Z`. +- Default to a patch increment when a deployer change is made: `1.0.0` -> `1.0.1`. +- Use a minor or major increment only when the deployer workflow or CI/CD compatibility contract changes intentionally. +- If the only deployer file being changed is `deployers/version.txt`, do not add an extra bump beyond the intended version update. + +## Applies To + +This rule covers deployer scripts, `azure.yaml`, `.azure` environment helpers, Bicep/Terraform deployer files, and other deployment workflow assets under `deployers/`. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2ad07a8a..78d57e5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,8 @@ return render_template('page.html', settings=public_settings) - Version is stored in `config.py`: `VERSION = "X.XXX.XXX"` - When incrementing, only change the third segment (e.g., `0.238.024` -> `0.238.025`) - Include the current version in functional test file headers and documentation files +- Deployer CI/CD logic version is tracked separately in `deployers/version.txt` +- When modifying files under `deployers/`, increment `deployers/version.txt`; default to a patch bump unless a deliberate deployment compatibility change warrants a minor or major increment ## Documentation Locations diff --git a/application/single_app/config.py b/application/single_app/config.py index a1a1ea9c..19871684 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.081" +VERSION = "0.241.083" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/deployers/version.txt b/deployers/version.txt new file mode 100644 index 00000000..afaf360d --- /dev/null +++ b/deployers/version.txt @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/docs/explanation/features/index.md b/docs/explanation/features/index.md index 35876518..7247c04d 100644 --- a/docs/explanation/features/index.md +++ b/docs/explanation/features/index.md @@ -17,6 +17,7 @@ category: Version History ## Versioned Features +- [Deployer Version Tracking](v0.241.082/DEPLOYER_VERSION_TRACKING.md) - [Azure CLI Upgrade Script](v0.241.079/AZURECLI_UPGRADE_SCRIPT.md) - [Core Document Search And Summarization](v0.241.007/CORE_DOCUMENT_SEARCH_AND_SUMMARIZATION.md) - [Exhaustive Document Review](v0.241.069/EXHAUSTIVE_DOCUMENT_REVIEW.md) diff --git a/docs/explanation/features/v0.241.082/DEPLOYER_VERSION_TRACKING.md b/docs/explanation/features/v0.241.082/DEPLOYER_VERSION_TRACKING.md new file mode 100644 index 00000000..45fb2882 --- /dev/null +++ b/docs/explanation/features/v0.241.082/DEPLOYER_VERSION_TRACKING.md @@ -0,0 +1,59 @@ +# Deployer Version Tracking + +Version: 0.241.082 + +Fixed/Implemented in version: **0.241.082** + +Dependencies: `deployers/version.txt`, `.github/instructions/update_deployer_version.instructions.md`, `CLAUDE.md`, `functional_tests/test_deployer_version_tracking.py`, `docs/explanation/features/index.md` + +## Overview + +This feature adds a standalone deployer version marker at `deployers/version.txt`. + +The deployer version is intentionally separate from the application version in `application/single_app/config.py`. It is meant for CI/CD logic tracking, deployment workflow compatibility checks, and future automation that needs to detect deployer changes without coupling that logic to app feature releases. + +## Technical Specifications + +The deployer version marker uses a plain text semantic version string with no prefixes, labels, or extra metadata. + +Current value: + +```text +1.0.0 +``` + +This format keeps the file easy to consume from shell scripts, PowerShell, GitHub Actions, Azure DevOps pipelines, or `azd`-adjacent helper scripts. + +The initial tracking model is: + +- bump the deployer version when CI/CD logic, deployer scripts, deployer configuration structure, or deployment workflow assumptions change +- keep the deployer version independent from the app version in `config.py` +- use the plain file content as the single source of truth for deployment-logic version checks + +The repository also includes a targeted instruction file at `.github/instructions/update_deployer_version.instructions.md` so agent-driven edits under `deployers/**` are expected to bump `deployers/version.txt` in the same change. + +## Usage Instructions + +Example reads from the repository root: + +```powershell +Get-Content .\deployers\version.txt +``` + +```bash +cat deployers/version.txt +``` + +CI/CD systems can compare this value against known-compatible deployer logic or stamp it into build metadata independently from the app container version. + +## Testing And Validation + +Coverage for this feature includes: + +- `functional_tests/test_deployer_version_tracking.py` to validate the standalone version file, the current app-version wiring for this feature, and the related documentation references +- the targeted deployer instruction file and repo guidance that require `deployers/version.txt` bumps when deployer files change +- direct repository inspection to verify the file lives under `deployers/` and stays separate from the application version source + +Known limitation: + +- the deployer version file is a tracking artifact only in this change; existing deployment scripts and pipelines are not yet auto-reading it unless they are updated to do so. \ No newline at end of file diff --git a/functional_tests/test_azurecli_upgrade_script.py b/functional_tests/test_azurecli_upgrade_script.py index 7448711f..07043cec 100644 --- a/functional_tests/test_azurecli_upgrade_script.py +++ b/functional_tests/test_azurecli_upgrade_script.py @@ -2,7 +2,7 @@ # test_azurecli_upgrade_script.py """ Functional test for Azure CLI code-only upgrade script. -Version: 0.241.081 +Version: 0.241.083 Implemented in: 0.241.079 This test ensures that the Azure CLI deployer includes a standalone upgrade diff --git a/functional_tests/test_deployer_version_tracking.py b/functional_tests/test_deployer_version_tracking.py new file mode 100644 index 00000000..308ac463 --- /dev/null +++ b/functional_tests/test_deployer_version_tracking.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# test_deployer_version_tracking.py +""" +Functional test for deployer version tracking. +Version: 0.241.083 +Implemented in: 0.241.083 + +This test ensures the deployers folder includes a standalone version marker +for CI/CD logic tracking that is separate from the single_app version. +""" + +from pathlib import Path +import re +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def read_workspace_file(relative_path: str) -> str: + """Read a workspace file as UTF-8 text.""" + return (REPO_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_deployer_version_tracking() -> bool: + """Validate deployer version tracking assets exist and stay decoupled.""" + print("🧪 Testing deployer version tracking") + print("=" * 70) + + config_content = read_workspace_file("application/single_app/config.py") + deployer_version = read_workspace_file("deployers/version.txt").strip() + deployer_instruction_content = read_workspace_file( + ".github/instructions/update_deployer_version.instructions.md" + ) + feature_doc_content = read_workspace_file( + "docs/explanation/features/v0.241.082/DEPLOYER_VERSION_TRACKING.md" + ) + feature_index_content = read_workspace_file("docs/explanation/features/index.md") + claude_content = read_workspace_file("CLAUDE.md") + + assert 'VERSION = "0.241.083"' in config_content, ( + "Expected config.py version 0.241.083 for the deployer version tracking feature follow-up." + ) + assert deployer_version == "1.0.0", "Expected deployers/version.txt to start at deployer version 1.0.0" + assert re.fullmatch(r"\d+\.\d+\.\d+", deployer_version), ( + "Expected deployers/version.txt to use a plain semantic version string." + ) + assert "applyTo: 'deployers/**'" in deployer_instruction_content, ( + "Expected a targeted instruction file for deployers/** edits." + ) + assert "include an update to `deployers/version.txt`" in deployer_instruction_content, ( + "Expected the deployer instruction file to require version bumps for deployer changes." + ) + assert "separate from the application version" in feature_doc_content, ( + "Expected feature doc to describe the deployer version as separate from the application version." + ) + assert "deployers/version.txt" in feature_doc_content, ( + "Expected feature doc to reference deployers/version.txt." + ) + assert "Deployer Version Tracking" in feature_index_content, ( + "Expected features index to list the deployer version tracking feature." + ) + assert "When modifying files under `deployers/`, increment `deployers/version.txt`" in claude_content, ( + "Expected CLAUDE.md to document deployer version bumps for deployer changes." + ) + + print("✅ Deployer version file exists with a CI/CD-friendly semantic version.") + print("✅ Instruction and repo guidance require deployer version bumps for deployer changes.") + print("✅ Feature documentation and index reference the standalone deployer version.") + return True + + +if __name__ == "__main__": + try: + success = test_deployer_version_tracking() + except Exception as ex: + print(f"❌ Test failed: {ex}") + raise + + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_sql_container_odbc_runtime.py b/functional_tests/test_sql_container_odbc_runtime.py index 70322242..b2ee1034 100644 --- a/functional_tests/test_sql_container_odbc_runtime.py +++ b/functional_tests/test_sql_container_odbc_runtime.py @@ -1,7 +1,7 @@ # test_sql_container_odbc_runtime.py """ Functional test for SQL container ODBC runtime packaging. -Version: 0.241.081 +Version: 0.241.083 Implemented in: 0.241.081 This test ensures that the application container packages the unixODBC runtime diff --git a/functional_tests/test_standard_chat_document_action_payload_fix.py b/functional_tests/test_standard_chat_document_action_payload_fix.py index e4c2e049..3475bbf3 100644 --- a/functional_tests/test_standard_chat_document_action_payload_fix.py +++ b/functional_tests/test_standard_chat_document_action_payload_fix.py @@ -2,7 +2,7 @@ # test_standard_chat_document_action_payload_fix.py """ Functional test for standard chat document action payload fix. -Version: 0.241.081 +Version: 0.241.083 Implemented in: 0.241.075 This test ensures standard chat omits disabled document-action payload fields @@ -27,8 +27,8 @@ def test_standard_chat_omits_disabled_document_action_payloads(): chat_messages_content = read_text("application/single_app/static/js/chat/chat-messages.js") feature_doc_content = read_text("docs/explanation/features/v0.241.072/DOCUMENT_ACTIONS_AND_COMPARISON.md") - assert 'VERSION = "0.241.081"' in config_content, ( - "Expected config.py version 0.241.081 for the search-documents label update." + assert 'VERSION = "0.241.083"' in config_content, ( + "Expected config.py version 0.241.083 for the search-documents label update." ) assert '`Search Documents` keeps the normal prompt flow while searching the selected documents for relevant context.' in feature_doc_content, ( "Expected the document actions feature doc to describe the renamed default search behavior." From e0a8adfe7ad5727385050ae2e10f78a9da78ed9e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 1 May 2026 07:58:32 -0400 Subject: [PATCH 28/28] fix global agent bug global agent was being classified as personal which meant that the user agent needed to be enabled for global agents to work too. --- application/single_app/config.py | 2 +- .../single_app/functions_agent_scope.py | 16 ++- .../single_app/semantic_kernel_loader.py | 36 ++++-- .../v0.241.007/GLOBAL_AGENT_SCOPE_GATE_FIX.md | 57 +++++++++ docs/explanation/release_notes.md | 10 ++ .../test_global_agent_scope_gate.py | 118 ++++++++++++++++++ 6 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 docs/explanation/fixes/v0.241.007/GLOBAL_AGENT_SCOPE_GATE_FIX.md create mode 100644 functional_tests/test_global_agent_scope_gate.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 7196cfe8..3ccb6ca9 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.006" +VERSION = "0.241.007" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_agent_scope.py b/application/single_app/functions_agent_scope.py index 660647b9..526c7d58 100644 --- a/application/single_app/functions_agent_scope.py +++ b/application/single_app/functions_agent_scope.py @@ -30,4 +30,18 @@ def scope_matches(candidate): if selected_agent_name: return next((agent for agent in agents_cfg if agent.get("name") == selected_agent_name and scope_matches(agent)), None) - return None \ No newline at end of file + return None + + +def is_selected_agent_scope_enabled(settings, selected_agent_data): + """Return whether app settings allow the selected agent's scope.""" + if not isinstance(selected_agent_data, dict): + return True + + if selected_agent_data.get("is_group", False): + return bool((settings or {}).get("allow_group_agents", False)) + + if selected_agent_data.get("is_global", False): + return True + + return bool((settings or {}).get("allow_user_agents", False)) \ No newline at end of file diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 3a2ca4b5..f66cfca7 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -47,7 +47,7 @@ from functions_agent_payload import can_agent_use_default_multi_endpoint_model from semantic_kernel_plugins.plugin_loader import discover_plugins from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory -from functions_agent_scope import find_agent_by_scope +from functions_agent_scope import find_agent_by_scope, is_selected_agent_scope_enabled import app_settings_cache # Agent and Azure OpenAI chat service imports @@ -1897,24 +1897,34 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie # Append selected group agent (if any) to the candidate list so downstream selection logic can resolve it selected_agent_data = selected_agent if isinstance(selected_agent, dict) else {} + selected_agent_is_global = selected_agent_data.get('is_global', False) selected_agent_is_group = selected_agent_data.get('is_group', False) selected_agent_group_id = selected_agent_data.get('group_id') conversation_group_id = getattr(g, "conversation_group_id", None) allow_user_agents = settings.get('allow_user_agents', False) allow_group_agents = settings.get('allow_group_agents', False) - if selected_agent_is_group and not allow_group_agents: - log_event( - "[SK Loader] Group agents are disabled; skipping group agent load.", - level=logging.WARNING - ) - load_core_plugins_only(kernel, settings) - return kernel, None - if not selected_agent_is_group and not allow_user_agents: - log_event( - "[SK Loader] User agents are disabled; skipping personal agent load.", - level=logging.WARNING - ) + if not is_selected_agent_scope_enabled(settings, selected_agent_data): + if selected_agent_is_group: + log_event( + "[SK Loader] Group agents are disabled; skipping group agent load.", + level=logging.WARNING, + extra={ + 'agent_name': selected_agent_data.get('name'), + 'allow_group_agents': allow_group_agents, + 'is_global': selected_agent_is_global, + } + ) + else: + log_event( + "[SK Loader] User agents are disabled; skipping personal agent load.", + level=logging.WARNING, + extra={ + 'agent_name': selected_agent_data.get('name'), + 'allow_user_agents': allow_user_agents, + 'is_global': selected_agent_is_global, + } + ) load_core_plugins_only(kernel, settings) return kernel, None diff --git a/docs/explanation/fixes/v0.241.007/GLOBAL_AGENT_SCOPE_GATE_FIX.md b/docs/explanation/fixes/v0.241.007/GLOBAL_AGENT_SCOPE_GATE_FIX.md new file mode 100644 index 00000000..6bb9cfc4 --- /dev/null +++ b/docs/explanation/fixes/v0.241.007/GLOBAL_AGENT_SCOPE_GATE_FIX.md @@ -0,0 +1,57 @@ +# GLOBAL_AGENT_SCOPE_GATE_FIX.md + +## Global Agent Scope Gate Fix (v0.241.007) + +Fixed/Implemented in version: **0.241.007** + +### Issue Description + +Per-user Semantic Kernel chats could silently fall back to the standard GPT model +when a user selected a global agent from the chat UI. The frontend showed no +error because the selection API accepted the agent and the streaming request +included that `agent_info`, but the backend still dropped into model-only mode. + +### Root Cause Analysis + +The per-user loader treated every non-group agent as a personal agent during the +scope gate check. When `allow_user_agents` was disabled and +`merge_global_semantic_kernel_with_workspace` was enabled, selected global agents +were blocked before the loader reached the global-agent merge and selection path. + +### Technical Details + +Files modified: +- `application/single_app/functions_agent_scope.py` +- `application/single_app/semantic_kernel_loader.py` +- `application/single_app/config.py` +- `functional_tests/test_global_agent_scope_gate.py` + +Code changes summary: +- Added `is_selected_agent_scope_enabled()` to centralize scope gating for + personal, global, and group agent selections. +- Updated `load_user_semantic_kernel()` so global agents bypass the + `allow_user_agents` toggle while personal and group agent rules remain intact. +- Added regression coverage for the global-agent bypass, group-agent enforcement, + and loader wiring. + +Testing approach: +- Added `functional_tests/test_global_agent_scope_gate.py` to validate the scope + helper behavior and confirm the per-user loader uses it. + +Impact analysis: +- Global agents selected in per-user chat mode now remain on the agent invocation + path instead of silently reverting to model-only GPT routing. +- Personal and group scope restrictions continue to behave as configured. + +### Validation + +Before: +- The backend logged `Using agent from request` and then immediately logged + `User agents are disabled; skipping personal agent load.` for global agents. +- Requests fell back to `Loading core plugins only for model-only mode...`. + +After: +- Global agent selections are no longer blocked by the personal-agent gate. +- Group selections still require `allow_group_agents`, and personal selections + still require `allow_user_agents`. +- The regression test protects the shared scope gate and its loader integration. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index da34cbad..bdc10fe4 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -4,6 +4,16 @@ This page tracks notable Simple Chat releases and organizes the detailed change For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.241.007)** + +#### Bug Fixes + +* **Global Agent Scope Gate Fallback** + * Fixed per-user Semantic Kernel chats so selecting a global agent no longer silently falls back to the standard GPT model when personal agents are disabled for the tenant. + * The per-user loader now treats global, personal, and group agent scopes separately, allowing valid global-agent selections to continue through agent invocation while keeping personal and group scope toggles enforced as configured. + * Added regression coverage for the shared scope gate used by the per-user loader. + * (Ref: `semantic_kernel_loader.py`, `functions_agent_scope.py`, `test_global_agent_scope_gate.py`, global agent request routing) + ### **(v0.241.006)** #### Bug Fixes diff --git a/functional_tests/test_global_agent_scope_gate.py b/functional_tests/test_global_agent_scope_gate.py new file mode 100644 index 00000000..0f7d21b9 --- /dev/null +++ b/functional_tests/test_global_agent_scope_gate.py @@ -0,0 +1,118 @@ +# test_global_agent_scope_gate.py +""" +Functional test for global agent scope gating in per-user Semantic Kernel mode. +Version: 0.241.007 +Implemented in: 0.241.007 + +This test ensures global agents remain eligible for loading even when personal +agent access is disabled, while personal and group scopes still respect their +own admin toggles. +""" + +import os +import sys + + +repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(repo_root) + +from application.single_app.functions_agent_scope import is_selected_agent_scope_enabled + + +def read_file_text(file_path): + with open(file_path, "r", encoding="utf-8") as file: + return file.read() + + +def test_global_agents_bypass_personal_toggle(): + """Ensure global agents are not blocked by the personal-agent toggle.""" + print("🔍 Validating global agent scope bypass...") + + settings = { + "allow_user_agents": False, + "allow_group_agents": False, + } + global_agent = { + "name": "beta_occ_document_summarization_agent", + "is_global": True, + "is_group": False, + } + personal_agent = { + "name": "personal-agent", + "is_global": False, + "is_group": False, + } + + assert is_selected_agent_scope_enabled(settings, global_agent) is True + assert is_selected_agent_scope_enabled(settings, personal_agent) is False + + print("✅ Global agent scope bypass passed.") + + +def test_group_agents_still_require_group_toggle(): + """Ensure group agents still honor the group-agent toggle.""" + print("🔍 Validating group agent scope enforcement...") + + settings = { + "allow_user_agents": True, + "allow_group_agents": False, + } + group_agent = { + "name": "group-agent", + "is_global": False, + "is_group": True, + "group_id": "group-a", + } + + assert is_selected_agent_scope_enabled(settings, group_agent) is False + + settings["allow_group_agents"] = True + assert is_selected_agent_scope_enabled(settings, group_agent) is True + + print("✅ Group agent scope enforcement passed.") + + +def test_loader_uses_scope_gate_helper(): + """Ensure the per-user loader uses the shared scope gate helper.""" + print("🔍 Validating loader wiring for shared scope gate helper...") + + loader_path = os.path.join( + repo_root, "application", "single_app", "semantic_kernel_loader.py" + ) + loader_text = read_file_text(loader_path) + + assert "is_selected_agent_scope_enabled(settings, selected_agent_data)" in loader_text, ( + "Expected semantic kernel loader to use the shared selected-agent scope helper." + ) + + print("✅ Loader wiring for scope gate helper passed.") + + +def run_tests(): + tests = [ + test_global_agents_bypass_personal_toggle, + test_group_agents_still_require_group_toggle, + test_loader_uses_scope_gate_helper, + ] + results = [] + + for test in tests: + print(f"\n🧪 Running {test.__name__}...") + try: + test() + print("✅ Test passed") + results.append(True) + except Exception as exc: + print(f"❌ Test failed: {exc}") + import traceback + + traceback.print_exc() + results.append(False) + + success = all(results) + print(f"\n📊 Results: {sum(results)}/{len(results)} tests passed") + return success + + +if __name__ == "__main__": + raise SystemExit(0 if run_tests() else 1) \ No newline at end of file