Skip to content
Open
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
23 changes: 19 additions & 4 deletions frontends/aiq_api/src/aiq_api/jobs/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,30 +447,45 @@ def _get_output_category(self, agent_info: tuple[str, str] | None = None) -> str
def _emit_cited_urls(self, content: str) -> None:
"""Extract URLs from output content and emit citation_use events.

When a SourceRegistry is attached, validates against it (consistent
with verify_citations). Otherwise falls back to the callback's own
_discovered_urls set.
When a SourceRegistry is attached, resolves against it (consistent
with verify_citations) and emits the match's confidence / match_kind
alongside the URL so the UI can render a verification badge. Falls
back to the callback's own _discovered_urls set when no registry is
attached (no confidence is emitted in that case).
"""
urls = self._extract_urls(content)
for url in urls:
normalized = self._normalize_url(url)
if normalized in self._cited_urls:
continue

confidence: float | None = None
match_kind: str | None = None
is_valid = False

registry = self._get_source_registry()
if registry is not None:
is_valid = registry.has_url(url)
match = registry.resolve_url(url)
if match is not None and match.kind != "ambiguous":
is_valid = True
confidence = match.confidence
match_kind = match.kind
else:
is_valid = normalized in self._discovered_urls

if is_valid:
self._cited_urls.add(normalized)
extra: dict[str, Any] = {}
if confidence is not None:
extra["confidence"] = confidence
if match_kind is not None:
extra["match_kind"] = match_kind
self._emit_artifact(
ArtifactType.CITATION_USE,
url,
name=url,
url=url,
**extra,
)

