Skip to content

Commit 493ee9c

Browse files
JSv4claude
andauthored
Deep research: frontend (report view, corpus tab, notifications) + chat status tool (#1836)
* Add deep-research chat status tool + slug query resolver Backend groundwork for the deep-research frontend (the v1 feature shipped backend-only; the completion chat message links to /research/{slug} but nothing resolved that route, and the chat agent could start a job but not report on one). - research_queries.py: add researchReportBySlug(slug) resolver (creator-only via BaseService.filter_visible; IDOR-safe null for non-owners/unknown slugs) so the frontend can resolve the /research/{slug} route. - research_tools.py: add check_deep_research_status chat tool (read-only) so a follow-up like "is my research done?" returns status/progress + the report link instead of being a dead end. - research_reports.py: add ResearchReportService.list_recent_for_corpus read helper (creator-only, bounded) backing the tool. - pydantic_ai_agents.py: register the status tool alongside the kickoff tool on the corpus chat agent; both are filtered out of the read-only research agent via restrict_tool_names. - tests: slug-resolver GraphQL tests + status-tool/service tests. * Build deep-research frontend: report view, corpus tab, notifications Completes the backend-only v1 (#1814) so users can monitor and read deep-research reports. The completion chat message linked to /research/{slug} but no route resolved it; RESEARCH_REPORT_* notifications weren't surfaced; and there was no report view or list. Frontend: - GraphQL + cache: research queries/mutations, JobStatus/ResearchReportType types, openedResearchReport reactive var, relayStylePagination keyed by the GraphQL field-arg names (corpusId/status) + researchReportBySlug keyArgs. - Routing: standalone /research/:slug resolved by CentralRouteManager Phase 1 (slug-based) with a Phase-3 canonical-redirect guard; openedResearchReport added to the routing write-discipline allowlist. - Views: ResearchReportDetail (SafeMarkdown body with footnote citations; Report/Citations/Sources/Run-details tabs; live polling + Cancel while running; citations deep-link to the cited annotation) and a corpus 'Research' tab listing reports. Secondary StartResearchModal for an explicit kickoff (primary trigger stays the chat agent). - Notifications: RESEARCH_REPORT_* wired into the notification type union + job-toast system (clickable, deep-links to /research/:slug), terminal cache updates, and a completion hook the detail view uses to refetch + stop polling. Tests: frontend unit tests (route parsing + research utils) and a Playwright component test for the detail view. Typescript compiles; unit + routing discipline tests pass. * Apply black formatting to research status-tool test * Remove unused loading binding from research detail useQuery CodeQL flagged the destructured `loading` from the GET_RESEARCH_REPORT useQuery as unused (the running/cancel states use the mutation's `cancelLoading`, not the query's). No behavior change. * Fix research detail CT crash: inline corpus back-URL The Playwright CT (esbuild dev) bundler failed to bind the named getCorpusUrl import in ResearchReportDetail, throwing 'getCorpusUrl is not defined' at render so the report never mounted (all 3 component tests failed). getDocumentUrl from the same module binds fine. Inline the corpus back-navigation URL (same /c/{creator}/{corpus} shape) and drop the getCorpusUrl import. The 3 ResearchReportDetail component tests now pass locally. * Fix research detail CT: mock had both variableMatcher and request.variables The real cause of the Component Tests failure: MockedProvider rejects a mock that supplies both request.variables and variableMatcher ("should contain either variableMatcher or request.variables"), so the provider constructor threw and the component never mounted. Keep the explicit request.variables ({ id }) form and drop the redundant variableMatcher. * Address review: client-side citation links, effect-guarded error toast, hook cleanups - ResearchReportDetail: RowLink is now styled(react-router Link) with to= so internal document/citation links route client-side instead of full-reload. - CorpusResearchReportCards: move the error toast into a useEffect so it fires once per error rather than on every re-render. - useResearchCompletionNotification: memoise numericId (feeds useCallback deps) and drop a leftover console.debug. - Add ResearchReportListCard component tests (completed + running) with a documentation screenshot. * Polish review items: color tokens, modal reset, Queued filter, cancel feedback Follow-up to the review fixes already on this branch: - ResearchReportDetail: replace remaining hardcoded hex (#ffffff/#fff7ed/ #9a3412) with OS_LEGAL_COLORS tokens (surface/warningSurface/warningText); stable keys (key={c.footnote} for citations, key=index+value for warnings); Cancel button shows 'Cancelling…' and disables while cancelRequested is pending. - StartResearchModal: reset prompt/title on close so a reopened modal starts blank. - ResearchTabContent: add the 'Queued' filter tab (backend status map already supports it; a fresh job sits in QUEUED briefly). Typescript compiles; research component tests pass. * Address review: clear openedResearchReport in all route handlers, restore getCorpusUrl helper, tokenize toast colors, reset research search on unmount - Clear openedResearchReport(null) in thread/labelset/user route handlers (sibling of openedExtract, was omitted) - Replace inlined corpus URL in ResearchReportDetail.handleBack with getCorpusUrl(corpus, {tab}); safer navigate('/') fallback - JobNotificationToast: source status colors from OS_LEGAL_COLORS tokens instead of raw hex - ResearchTabContent: reset researchSearchTerm and use DEBOUNCE.LIST_SEARCH_MS; clarify empty-state copy + add Load More in CorpusResearchReportCards - ResearchReportListCard: console.warn on missing slug instead of silent no-op * Add CorpusResearchReportCards smoke test + research report detail screenshot - New CorpusResearchReportCards.ct.tsx + wrapper: mounts the corpus Research tab list, asserts query wiring + empty state, captures a screenshot (review item 10). Populated-list assertion omitted — the component's network-only query re-fires across renders and MockedProvider can't serve it deterministically in isolation; card visuals are covered by ResearchReportListCard.ct.tsx. - ResearchReportDetail.ct.tsx: add docScreenshot for the completed-report state. * Address review: fix citation annotation typename, status type, toolbar token - Fix citation deep-link annotation typename mismatch in ResearchReportDetail: build the annotation global ID from fullSourceAnnotationList (the canonical ServerAnnotationType id the backend emits) via an annGlobalIdByPk map, instead of reconstructing toGlobalId('AnnotationType', pk) which guessed the wrong type name and produced deep-links resolving to the wrong entity. Drop the now-unused toGlobalId import. - Add a CT regression guard asserting the citation link's ?ann= param carries the ServerAnnotationType global id. - Type GetResearchReportsInput.status as JobStatus | undefined (was loose string) and tighten FILTER_TO_STATUS to match. - Use OS_LEGAL_COLORS.surface for the research Toolbar background instead of a hardcoded 'white' literal, consistent with the other toolbars in this PR. * Address review: seed ResearchReportDetail test reactive var synchronously ResearchReportDetailTestWrapper seeded openedResearchReport in a useEffect, which fires only after the child's first render — so ResearchReportDetail flashed its 'not found' state for one frame before the report appeared, a flakiness risk on slow CI. Switch to the synchronous useState-initializer pattern (matching CorpusResearchReportCardsTestWrapper) so the var is seeded before the first render, keeping a useEffect solely for unmount cleanup. * Address review: research-report routing, citation keys, double-fetch guard - CentralRouteManager: call routeLoading(false) before navigate('/404') in both the error and null-data branches of the research-report block. - ResearchReportRoute: remove dead error branch (CentralRouteManager redirects to /404 on error/null, never sets routeError for research). - ResearchReportDetail: key citation rows by array index, not c.footnote, to avoid React key collisions from duplicate footnotes in JSON. - StartResearchModal: navigate via getResearchReportUrl(payload.obj). - ResearchTabContent: widen setActiveTab prop to (number | string). - CorpusResearchReportCards: mount-guard the refetch effect to avoid a double fetch on initial mount; add explicit 'Could not load reports' error render when error && no reports loaded. - navigationUtils: drop production console.warn in getResearchReportUrl ('#' sentinel already signals the caller). - useNotificationWebSocket: TODO(v2) on RESEARCH_REPORT_PROGRESS union member. * Fix research report card tests + harden fetch/error states - CorpusResearchReportCardsTestWrapper: set addTypename:false on the custom InMemoryCache so it agrees with MockedProvider; a typename-adding cache injected __typename into the query the mock link matched against, draining the mock bucket and resolving the final fire to an error. - CorpusResearchReportCards: switch fetchPolicy to cache-and-network (nextFetchPolicy cache-first) so re-renders serve from cache instead of a network-only refetch storm; only surface the error state once the request has settled (error && !loading) so a transient retry error doesn't flash over the spinner. - ResearchReportDetail.ct: add research--report-detail--citations docScreenshot after the citations-tab assertions. * Address review: research status/util hardening + StartResearchModal test - Remove dead RESEARCH_REPORT_PROGRESS member from the notification type union (no v1 emitter/handler); add it when the handler is wired. - getResearchStatus dev-warns on an unrecognized JobStatus instead of silently rendering 'Queued' (null/undefined still treated as the not-set-yet case). - Align the chat status tool's duration string with the frontend formatResearchDuration helper (sub-minute reads '42s', not '0m 42s'); add a regression test. - Cap StartResearchModal's optional title input at MAX_RESEARCH_TITLE_CHARS (255, mirroring the ResearchReport.title model column) + new smoke CT. - Key citation rows off the unique footnote, not the array index. - Add anonymous-user rejection test pinning @login_required on researchReportBySlug. - Clarify the cache-and-network/cache-first nextFetchPolicy comment. * Address review: atomic research finalize + clear report state on route change - finalize() now wraps the terminal content/status save and the source_annotations/source_documents M2M set() in a single transaction.atomic() block, so an interrupted Celery task can no longer leave a COMPLETED report whose citations have no backing provenance rows. - CentralRouteManager clears openedResearchReport in Phase 1 whenever the resolved route is no longer a research route (single source of truth), closing the stale-state gap on /research/:slug -> corpus/document routes and browser back/forward. Regression-guarded in centralRouteDiscipline.test.ts. * Apply black formatting to research_reports.py Collapse a source_documents.set(Document.objects.filter(...)) call onto one line — black flagged it (the only pre-commit/linter violation on HEAD; isort/flake8/pyupgrade all clean). Formatting only, no behavior change. * Address PR #1836 review: permission constant, terminal-status polling guard, CHANGELOG indent - Extract the research-report update permission string into a RESEARCH_REPORT_UPDATE_PERMISSION constant so a typo can't silently disable Cancel for all users (review #2). - isTerminalResearchStatus now treats an unrecognized status as terminal so the detail view never polls indefinitely on a future backend state the frontend doesn't recognize; null/Queued/Running stay non-terminal (review #3). Added a unit-test case. - Fix a lost 2-space continuation indent in the CHANGELOG SmartLabel entry (review #5). * fix(research): address review on deep-research frontend (#1814) - graphql-api: add the backend's transient JobStatus.CREATED to the frontend enum; it was missing, so isTerminalResearchStatus(CREATED) wrongly returned true and stopped status polling before the job left the pre-queue state - researchUtils: treat CREATED as non-terminal and display it as 'Queued' - ResearchReportDetail: rename isRunning -> isActive (it covers created/queued/ running), key warning chips by their text instead of array index, log the swallowed error in the cancel catch block - StartResearchModal: log the swallowed error in the submit catch block - tests: cover CREATED in getResearchStatus and isTerminalResearchStatus --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 806d41b commit 493ee9c

42 files changed

Lines changed: 3611 additions & 45 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 41 additions & 5 deletions
Large diffs are not rendered by default.

config/graphql/research_queries.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,25 @@ def resolve_research_reports(self, info, **kwargs) -> Any:
6868
return qs.none()
6969
qs = qs.filter(status=status)
7070
return qs.order_by("-created")
71+
72+
research_report_by_slug = graphene.Field(
73+
ResearchReportType,
74+
slug=graphene.String(required=True),
75+
description=(
76+
"Fetch a single research report by its unique slug. The "
77+
"deep-research completion chat message links to /research/{slug}, "
78+
"so the frontend resolves that route through this field. "
79+
"Creator-only visibility (returns null for non-owners or unknown "
80+
"slugs — IDOR-safe)."
81+
),
82+
)
83+
84+
@login_required
85+
def resolve_research_report_by_slug(self, info, slug) -> Any:
86+
return (
87+
BaseService.filter_visible(
88+
ResearchReport, info.context.user, request=info.context
89+
)
90+
.filter(slug=slug)
91+
.first()
92+
)
22.3 KB
Loading
40.6 KB
Loading
46.8 KB
Loading
29.3 KB
Loading

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
} from "./components/admin";
7575
import { useEnv } from "./components/hooks/UseEnv";
7676
import { ExtractDetailRoute } from "./components/routes/ExtractDetailRoute";
77+
import { ResearchReportRoute } from "./components/routes/ResearchReportRoute";
7778
import { FileUploadPackageProps } from "./components/widgets/modals/DocumentUploadModal";
7879
import { DocumentLandingRoute } from "./components/routes/DocumentLandingRoute";
7980
import { LabelSetLandingRoute } from "./components/routes/LabelSetLandingRoute";
@@ -406,6 +407,7 @@ export const App = () => {
406407
<Route path="/about" element={<About />} />
407408
<Route path="/extracts/:extractId" element={<ExtractDetailRoute />} />
408409
<Route path="/extracts" element={<Extracts />} />
410+
<Route path="/research/:slug" element={<ResearchReportRoute />} />
409411
<Route path="/admin/badges" element={<BadgeManagement />} />
410412
<Route path="/admin/settings" element={<GlobalSettingsPanel />} />
411413
<Route path="/admin/agents" element={<GlobalAgentManagement />} />

frontend/src/assets/configurations/constants.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export const SELECTION_MENU = {
128128
export const DEBOUNCE = {
129129
SEARCH_MS: 1000,
130130
EXTRACT_SEARCH_MS: 500,
131+
/** Generic debounce for client-side list search boxes (extracts, research, …). */
132+
LIST_SEARCH_MS: 500,
131133
CLICK_OUTSIDE_DELAY_MS: 100,
132134
CORPUS_SEARCH_MS: 400,
133135
CORPUS_SEARCH_MAX_WAIT_MS: 1000,
@@ -229,6 +231,51 @@ export const EXTRACT_STATUS_COLORS = {
229231
[EXTRACT_STATUS.NOT_STARTED]: "default",
230232
} as const;
231233

234+
// Deep-research report status constants.
235+
// Keys match the backend JobStatus enum values
236+
// (opencontractserver/types/enums.py); values are the display labels.
237+
export const RESEARCH_STATUS = {
238+
QUEUED: "Queued",
239+
RUNNING: "Researching",
240+
COMPLETED: "Completed",
241+
FAILED: "Failed",
242+
CANCELLED: "Cancelled",
243+
} as const;
244+
245+
export type ResearchStatusLabel =
246+
(typeof RESEARCH_STATUS)[keyof typeof RESEARCH_STATUS];
247+
248+
// Research chip color mapping (Chip `color` tokens from @os-legal/ui)
249+
export const RESEARCH_STATUS_COLORS = {
250+
[RESEARCH_STATUS.QUEUED]: "default",
251+
[RESEARCH_STATUS.RUNNING]: "info",
252+
[RESEARCH_STATUS.COMPLETED]: "success",
253+
[RESEARCH_STATUS.FAILED]: "error",
254+
[RESEARCH_STATUS.CANCELLED]: "warning",
255+
} as const;
256+
257+
// Poll interval (ms) the research report detail view uses while a job is
258+
// non-terminal. The backend emits no per-step progress events in v1, so the
259+
// view polls the (indexed, creator-only) single-report query for live
260+
// stepCount/lastProgressAt and stops on the terminal WebSocket notification.
261+
export const RESEARCH_REPORT_POLL_INTERVAL_MS = 5000;
262+
263+
// Guardian object-permission string that gates cancelling a research report.
264+
// Centralised here (not inlined) so a typo can't silently disable Cancel for
265+
// every user and any future check on the same model stays consistent.
266+
export const RESEARCH_REPORT_UPDATE_PERMISSION = "update_researchreport";
267+
268+
// Max research prompt length. Mirrors the backend cap
269+
// (opencontractserver/research/constants.py MAX_RESEARCH_PROMPT_CHARS) so the
270+
// UI rejects over-long prompts before the round-trip.
271+
export const MAX_RESEARCH_PROMPT_CHARS = 10000;
272+
273+
// Max research report title length. Mirrors the backend model column
274+
// (opencontractserver/research/models.py ResearchReport.title max_length=255)
275+
// so the UI caps the optional title before the round-trip rather than letting
276+
// the DB silently truncate / the service reject it.
277+
export const MAX_RESEARCH_TITLE_CHARS = 255;
278+
232279
// Tool usage UI constants (used by chat ToolUsageIndicator)
233280
export const TOOL_UNKNOWN_LABEL = "Unknown Tool";
234281

frontend/src/components/notifications/JobNotificationToast.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,27 @@ import {
66
BarChart3,
77
Download,
88
XCircle,
9+
Sparkles,
10+
Ban,
911
LucideIcon,
1012
} from "lucide-react";
1113
import type { JobNotification } from "../../hooks/useJobNotifications";
1214
import type { NotificationType } from "../../hooks/useNotificationWebSocket";
15+
import { OS_LEGAL_COLORS } from "../../assets/configurations/osLegalStyles";
1316

14-
const ToastContainer = styled.div`
17+
// Semantic status colors for job toasts, sourced from the design tokens so we
18+
// don't scatter raw hex literals (no-magic-numbers). The IconContainer appends
19+
// a "20" alpha suffix, so each must be a 6-digit hex.
20+
const STATUS_SUCCESS = OS_LEGAL_COLORS.success;
21+
const STATUS_FAILED = OS_LEGAL_COLORS.danger;
22+
const STATUS_INFO = OS_LEGAL_COLORS.primaryBlue;
23+
const STATUS_NEUTRAL = OS_LEGAL_COLORS.textMuted;
24+
25+
const ToastContainer = styled.div<{ $clickable?: boolean }>`
1526
display: flex;
1627
align-items: center;
1728
gap: 12px;
29+
cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")};
1830
`;
1931

2032
const IconContainer = styled.div<{ $color: string }>`
@@ -62,14 +74,14 @@ interface JobNotificationConfig {
6274
const JOB_NOTIFICATION_CONFIG: Record<string, JobNotificationConfig> = {
6375
DOCUMENT_PROCESSED: {
6476
icon: FileText,
65-
color: "#4CAF50",
77+
color: STATUS_SUCCESS,
6678
title: "Document Ready",
6779
getMessage: (data) =>
6880
`"${(data.documentTitle as string) || "Document"}" finished processing`,
6981
},
7082
EXTRACT_COMPLETE: {
7183
icon: Table2,
72-
color: "#2196F3",
84+
color: STATUS_INFO,
7385
title: "Extract Complete",
7486
getMessage: (data) =>
7587
`"${(data.extractName as string) || "Extract"}" completed (${
@@ -78,31 +90,54 @@ const JOB_NOTIFICATION_CONFIG: Record<string, JobNotificationConfig> = {
7890
},
7991
ANALYSIS_COMPLETE: {
8092
icon: BarChart3,
81-
color: "#4CAF50",
93+
color: STATUS_SUCCESS,
8294
title: "Analysis Complete",
8395
getMessage: (data) =>
8496
`"${(data.analyzerName as string) || "Analysis"}" finished successfully`,
8597
},
8698
ANALYSIS_FAILED: {
8799
icon: XCircle,
88-
color: "#F44336",
100+
color: STATUS_FAILED,
89101
title: "Analysis Failed",
90102
getMessage: (data) =>
91103
`"${(data.analyzerName as string) || "Analysis"}" encountered an error`,
92104
},
93105
EXPORT_COMPLETE: {
94106
icon: Download,
95-
color: "#4CAF50",
107+
color: STATUS_SUCCESS,
96108
title: "Export Ready",
97109
getMessage: (data) =>
98110
`"${
99111
(data.exportName as string) || (data.corpusName as string) || "Export"
100112
}" is ready for download`,
101113
},
114+
RESEARCH_REPORT_COMPLETE: {
115+
icon: Sparkles,
116+
color: STATUS_SUCCESS,
117+
title: "Research Complete",
118+
getMessage: (data) =>
119+
`"${(data.title as string) || "Research"}" is ready to read`,
120+
},
121+
RESEARCH_REPORT_FAILED: {
122+
icon: XCircle,
123+
color: STATUS_FAILED,
124+
title: "Research Failed",
125+
getMessage: (data) =>
126+
`"${(data.title as string) || "Research"}" could not be completed`,
127+
},
128+
RESEARCH_REPORT_CANCELLED: {
129+
icon: Ban,
130+
color: STATUS_NEUTRAL,
131+
title: "Research Cancelled",
132+
getMessage: (data) =>
133+
`"${(data.title as string) || "Research"}" was cancelled`,
134+
},
102135
};
103136

104137
export interface JobNotificationToastProps {
105138
notification: JobNotification;
139+
/** Optional click handler — e.g. deep-link a research toast to its report. */
140+
onClick?: () => void;
106141
}
107142

108143
/**
@@ -111,6 +146,7 @@ export interface JobNotificationToastProps {
111146
*/
112147
export function JobNotificationToast({
113148
notification,
149+
onClick,
114150
}: JobNotificationToastProps) {
115151
const config =
116152
JOB_NOTIFICATION_CONFIG[notification.type] ||
@@ -119,7 +155,7 @@ export function JobNotificationToast({
119155
const Icon = config.icon;
120156

121157
return (
122-
<ToastContainer>
158+
<ToastContainer $clickable={Boolean(onClick)} onClick={onClick}>
123159
<IconContainer $color={config.color}>
124160
<Icon />
125161
</IconContainer>

0 commit comments

Comments
 (0)