Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
EXECUTOR_MAX_WORKERS = 30
CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32'))
SESSION_TYPE = 'filesystem'
VERSION = "0.261.418"
VERSION = "0.261.419"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
12 changes: 8 additions & 4 deletions application/single_app/functions_appinsights.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,23 +134,27 @@
})
LOGGER_WORKFLOW_HASH_KEYS = frozenset({
"conversationidhash", "turnidhash", "runidhash", "stepidhash", "producerstepidhash",
"documentidhash", "plannerbindinghash", "documentsethash",
"documentidhash", "plannerbindinghash", "documentsethash", "messageidhash", "producerrunidhash",
})
# Application-owned snake_case codes; any other text under these keys is dropped.
LOGGER_WORKFLOW_CODE_KEYS = frozenset({
"validationcode", "validationrule", "responsefailure", "executioncode", "outputcode", "durablestatus",
"failurecode", "authorityreason", "capabilityid", "outputformat", "producercapabilityid",
"executionpath", "outcome",
"executionpath", "outcome", "analysisvalidationstatus", "analysissemantics",
})


def workflow_log_context(*, conversation_id=None, turn_id=None, run_id=None, step_id=None):
def workflow_log_context(
*, conversation_id=None, turn_id=None, run_id=None, step_id=None,

Check warning on line 148 in application/single_app/functions_appinsights.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
message_id=None, producer_run_id=None, producer_step_id=None,
):
"""Correlate workflow requests without recording their raw identifiers."""
return {
f"{name}_hash": hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
for name, value in (
("conversation_id", conversation_id), ("turn_id", turn_id), ("run_id", run_id),
("step_id", step_id),
("step_id", step_id), ("message_id", message_id),
("producer_run_id", producer_run_id), ("producer_step_id", producer_step_id),
)
if isinstance(value, str) and value
}
Expand Down
20 changes: 19 additions & 1 deletion application/single_app/functions_orchestration_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
assert_current_request_sources_available, guard_model_callable, strict_source_authority,
)
from content_screening.contracts import DocumentHeldError, ScreeningError
from functions_appinsights import log_chat_execution_outcome, log_event, workflow_log_context
from functions_appinsights import debug_print, log_chat_execution_outcome, log_event, workflow_log_context

Check warning on line 106 in application/single_app/functions_orchestration_execution.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
from functions_orchestration_adapters import resolve_context_source_manifest
from functions_orchestration_checkpoints import (
CheckpointError, fingerprint, orchestration_answer_message_id, publication_documents_match,
Expand Down Expand Up @@ -1838,6 +1838,24 @@
document, {key: value for key, value in saved.items() if not key.startswith("_")},
):
raise HarnessExecutionError("message_not_saved")
for descriptor in (saved.get("metadata") or {}).get("saved_analyses") or []:
binding = descriptor.get("binding") or {}
debug_print(
"[SAVED_ANALYSIS] Findings published",
category="DOCUMENT_ANALYSIS",
**workflow_log_context(
conversation_id=self.record["conversation_id"],

Check warning on line 1847 in application/single_app/functions_orchestration_execution.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
turn_id=self.record.get("turn_id"), run_id=self.record["id"],
message_id=message_id, producer_run_id=binding.get("run_id"),
producer_step_id=binding.get("step_id"),
),
descriptor_count=len(saved["metadata"]["saved_analyses"]),
record_count=descriptor.get("record_count"),
source_count=descriptor.get("source_count"),
available=descriptor.get("available", True),
analysis_validation_status=descriptor.get("validation_status"),
analysis_semantics=(descriptor.get("semantics") or {}).get("mode"),
)
# Publication preserves an administrator's earlier retraction of this reply.
blocked = document.get("role") == "safety"
if blocked:
Expand Down
176 changes: 97 additions & 79 deletions application/v2_ui/src/components/chat/AnalysisResult.tsx

Large diffs are not rendered by default.

69 changes: 60 additions & 9 deletions application/v2_ui/src/components/chat/MessageInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// One panel with three sections rather than three separate panels: a user comparing what a
// response cited against how it was generated should not have to close one to open another.