def _emit_tool_artifact(self, tool_name: str, tool_input: Any, run_id: str = "") -> None:
Expand Down
39 changes: 35 additions & 4 deletions frontends/ui/src/adapters/api/deep-research-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ export interface ArtifactUpdateEvent extends DeepResearchSSEEvent {
type: ArtifactType
content: string | TodoItem[]
url?: string // For citation_source and citation_use types
/** Citation verification confidence (0–1). Optional; emitted by citation_use when a registry resolved the URL. */
confidence?: number
/** Citation match strategy. See Python MatchKind for the full list. */
match_kind?: string
}
metadata?: {
workflow?: string
Expand Down Expand Up @@ -207,7 +211,13 @@ export interface DeepResearchCallbacks {
onToolEnd?: (name: string, output?: string, eventId?: string, agentId?: string) => void
/** Called on artifact updates */
onTodoUpdate?: (todos: TodoItem[], workflow?: string) => void
onCitationUpdate?: (url: string, content: string, isCited?: boolean) => void
onCitationUpdate?: (
url: string,
content: string,
isCited?: boolean,
confidence?: number,
matchKind?: string,
) => void
onFileUpdate?: (filename: string, content: string) => void
onOutputUpdate?: (content: string, outputCategory?: string, workflow?: string) => void
/** Called on job heartbeat (confirms job is alive during long operations) */
Expand Down Expand Up @@ -485,11 +495,20 @@ export const createDeepResearchClient = (options: DeepResearchStreamOptions): De
case 'artifact.update': {
// artifact.update has nested structure: { id, timestamp, data: { type, content, url?, output_category? }, metadata?: { workflow } }
const artifactWrapper = rawData as {
data?: { type: ArtifactType; content: string | TodoItem[]; url?: string; output_category?: string }
data?: {
type: ArtifactType
content: string | TodoItem[]
url?: string
output_category?: string
confidence?: number
match_kind?: string
}
type?: ArtifactType
content?: string | TodoItem[]
url?: string
output_category?: string
confidence?: number
match_kind?: string
metadata?: { workflow?: string }
}
// Handle both nested (data.type) and flat (type) structures
Expand All @@ -502,11 +521,23 @@ export const createDeepResearchClient = (options: DeepResearchStreamOptions): De
break
case 'citation_source':
// citation_source = "Referenced" sources (discovered during search)
callbacks.onCitationUpdate?.(artifactData.url || '', artifactData.content as string, false)
callbacks.onCitationUpdate?.(
artifactData.url || '',
artifactData.content as string,
false,
artifactData.confidence,
artifactData.match_kind,
)
break
case 'citation_use':
// citation_use = "Cited" sources (actually used in the report)
callbacks.onCitationUpdate?.(artifactData.url || '', artifactData.content as string, true)
callbacks.onCitationUpdate?.(
artifactData.url || '',
artifactData.content as string,
true,
artifactData.confidence,
artifactData.match_kind,
)
break
case 'file': {
// file artifacts are written during research — extract filename from path
Expand Down
26 changes: 25 additions & 1 deletion frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,31 @@ describe('useDeepResearch', () => {
expect(mockAddDeepResearchCitation).toHaveBeenCalledWith(
'https://example.com',
'Citation content',
true
true,
undefined,
undefined,
)
})

test('onCitationUpdate forwards confidence + matchKind to store', async () => {
await setupConnectedHook()

act(() => {
mockClient?.callbacks.onCitationUpdate?.(
'https://example.com',
'Citation content',
true,
0.85,
'truncation',
)
})

expect(mockAddDeepResearchCitation).toHaveBeenCalledWith(
'https://example.com',
'Citation content',
true,
0.85,
'truncation',
)
})

Expand Down
35 changes: 30 additions & 5 deletions frontends/ui/src/features/chat/hooks/use-deep-research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type TodoItem,
} from '@/adapters/api'
import { useChatStore } from '../store'
import type { CitationSource } from '../types'
import { useAuth } from '@/adapters/auth'
import { useLayoutStore } from '@/features/layout/store'
import { checkBackendHealthCached } from '@/shared/hooks/use-backend-health'
Expand Down Expand Up @@ -211,7 +212,13 @@ export const useDeepResearch = (): UseDeepResearchReturn => {
llmSteps: new Map<string, { name: string; workflow?: string; content: string; thinking?: string; usage?: { input_tokens: number; output_tokens: number } }>(),
toolCalls: new Map<string, { name: string; input?: Record<string, unknown>; output?: string; workflow?: string; agentId?: string }>(),
todos: null as TodoItem[] | null,
citations: [] as Array<{ url: string; content: string; isCited: boolean }>,
citations: [] as Array<{
url: string
content: string
isCited: boolean
confidence?: number
matchKind?: CitationSource['matchKind']
}>,
files: new Map<string, string>(),
reportContent: null as string | null,
}
Expand All @@ -237,7 +244,15 @@ export const useDeepResearch = (): UseDeepResearchReturn => {
const agents = Array.from(buf.agents.entries()).map(([id, a]) => ({ id, name: a.name, input: a.input, output: a.output, status: 'complete' as const, startedAt: now, completedAt: now }))
const llmSteps = Array.from(buf.llmSteps.entries()).map(([id, s]) => ({ id, name: s.name, workflow: s.workflow, content: s.content, thinking: s.thinking, usage: s.usage, isComplete: true, timestamp: now }))
const toolCalls = Array.from(buf.toolCalls.entries()).map(([id, t]) => ({ id, name: t.name, input: t.input, output: t.output, workflow: t.workflow, agentId: t.agentId, status: 'complete' as const, timestamp: now }))
const citations = buf.citations.map((c, i) => ({ id: `citation-${i}`, url: c.url, content: c.content, isCited: c.isCited, timestamp: now }))
const citations = buf.citations.map((c, i) => ({
id: `citation-${i}`,
url: c.url,
content: c.content,
isCited: c.isCited,
confidence: c.confidence,
matchKind: c.matchKind,
timestamp: now,
}))
const files = Array.from(buf.files.entries()).map(([filename, content], i) => ({ id: `file-${i}`, filename, content, timestamp: now }))
const todos = buf.todos ? normalizeDeepResearchTodos(buf.todos) : undefined

Expand Down Expand Up @@ -480,10 +495,20 @@ export const useDeepResearch = (): UseDeepResearchReturn => {

},

onCitationUpdate: (url, content, isCited) => {
if (buf.active) { buf.citations.push({ url, content, isCited: isCited ?? false }); return }
onCitationUpdate: (url, content, isCited, confidence, matchKind) => {
if (buf.active) {
buf.citations.push({
url,
content,
isCited: isCited ?? false,
confidence,
matchKind: matchKind as CitationSource['matchKind'],
})
return
}
if (!isActiveJob()) return
resetTimeout(); addDeepResearchCitation(url, content, isCited)
resetTimeout()
addDeepResearchCitation(url, content, isCited, confidence, matchKind as CitationSource['matchKind'])
},

onFileUpdate: (filename, content) => {
Expand Down
21 changes: 18 additions & 3 deletions frontends/ui/src/features/chat/hooks/use-load-job-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type TodoItem,
} from '@/adapters/api'
import { useChatStore } from '../store'
import type { CitationSource } from '../types'
import {
getDeepResearchJobLoadErrorDetails,
getDeepResearchJobLoadFailureKind,
Expand Down Expand Up @@ -368,7 +369,13 @@ export const useLoadJobData = (): UseLoadJobDataReturn => {
}
>(),
todos: null as TodoItem[] | null,
citations: [] as Array<{ url: string; content: string; isCited: boolean }>,
citations: [] as Array<{
url: string
content: string
isCited: boolean
confidence?: number
matchKind?: CitationSource['matchKind']
}>,
files: new Map<string, string>(), // filename -> latest content (deduped)
reportContent: null as string | null,
}
Expand Down Expand Up @@ -421,6 +428,8 @@ export const useLoadJobData = (): UseLoadJobDataReturn => {
url: c.url,
content: c.content,
isCited: c.isCited,
confidence: c.confidence,
matchKind: c.matchKind,
timestamp: now,
}))

Expand Down Expand Up @@ -567,8 +576,14 @@ export const useLoadJobData = (): UseLoadJobDataReturn => {
buffer.todos = todos
},

onCitationUpdate: (url, content, isCited) => {
buffer.citations.push({ url, content, isCited: isCited ?? false })
onCitationUpdate: (url, content, isCited, confidence, matchKind) => {
buffer.citations.push({
url,
content,
isCited: isCited ?? false,
confidence,
matchKind: matchKind as CitationSource['matchKind'],
})
},

onFileUpdate: (filename, content) => {
Expand Down
22 changes: 21 additions & 1 deletion frontends/ui/src/features/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2011,7 +2011,13 @@ export const useChatStore = create<ChatStore>()(
)
},

addDeepResearchCitation: (url: string, content: string, isCited?: boolean) => {
addDeepResearchCitation: (
url: string,
content: string,
isCited?: boolean,
confidence?: number,
matchKind?: CitationSource['matchKind'],
) => {
const { deepResearchCitations } = get()

// Check if citation with same URL already exists
Expand All @@ -2021,11 +2027,23 @@ export const useChatStore = create<ChatStore>()(
// Update existing citation - if it's being marked as cited, update that
const updatedCitations = deepResearchCitations.map((c, i) => {
if (i === existingIndex) {
// Confidence is monotonic: never downgrade from a previously
// observed higher confidence (e.g. exact → child_path).
const nextConfidence =
confidence === undefined
? c.confidence
: c.confidence === undefined
? confidence
: Math.max(c.confidence, confidence)
const nextMatchKind =
nextConfidence === confidence ? matchKind ?? c.matchKind : c.matchKind
return {
...c,
content: content || c.content,
// Once cited, always cited (citation_use trumps citation_source)
isCited: isCited || c.isCited,
confidence: nextConfidence,
matchKind: nextMatchKind,
}
}
return c
Expand All @@ -2044,6 +2062,8 @@ export const useChatStore = create<ChatStore>()(
content,
timestamp: new Date(),
isCited,
confidence,
matchKind,
}

set(
Expand Down
25 changes: 24 additions & 1 deletion frontends/ui/src/features/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,19 @@ export interface PendingInteraction {
/** Deep research job status (from SSE stream) */
export type DeepResearchJobStatus = 'submitted' | 'running' | 'success' | 'failure' | 'interrupted'

/** Match strategy that resolved a citation against the source registry. */
export type CitationMatchKind =
| 'exact'
| 'normalized'
| 'truncation'
| 'prefix'
| 'child_path'
| 'query_subset'
| 'citation_key'
| 'unmatched'
| 'ambiguous'
| 'unverifiable'

/** Citation source from deep research */
export interface CitationSource {
id: string
Expand All @@ -262,6 +275,10 @@ export interface CitationSource {
timestamp: Date
/** Whether this source was actually cited in the report (vs just referenced/discovered) */
isCited?: boolean
/** Verification confidence (0–1) reported by the backend, when available. */
confidence?: number
/** Match strategy that resolved this citation against the source registry. */
matchKind?: CitationMatchKind
}

/** Plan message for chat/HITL display and restore flows */
Expand Down Expand Up @@ -602,7 +619,13 @@ export interface ChatActions {
*/
refreshDeepResearchSessionStatuses: () => Promise<void>
/** Add a citation from deep research (isCited=true for citation_use, false for citation_source) */
addDeepResearchCitation: (url: string, content: string, isCited?: boolean) => void
addDeepResearchCitation: (
url: string,
content: string,
isCited?: boolean,
confidence?: number,
matchKind?: CitationMatchKind,
) => void
/** Set the full todo list from deep research (replaces existing) */
setDeepResearchTodos: (todos: Array<{ content: string; status: string }>) => void
/** Mark all in-progress and pending todos as stopped (on error) */
Expand Down
Loading