Skip to content

Commit ef9101b

Browse files
paullizerCopilot
andcommitted
Merge V2 (#1585) into phase 6a
Brings in #1585 (0.261.210). The only conflict was VERSION in config.py, kept at phase 6a's 0.261.213. The settings hunks in functions_settings.py and admin_settings_fields.py merged cleanly. The regenerated docs inventory is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents e80cda3 + 4f77838 commit ef9101b

35 files changed

Lines changed: 4179 additions & 289 deletions

‎application/external_apps/databaseseeder/artifacts/admin_settings.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"enable_image_generation": false,
3838
"enable_image_gen_apim": false,
3939
"azure_openai_image_gen_endpoint": "",
40-
"azure_openai_image_gen_api_version": "2024-05-01-preview",
40+
"azure_openai_image_gen_api_version": "2025-04-01-preview",
4141
"azure_openai_image_gen_authentication_type": "key",
4242
"azure_openai_image_gen_subscription_id": "",
4343
"azure_openai_image_gen_resource_group": "",

‎application/single_app/admin_settings_fields.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2320,8 +2320,8 @@
23202320
"type": "switch",
23212321
"label": "Enable Multi-Modal Vision Analysis",
23222322
"help": (
2323-
"Sends page images to a vision-capable model so figures, charts and "
2324-
"scanned pages are described and indexed rather than skipped."
2323+
"Asks a vision-capable model to describe each uploaded image, so the image "
2324+
"is indexed and can be cited by what it shows even when it contains no text."
23252325
),
23262326
"default": False,
23272327
"role": "capability",
@@ -5923,7 +5923,7 @@
59235923
"only; a chat deployment reached through the Responses image tool uses a "
59245924
"version new enough for that route regardless of what is set here."
59255925
),
5926-
"default": "2024-12-01-preview",
5926+
"default": "2025-04-01-preview",
59275927
"depends_on": [
59285928
{"key": "enable_image_generation", "equals": True},
59295929
{"key": "enable_image_gen_apim", "equals": False},
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# functions_chat_upload_extraction.py
2+
"""Describe what ingestion extracted from the workspace document behind a chat upload.
3+
4+
A file uploaded into a conversation is stored as a workspace document, so the chat message
5+
itself carries no text. Opening the upload therefore has to read what ingestion produced for
6+
that document: the indexed text that search and chat actually use, and the structured image
7+
analysis that was saved on the document record but was never shown anywhere.
8+
9+
Everything here is pure so the route can stay thin and the shaping can be tested without
10+
Cosmos DB, AI Search, or Azure credentials.
11+
"""
12+
13+
WORKSPACE_UPLOAD_FILE_CONTENT_SOURCE = "workspace"
14+
NO_EXTRACTED_CONTENT_MESSAGE = "No extracted content is available for this file yet."
15+
STILL_PROCESSING_MESSAGE = "This file is still being processed. Try again when it finishes."
16+
PROCESSING_FAILED_MESSAGE = (
17+
"Processing this file failed, so nothing was extracted. Reprocess or re-upload it to try again."
18+
)
19+
20+
21+
def _clean_text(value):
22+
return value.strip() if isinstance(value, str) else ""
23+
24+
25+
def _clean_list(values):
26+
if isinstance(values, str):
27+
values = [values]
28+
if not isinstance(values, (list, tuple)):
29+
return []
30+
return [value.strip() for value in values if isinstance(value, str) and value.strip()]
31+
32+
33+
def summarize_vision_analysis(vision_analysis):
34+
"""Return the displayable parts of a stored vision analysis, or None when it has none.
35+
36+
An analysis that records an error is excluded, because its description is the error
37+
message rather than anything the model saw in the image.
38+
"""
39+
if not isinstance(vision_analysis, dict) or vision_analysis.get("error"):
40+
return None
41+
42+
summary = {
43+
"model": _clean_text(vision_analysis.get("model") or vision_analysis.get("model_name")),
44+
"description": _clean_text(vision_analysis.get("description")),
45+
"objects": _clean_list(vision_analysis.get("objects")),
46+
"text": _clean_text(vision_analysis.get("text")),
47+
"analysis": _clean_text(
48+
vision_analysis.get("analysis") or vision_analysis.get("contextual_analysis")
49+
),
50+
}
51+
if not any(summary[key] for key in ("description", "objects", "text", "analysis")):
52+
return None
53+
return summary
54+
55+
56+
def render_vision_analysis_text(summary):
57+
"""Plain-text rendering for clients that can only show a text body."""
58+
if not summary:
59+
return ""
60+
61+
lines = ["AI Vision Analysis"]
62+
if summary.get("model"):
63+
lines.append(f"Model: {summary['model']}")
64+
if summary.get("description"):
65+
lines.append(f"\nDescription: {summary['description']}")
66+
if summary.get("objects"):
67+
lines.append(f"\nObjects Detected: {', '.join(summary['objects'])}")
68+
if summary.get("text"):
69+
lines.append(f"\nVisible Text: {summary['text']}")
70+
if summary.get("analysis"):
71+
lines.append(f"\nContextual Analysis: {summary['analysis']}")
72+
return "\n".join(lines).strip()
73+
74+
75+
def join_indexed_chunk_text(chunks):
76+
"""Join the indexed text of ordered chunks, skipping empty ones."""
77+
parts = []
78+
for chunk in chunks or []:
79+
if not isinstance(chunk, dict):
80+
continue
81+
text = _clean_text(chunk.get("chunk_text"))
82+
if text:
83+
parts.append(text)
84+
return "\n\n".join(parts)
85+
86+
87+
def _percentage_complete(document):
88+
try:
89+
return int(document.get("percentage_complete"))
90+
except (TypeError, ValueError):
91+
return None
92+
93+
94+
def is_workspace_upload_failed(document):
95+
"""Return whether ingestion stopped with an error, which is final rather than in progress."""
96+
return _clean_text(document.get("status")).lower().startswith("error")
97+
98+
99+
def is_workspace_upload_processing(document):
100+
"""Return whether the linked document has not finished ingestion yet."""
101+
if is_workspace_upload_failed(document):
102+
return False
103+
percentage = _percentage_complete(document)
104+
if percentage is not None:
105+
return percentage < 100
106+
status = _clean_text(document.get("status")).lower()
107+
return bool(status) and not status.startswith("processing complete")
108+
109+
110+
def build_workspace_upload_details(document, indexed_chunk_count):
111+
"""Return the browser-safe extraction details of the document behind a chat upload.
112+
113+
Only descriptive ingestion results are returned. Storage locations, scope identifiers and
114+
anything the screening subsystem manages stay on the server.
115+
"""
116+
source = document if isinstance(document, dict) else {}
117+
try:
118+
number_of_pages = int(source.get("number_of_pages"))
119+
except (TypeError, ValueError):
120+
number_of_pages = None
121+
122+
return {
123+
"document_id": _clean_text(source.get("id")),
124+
"title": _clean_text(source.get("title")),
125+
"abstract": _clean_text(source.get("abstract")),
126+
"keywords": _clean_list(source.get("keywords")),
127+
"status": _clean_text(source.get("status")),
128+
"percentage_complete": _percentage_complete(source),
129+
"number_of_pages": number_of_pages,
130+
"extraction_engine": _clean_text(source.get("extraction_engine")),
131+
"extraction_engine_reason": _clean_text(source.get("extraction_engine_reason")),
132+
"indexed_chunk_count": max(0, int(indexed_chunk_count or 0)),
133+
"vision_analysis": summarize_vision_analysis(source.get("vision_analysis")),
134+
}
135+
136+
137+
def build_workspace_upload_file_content(filename, document, chunks):
138+
"""Shape the file-content response for a chat upload backed by a workspace document.
139+
140+
Returns ``(payload, status_code)``. ``file_content`` is always a readable body when the
141+
response succeeds, so a client that only renders text still has something to show: the
142+
indexed text when ingestion saved any, otherwise the stored image analysis.
143+
"""
144+
ordered_chunks = [chunk for chunk in chunks or [] if isinstance(chunk, dict)]
145+
indexed_text = join_indexed_chunk_text(ordered_chunks)
146+
details = build_workspace_upload_details(document, len(ordered_chunks))
147+
body = indexed_text or render_vision_analysis_text(details["vision_analysis"])
148+
149+
if not body:
150+
source = document if isinstance(document, dict) else {}
151+
if is_workspace_upload_failed(source):
152+
message = PROCESSING_FAILED_MESSAGE
153+
elif is_workspace_upload_processing(source):
154+
message = STILL_PROCESSING_MESSAGE
155+
else:
156+
message = NO_EXTRACTED_CONTENT_MESSAGE
157+
return {"error": message, "workspace_document": details}, 404
158+
159+
return {
160+
"file_content": body,
161+
"filename": filename,
162+
"is_table": False,
163+
"file_content_source": WORKSPACE_UPLOAD_FILE_CONTENT_SOURCE,
164+
"indexed_text_available": bool(indexed_text),
165+
"workspace_document": details,
166+
}, 200

‎application/single_app/functions_content.py‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,19 +625,53 @@ def resolve_extraction_engine_for_mode(extraction_mode, settings):
625625
return functions_settings.resolve_enhanced_extraction_engine(settings)
626626

627627

628+
# Persisted as the extraction engine reason and shown in workspace tooltips, so keep it short.
629+
CONTENT_UNDERSTANDING_IMAGE_ANALYZER_REASON = (
630+
'Content Understanding found no text, so its image analyzer description was indexed'
631+
)
632+
633+
634+
def _describe_image_with_content_understanding(file_path, settings, analyze_image):
635+
"""Return the Content Understanding image analyzer description, or '' when it has none."""
636+
try:
637+
description_text = str(analyze_image(file_path, settings=settings) or '').strip()
638+
except Exception as image_analyzer_error:
639+
log_event(
640+
f"[EXTRACTION_ENGINE] Content Understanding image analyzer failed for "
641+
f"{os.path.basename(file_path)}: {image_analyzer_error}. "
642+
"Falling back to Document Intelligence Layout.",
643+
level=logging.WARNING,
644+
)
645+
return ''
646+
647+
if not description_text:
648+
log_event(
649+
f"[EXTRACTION_ENGINE] Content Understanding image analyzer returned no description for "
650+
f"{os.path.basename(file_path)}; falling back to Document Intelligence Layout.",
651+
level=logging.WARNING,
652+
)
653+
return description_text
654+
655+
628656
def extract_content_with_extraction_engine(
629657
file_path,
630658
extraction_mode,
631659
extraction_engine=None,
632660
settings=None,
633661
pages=None,
662+
is_image=False,
634663
):
635664
"""Extract page content with the engine that backs the requested extraction mode.
636665
637666
Standard extraction always uses Document Intelligence ``prebuilt-read``. Enhanced extraction
638667
prefers Azure AI Content Understanding, but always falls back to Document Intelligence
639668
``prebuilt-layout`` so a Content Understanding outage never blocks ingestion.
640669
670+
When ``is_image`` is set and the document analyzer finds no content, which is normal for a
671+
photo with no text, the Content Understanding image analyzer's description is returned as
672+
the image's single page instead. Only if that also yields nothing does extraction fall back
673+
to Document Intelligence Layout.
674+
641675
Returns ``(pages_data, engine_used, fallback_reason)``.
642676
"""
643677
resolved_settings = settings if settings is not None else get_settings()
@@ -654,7 +688,10 @@ def extract_content_with_extraction_engine(
654688
'',
655689
)
656690

657-
from functions_content_understanding import extract_content_with_content_understanding
691+
from functions_content_understanding import (
692+
analyze_image_with_content_understanding,
693+
extract_content_with_content_understanding,
694+
)
658695

659696
try:
660697
content_understanding_pages = extract_content_with_content_understanding(
@@ -668,6 +705,18 @@ def extract_content_with_extraction_engine(
668705
functions_settings.EXTRACTION_ENGINE_CONTENT_UNDERSTANDING,
669706
'',
670707
)
708+
if is_image:
709+
image_description = _describe_image_with_content_understanding(
710+
file_path,
711+
resolved_settings,
712+
analyze_image_with_content_understanding,
713+
)
714+
if image_description:
715+
return (
716+
[{'page_number': 1, 'content': image_description}],
717+
functions_settings.EXTRACTION_ENGINE_CONTENT_UNDERSTANDING,
718+
CONTENT_UNDERSTANDING_IMAGE_ANALYZER_REASON,
719+
)
671720
fallback_reason = (
672721
'Content Understanding returned no content, so Document Intelligence Layout was used'
673722
)

‎application/single_app/functions_content_understanding.py‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import json
1515
import logging
1616
import os
17+
import re
1718
import time
1819

1920
import requests
@@ -48,6 +49,11 @@
4849
"Understanding defaults before using Enhanced extraction."
4950
)
5051

52+
# Image analyzers such as prebuilt-imageSearch return a bare image reference, for example
53+
# "![image](pages/1)", as the markdown and put the actual description in a Summary field.
54+
MARKDOWN_IMAGE_PLACEHOLDER_PATTERN = re.compile(r'!\[[^\]]*\]\([^)]*\)')
55+
CONTENT_UNDERSTANDING_SUMMARY_FIELD = 'summary'
56+
5157

5258
class ContentUnderstandingError(Exception):
5359
"""Raised when a Content Understanding request cannot be completed."""
@@ -521,8 +527,45 @@ def extract_content_with_content_understanding(file_path, pages=None, settings=N
521527
return pages_data
522528

523529

530+
def _is_image_placeholder_markdown(markdown_text):
531+
"""Return True when markdown holds nothing but image references such as ``![image](pages/1)``."""
532+
return not MARKDOWN_IMAGE_PLACEHOLDER_PATTERN.sub('', str(markdown_text or '')).strip()
533+
534+
535+
def _build_string_field_blocks(content):
536+
"""Return readable text for the non-empty string fields an analyzer extracted.
537+
538+
The ``Summary`` field is emitted as plain text first, because it is the analyzer's own
539+
description of the input. Every other string field is emitted as ``Name: value``.
540+
"""
541+
fields = content.get('fields')
542+
if not isinstance(fields, dict):
543+
return []
544+
545+
summary_blocks = []
546+
field_blocks = []
547+
for field_name, field_value in fields.items():
548+
if not isinstance(field_value, dict):
549+
continue
550+
value_string = field_value.get('valueString')
551+
if not isinstance(value_string, str) or not value_string.strip():
552+
continue
553+
554+
if str(field_name).strip().lower() == CONTENT_UNDERSTANDING_SUMMARY_FIELD:
555+
summary_blocks.append(value_string.strip())
556+
else:
557+
field_blocks.append(f"{field_name}: {value_string.strip()}")
558+
559+
return summary_blocks + field_blocks
560+
561+
524562
def analyze_image_with_content_understanding(image_path, settings=None):
525-
"""Analyze a standalone image and return its description text, or an empty string."""
563+
"""Analyze a standalone image and return its description text, or an empty string.
564+
565+
Image analyzers describe the image in extracted fields rather than in the markdown, which is
566+
only a placeholder image reference, so string fields are read alongside any real markdown
567+
and figure descriptions.
568+
"""
526569
resolved_settings = settings if settings is not None else _get_settings()
527570
config = _resolve_config(settings=resolved_settings)
528571

@@ -535,15 +578,27 @@ def analyze_image_with_content_understanding(image_path, settings=None):
535578
text_blocks = []
536579
for content in _iter_document_contents(result):
537580
markdown_text = str(content.get('markdown') or '').strip()
581+
if _is_image_placeholder_markdown(markdown_text):
582+
markdown_text = ''
538583
if markdown_text:
539584
text_blocks.append(markdown_text)
540585

586+
field_blocks = _build_string_field_blocks(content)
587+
text_blocks.extend(field_blocks)
588+
589+
# De-duplicate figure descriptions against everything already emitted for this content.
590+
emitted_text = "\n\n".join([markdown_text] + field_blocks)
541591
for summary in _build_figure_summaries(content):
542-
rendered_summary = _render_figure_summary(summary, markdown_text)
592+
rendered_summary = _render_figure_summary(summary, emitted_text)
543593
if rendered_summary:
544594
text_blocks.append(rendered_summary)
545595

546-
return "\n\n".join(block for block in text_blocks if block).strip()
596+
description_text = "\n\n".join(block for block in text_blocks if block).strip()
597+
debug_print(
598+
f"[CONTENT_UNDERSTANDING] Image analyzer {config['image_analyzer_id']} returned "
599+
f"{len(description_text)} character(s) for {os.path.basename(image_path)}"
600+
)
601+
return description_text
547602

548603

549604
def test_content_understanding_connection(config_override, sample_file_path=None):

0 commit comments

Comments
 (0)