import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { clsx } from 'clsx';
import {
Braces,
Expand All @@ -31,7 +31,7 @@ import {
import { normalizeOrchestrationAttempt } from '../../lib/orchestration';
import { openOrchestrationRecovery } from '../../lib/orchestrationController';
import { m365ChipLabel, m365SecondaryLine } from '../../lib/m365Citations';
import type { ChatMessage, Json, PersistedThought } from '../../lib/types';
import type { ChatMessage, Json, PersistedThought, SavedAnalysisDescriptor } from '../../lib/types';
import { buildToolResultView, type RowMode } from '../../lib/agentCitationRows';
import { GlassButton } from '../ui/primitives';
import { M365KindIcon, M365OpenLink } from './M365CitationChip';
Expand All @@ -42,9 +42,15 @@ import { readEvidenceStatus, type EvidenceStatus } from '../../lib/evidenceStatu
import { readMaskState } from '../../lib/masking';
import { WebProviderBadge } from './WebCitationCards';
import { safeWebSourceUrl } from '../../lib/webCitations';
import { analysisUnavailableMessage, readSavedAnalyses } from '../../lib/savedAnalysis';
import { AnalysisDiagnostics, AnalysisResult } from './AnalysisResult';
import { CitationChip } from './CitationChip';
import { documentCitationGroups } from '../../lib/citations';

export type InspectorSection = 'details' | 'sources' | 'reasoning';

const ignoreUnavailableAnalysis = () => {};

const SECTION_LABEL: Record<InspectorSection, string> = {
details: 'Details',
sources: 'Sources',
Expand Down Expand Up @@ -170,15 +176,21 @@ function DetailsSection({ message }: { message: ChatMessage }) {
);
}

function SourcesSection({ sources, evidence }: { sources: MessageSources; evidence: EvidenceStatus | null }) {
function SourcesSection({ sources, evidence, hasFindings = false }: {
sources: MessageSources; evidence: EvidenceStatus | null; hasFindings?: boolean;
}) {
const empty = hasFindings
? 'No citations were attached to the answer. Saved findings may still include supporting passages.'
: 'This response did not cite any sources.';
const documentGroups = documentCitationGroups(sources.documents);
if (sources.total === 0 && !evidence) {
return <Empty>This response did not cite any sources.</Empty>;
return <Empty>{empty}</Empty>;
}

return (
<div className="space-y-4">
<EvidenceStatusDetails status={evidence} />
{sources.total === 0 && <Empty>This response did not cite any sources.</Empty>}
{sources.total === 0 && <Empty>{empty}</Empty>}
{sources.conversations.length > 0 && (
<div>
<h4 className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-text-3 uppercase">
Expand All @@ -190,14 +202,22 @@ function SourcesSection({ sources, evidence }: { sources: MessageSources; eviden
{sources.documents.length > 0 && (
<div>
<h4 className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-text-3 uppercase">
<FileText size={12} /> Documents ({sources.documents.length})
<FileText size={12} /> Document citations ({sources.documents.length})
</h4>
<ul className="space-y-1">
{sources.documents.map((citation, index) => {
const described = describeDocumentCitation(citation);
const id = typeof citation.citation_id === 'string'
? citation.citation_id.trim().replace(/^#/, '') : '';
const group = documentGroups.get(id);
if (group && !described.isSummary) {
return <li key={`${citation.citation_id ?? ''}-${index}`} className="min-w-0">
<CitationChip group={group} />
</li>;
}
return (
<li
key={`${citation.citation_id ?? index}`}
key={`${citation.citation_id ?? ''}-${index}`}
className="flex items-start gap-2 rounded-lg bg-surface-sunken px-2.5 py-1.5 text-xs"
>
{described.isSummary ? (
Expand Down Expand Up @@ -454,15 +474,22 @@ export function MessageInspector({
section,
onSection,
onClose,
onAnalysisUnavailable = ignoreUnavailableAnalysis,
unavailableAnalysisKeys,
}: {
message: ChatMessage;
section: InspectorSection;
onSection: (next: InspectorSection) => void;
onClose: () => void;
onAnalysisUnavailable?: (descriptor: SavedAnalysisDescriptor) => void;
unavailableAnalysisKeys?: ReadonlySet<string>;
}) {
const sources = readSources(message as unknown as Json);
const masks = readMaskState(message);
const evidence = masks.hasAnyMask ? null : readEvidenceStatus(message.metadata?.evidence_status);
const savedAnalyses = useMemo(() => masks.hasAnyMask ? [] : readSavedAnalyses(message.metadata),
[masks.hasAnyMask, message.metadata]);
const hasFindings = savedAnalyses.length > 0;
// An uploaded file cites nothing and has no reasoning. Its sources are what ingestion
// extracted from it, which is the material any later citation of the file comes from.
const upload = message.role === 'file';
Expand Down Expand Up @@ -509,11 +536,16 @@ export function MessageInspector({
</button>
</div>

<div className="max-h-96 overflow-y-auto px-3 py-2">
<div className={clsx('overflow-y-auto px-3 py-3',
hasFindings && active === 'sources' ? 'max-h-[min(55dvh,36rem)]' : 'max-h-96')}>
{active === 'details' && (
<>
<AttemptDetails message={message} />
<DetailsSection message={message} />
{savedAnalyses.map((descriptor, index) => descriptor && descriptor.available !== false
&& !unavailableAnalysisKeys?.has(`${descriptor.conversation_id}:${descriptor.message_id}:${descriptor.result_sha256}`) ? (
<AnalysisDiagnostics key={`${descriptor.result_sha256}-${index}`} descriptor={descriptor} />
) : null)}
</>
)}
{active === 'sources' && upload && (
Expand All @@ -527,7 +559,26 @@ export function MessageInspector({
<Empty>This upload has no stored extraction to show.</Empty>
)
)}
{active === 'sources' && !upload && <SourcesSection sources={sources} evidence={evidence} />}
{active === 'sources' && !upload && (
<div className={clsx(hasFindings && 'grid min-w-0 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]')}>
<section aria-label="Answer citations" className="min-w-0">
{hasFindings && <h3 className="mb-2 text-sm font-semibold text-text-1">Cited in the answer</h3>}
<SourcesSection sources={sources} evidence={evidence} hasFindings={hasFindings} />
</section>
{hasFindings && <div className="min-w-0 space-y-5 border-t border-edge pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-5">
{savedAnalyses.map((descriptor, index) => descriptor ? (
<AnalysisResult
key={`${descriptor.conversation_id}:${descriptor.message_id}:${descriptor.result_sha256}`}
descriptor={unavailableAnalysisKeys?.has(
`${descriptor.conversation_id}:${descriptor.message_id}:${descriptor.result_sha256}`,
) ? { ...descriptor, available: false } : descriptor}
onUnavailable={onAnalysisUnavailable}
reviewNumber={savedAnalyses.length > 1 ? index + 1 : undefined}
/>
) : <p key={index} role="status" className="text-sm text-text-2">{analysisUnavailableMessage()}</p>)}
</div>}
</div>
)}
{active === 'reasoning' && <ReasoningSection message={message} />}
</div>
</div>
Expand Down
25 changes: 16 additions & 9 deletions application/v2_ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {
import { InlineMapCards } from './InlineMapCard';
import { ChatFilePreview } from './ChatFilePreview';
import { GeneratedArtifactCard } from './GeneratedArtifactCard';
import { AnalysisResult } from './AnalysisResult';
import type { SavedAnalysisDescriptor } from '../../lib/types';
import { IconButton, MessageActions } from './MessageActions';
import { isCheckingFailureNotice, OrchestrationMessageRecovery } from './OrchestrationRecoveryNotice';
Expand Down Expand Up @@ -1262,16 +1261,17 @@ function MessageBubbleInner({
) : null}
{/* Inside the bubble, because a generated file belongs to the reply
that produced it rather than sitting loose in the thread. */}
{hasSavedAnalysis && (masks.ranges.length > 0 ? (
{hasSavedAnalysis && masks.ranges.length > 0 ? (
<p className="mt-2 text-xs text-text-3">Saved findings are hidden while this message is masked.</p>
) : savedAnalyses.map((descriptor, index) => descriptor ? (
<AnalysisResult key={`${descriptor.conversation_id}:${descriptor.message_id}:${descriptor.result_sha256}`}
descriptor={descriptor} onUnavailable={markAnalysisUnavailable} />
) : (
<p key={`unavailable-analysis-${index}`} role="status" className="mt-2 text-sm text-text-2">
) : analysisUnavailable ? (
<p role="status" className="mt-2 text-sm text-text-2">
{analysisUnavailableMessage()}
</p>
)))}
) : hasSavedAnalysis && savedAnalyses.some((descriptor) => descriptor?.validation_status !== 'valid') ? (
<p role="status" className="mt-2 text-xs text-text-2">
This review has unresolved checks or incomplete findings. Open Sources to inspect its limits.
</p>
) : null}
{!analysisUnavailable && (!hasSavedAnalysis || masks.ranges.length === 0) && (
hasSavedAnalysis ? artifactCards.length > 0 && (
<details className="mt-2">
Expand Down Expand Up @@ -1319,7 +1319,12 @@ function MessageBubbleInner({
inspector || replayExpanded ? 'opacity-100' : 'opacity-0',
)}
>
{!analysisUnavailable && <MessageActions
{analysisUnavailable ? (
<IconButton label="Show sources" active={inspector === 'sources'}
onClick={() => inspectMessage('sources')}>
<BookOpen size={15} />
</IconButton>
) : <MessageActions
message={message}
onEdit={isUser ? () => setEditing(true) : undefined}
inspector={inspector}
Expand Down Expand Up @@ -1354,6 +1359,8 @@ function MessageBubbleInner({
section={inspector}
onSection={inspectMessage}
onClose={() => setInspector(null)}
onAnalysisUnavailable={markAnalysisUnavailable}
unavailableAnalysisKeys={unavailableAnalysisKeys}
/>
)}
</div>
Expand Down
7 changes: 7 additions & 0 deletions application/v2_ui/src/lib/citations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ function rawDocumentSources(records: readonly HybridCitation[]): Map<string, Cit
return sources;
}

/** Inspector links retain the answer parser's conflict checks across all recorded citations. */
export function documentCitationGroups(records: readonly HybridCitation[]): Map<string, CitationGroup | null> {
return new Map([...rawDocumentSources(records)].map(([id, group]) => [
id, group ? { ...group, id } : null,
]));
}

function splitCitationId(rawId: string): ParsedCitation {
const citationId = rawId.startsWith('#') ? rawId.slice(1) : rawId;
const separator = citationId.lastIndexOf('_');
Expand Down
Loading
Loading