diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 1710ab9f..ecc3b34d 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,4 @@ -import type { ErrorCode, GroundingSource, ProjectOverviewDto, ScheduleDto, NotificationDto, GscCoverageSummaryDto, GscCoverageSnapshotDto, GscPerformanceDailyDto, IndexingRequestResultDto, MetricsWindow, GA4AiReferralHistoryEntry, GA4SessionHistoryEntry, GA4SocialReferralHistoryEntry, InsightDto, ProjectReportDto, ReportAudience, CitationVisibilityResponse, BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto, TrafficSourceDto, TrafficSourceDetailDto, TrafficSourceListResponse, TrafficStatusResponse, TrafficEventsResponse, TrafficConnectCloudRunRequest, TrafficConnectWordpressRequest, TrafficConnectVercelRequest, TrafficSyncResponse, DiscoveryRunRequest, DiscoverySessionDto, DiscoverySessionDetailDto, DiscoveryPromotePreview, DiscoveryPromoteRequest, DiscoveryPromoteResult, ProjectDto, QueryDto, CompetitorDto, LocationContext, GoogleConnectionDto, GscUrlInspectionDto, GscDeindexedRowDto, BingUrlInspectionDto, BingCoverageSummaryDto, BingKeywordStatsDto, BingStatusDto, BingConnectResponseDto, BingSetSiteResponseDto, BingSitesResponseDto, GscSearchDataDto, ContentTargetDismissalDto, ContentTargetDismissRequest } from '@ainyc/canonry-contracts' +import type { ErrorCode, GroundingSource, ProjectOverviewDto, ScheduleDto, NotificationDto, GscCoverageSummaryDto, GscCoverageSnapshotDto, GscPerformanceDailyDto, IndexingRequestResultDto, MetricsWindow, GA4AiReferralHistoryEntry, GA4SessionHistoryEntry, GA4SocialReferralHistoryEntry, InsightDto, ProjectReportDto, ReportAudience, CitationVisibilityResponse, BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto, TrafficSourceDto, TrafficSourceDetailDto, TrafficSourceListResponse, TrafficStatusResponse, TrafficEventsResponse, TrafficConnectCloudRunRequest, TrafficConnectWordpressRequest, TrafficConnectVercelRequest, TrafficSyncResponse, DiscoveryRunRequest, DiscoverySessionDto, DiscoverySessionDetailDto, DiscoveryPromotePreview, DiscoveryPromoteRequest, DiscoveryPromoteResult, ProjectDto, QueryDto, CompetitorDto, LocationContext, GoogleConnectionDto, GscUrlInspectionDto, GscDeindexedRowDto, BingUrlInspectionDto, BingCoverageSummaryDto, BingKeywordStatsDto, BingStatusDto, BingConnectResponseDto, BingSetSiteResponseDto, BingSitesResponseDto, GscSearchDataDto, ContentTargetDismissalDto, ContentTargetDismissRequest, RecommendationExplanationDto, RecommendationExplainRequest } from '@ainyc/canonry-contracts' import { createClient as createHeyClient, // Projects + queries + competitors + locations + runs + apply + settings + telemetry @@ -14,6 +14,8 @@ import { postApiV1ProjectsByNameQueriesGenerate, postApiV1ProjectsByNameContentDismissals, deleteApiV1ProjectsByNameContentDismissalsByTargetRef, + getApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysis, + postApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyze, getApiV1ProjectsByNameCompetitors, putApiV1ProjectsByNameCompetitors, postApiV1ProjectsByNameLocations, @@ -548,6 +550,54 @@ export function undismissContentTarget(projectName: string, targetRef: string): ) } +/** + * Read-only cache lookup for a recommendation's LLM explanation. Returns + * `null` when no cached row exists yet (the GET endpoint 404s in that + * case). UI uses this to hydrate the "Why this?" panel without paying for + * a fresh LLM call when the user expands a recommendation that was + * analyzed in an earlier session. + * + * Returns `null` (not throws) on 404 so the caller can branch declaratively + * — every other error still throws via `invokeWeb`. + */ +export async function fetchRecommendationAnalysis( + projectName: string, + targetRef: string, +): Promise { + try { + return await invokeWeb(() => + getApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysis({ + client: heyClient, + path: { name: projectName, targetRef }, + }), + ) + } catch (err) { + if (err instanceof ApiError && err.statusCode === 404) return null + throw err + } +} + +/** + * Generate (or return cached) LLM explanation for one recommendation. + * Idempotent on the server: cached rows are returned free for the same + * `(project, targetRef, promptVersion)`. Pass `forceRefresh: true` to + * regenerate; pass `provider` / `model` to override the project's default + * agent provider. + */ +export function analyzeRecommendation( + projectName: string, + targetRef: string, + body: RecommendationExplainRequest, +): Promise { + return invokeWeb(() => + postApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyze({ + client: heyClient, + path: { name: projectName, targetRef }, + body, + }), + ) +} + export function setCompetitors(projectName: string, competitors: string[]): Promise { return invokeWeb(() => putApiV1ProjectsByNameCompetitors({ diff --git a/apps/web/src/pages/ReportPage.tsx b/apps/web/src/pages/ReportPage.tsx index f0d768e2..a32eb503 100644 --- a/apps/web/src/pages/ReportPage.tsx +++ b/apps/web/src/pages/ReportPage.tsx @@ -1,13 +1,15 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { Download } from 'lucide-react' import type { ProjectReportDto, + RecommendationExplanationDto, ReportActionPlanItem, ReportAudience, ReportInsight, } from '@ainyc/canonry-contracts' import { + AGENT_PROVIDER_IDS, contentActionLabel, dedupeReportActions, dedupeReportOpportunities, @@ -22,10 +24,10 @@ import { import { ToneBadge } from '../components/shared/ToneBadge.js' import { Button } from '../components/ui/button.js' -import { downloadReportHtml, heyClient, ApiError } from '../api.js' +import { downloadReportHtml, fetchRecommendationAnalysis, heyClient, ApiError } from '../api.js' import { getApiV1ProjectsByNameReportOptions } from '@ainyc/canonry-api-client/react-query' import { asyncHandler } from '../lib/async-handler.js' -import { useDismissContentTarget } from '../queries/mutations.js' +import { useAnalyzeRecommendation, useDismissContentTarget } from '../queries/mutations.js' import { addToast } from '../lib/toast-store.js' import type { MetricTone } from '../view-models.js' @@ -393,6 +395,200 @@ function clientConfidenceLabel(confidence: ReportActionPlanItem['confidence']): } } +/** + * Render an LLM-generated explanation as a bulleted list. The system + * prompt forces dash-prefixed bullets (`- first reason\n- action\n- ...`). + * Anything that doesn't lead with `-` falls back to a paragraph so a + * misbehaving response still renders rather than disappearing. + */ +export function ExplanationBody({ text }: { text: string }) { + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) + const bullets = lines + .filter((l) => l.startsWith('-') || l.startsWith('•')) + .map((l) => l.replace(/^[-•]\s*/, '')) + const paragraphs = lines.filter((l) => !l.startsWith('-') && !l.startsWith('•')) + return ( +
+ {paragraphs.map((p, i) => ( +

{p}

+ ))} + {bullets.length > 0 && ( +
    + {bullets.map((b, i) =>
  • {b}
  • )} +
+ )} +
+ ) +} + +/** + * Inline "Why this?" panel — one per action card with a `targetRef`. + * On mount: if a cached explanation exists, hydrate from it; otherwise + * fire `POST /analyze` immediately. Repeat opens within a session are + * cheap because the backend caches per (project, targetRef, promptVersion). + * + * Provider override: the dropdown lets the operator pick a non-default + * provider for this explanation. Switching providers triggers an immediate + * regenerate with `forceRefresh: true` so the panel renders the new + * provider's output without a second click. + * + * Cost is rendered in cents (millicents / 1000) to a 4-decimal precision so + * even sub-tenth-of-a-cent calls are legible. + */ +export function WhyThisPanel({ + projectName, + targetRef, + onClose, +}: { + projectName: string + targetRef: string + onClose: () => void +}) { + const [providerOverride, setProviderOverride] = useState('') + const [explanation, setExplanation] = useState(null) + const [error, setError] = useState(null) + const [isHydrating, setIsHydrating] = useState(true) + const analyzeMutation = useAnalyzeRecommendation() + + // Hydrate from the cache on mount. If the GET 404s (no cached + // explanation), kick off POST so the panel renders progress immediately. + // The provider dropdown isn't applied here — initial hydrate always + // pulls whatever's cached for the project's default provider. + useEffect(() => { + let cancelled = false + setIsHydrating(true) + setError(null) + fetchRecommendationAnalysis(projectName, targetRef) + .then((cached) => { + if (cancelled) return + setIsHydrating(false) + if (cached) { + setExplanation(cached) + return + } + // No cache — auto-generate so the panel doesn't sit empty. + analyzeMutation.mutate( + { projectName, targetRef, body: {} }, + { + onSuccess: (data) => { + if (!cancelled) setExplanation(data) + }, + onError: (err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }, + }, + ) + }) + .catch((err) => { + if (cancelled) return + setIsHydrating(false) + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + // Deps are `projectName` + `targetRef` only — re-hydrating on every + // mutation state change would re-fire the GET and POST chain on every + // keystroke of the provider dropdown. The mutation object is stable + // per the useMutation contract for our usage (fire-and-forget mutate + // calls from inside the effect). + }, [projectName, targetRef]) + + const handleRegenerate = (overrideProvider?: string) => { + setError(null) + const provider = overrideProvider ?? providerOverride + analyzeMutation.mutate( + { + projectName, + targetRef, + body: { + ...(provider ? { provider } : {}), + forceRefresh: true, + }, + }, + { + onSuccess: (data) => setExplanation(data), + onError: (err) => setError(err instanceof Error ? err.message : String(err)), + }, + ) + } + + const handleProviderChange = (next: string) => { + setProviderOverride(next) + // Auto-regenerate when the user picks a new provider — keeps the + // dropdown feeling reactive instead of "select then click again." + handleRegenerate(next) + } + + const isLoading = isHydrating || analyzeMutation.isPending + const costCents = explanation ? explanation.costMillicents / 1000 : 0 + + return ( +
+
+

Why this recommendation

+ +
+ {isLoading && ( +

+ {isHydrating ? 'Looking for cached analysis…' : 'Analyzing recommendation…'} +

+ )} + {!isLoading && error && ( +
+

{error}

+ +
+ )} + {!isLoading && !error && explanation && ( + <> + +
+
+ + + +
+ + {explanation.model} · ~{costCents.toFixed(4)}¢ + +
+ + )} +
+ ) +} + function ActionPlanSection({ report, audience, projectName }: { report: ProjectReportDto; audience: ReportAudience; projectName: string }) { const rawActions = audience === 'client' ? report.clientSummary.actionItems @@ -412,6 +608,10 @@ function ActionPlanSection({ report, audience, projectName }: { report: ProjectR // dismissed" — the actual server state is whatever the next report // refetch returns. They converge after a successful round-trip. const [optimisticDismissed, setOptimisticDismissed] = useState>(new Set()) + // Set of `targetRef`s whose "Why this?" panel is currently expanded. + // Toggling drops/adds the panel — when removed the component unmounts and + // its in-flight POST cancels via the cleanup in WhyThisPanel's effect. + const [expandedExplanations, setExpandedExplanations] = useState>(new Set()) // Filter dedupedActions through the optimistic set so the UI updates // instantly. Server-side filter still applies on the next refetch; // this is purely a render-time bypass to remove perceived latency. @@ -419,6 +619,15 @@ function ActionPlanSection({ report, audience, projectName }: { report: ProjectR ? dedupedActions.filter(a => !a.targetRef || !optimisticDismissed.has(a.targetRef)) : dedupedActions + const toggleExplanation = (targetRef: string) => { + setExpandedExplanations((prev) => { + const next = new Set(prev) + if (next.has(targetRef)) next.delete(targetRef) + else next.add(targetRef) + return next + }) + } + const handleDismiss = (action: ProjectReportDto['actionPlan'][number]) => { if (!action.targetRef) return const ref = action.targetRef @@ -518,16 +727,34 @@ function ActionPlanSection({ report, audience, projectName }: { report: ProjectR {isClient ? 'What success looks like:' : 'Win condition:'} {action.successMetric}

{action.targetRef && ( -
- -
+ <> +
+ + +
+ {expandedExplanations.has(action.targetRef) && ( + toggleExplanation(action.targetRef!)} + /> + )} + )} ) diff --git a/apps/web/src/queries/mutations.ts b/apps/web/src/queries/mutations.ts index 01bf93df..b1843610 100644 --- a/apps/web/src/queries/mutations.ts +++ b/apps/web/src/queries/mutations.ts @@ -9,6 +9,7 @@ import { heyClient, type ApiRun, type ApiTriggerAllRunsResult, + analyzeRecommendation, appendQueries, dismissContentTarget, triggerRun, @@ -18,7 +19,11 @@ import { triggerInspectSitemap, undismissContentTarget, } from '../api.js' -import type { ContentTargetDismissRequest } from '@ainyc/canonry-contracts' +import type { + ContentTargetDismissRequest, + RecommendationExplainRequest, + RecommendationExplanationDto, +} from '@ainyc/canonry-contracts' import { createTrackedBatch, trackRun, type TrackedRunSourceAction } from '../lib/run-tracker-store.js' import { addToast } from '../lib/toast-store.js' import { invalidateQueriesForRunKind } from './run-invalidations.js' @@ -350,3 +355,28 @@ export function useUndismissContentTarget() { }, }) } + +/** + * Generate (or fetch cached) LLM explanation for one content recommendation. + * The backend caches per `(project, targetRef, promptVersion)` — repeat + * calls without `forceRefresh` return the cached row free, so the natural + * pattern is "fire on panel open, render `mutation.data`." + * + * We deliberately do NOT invalidate any project-scoped queries here: the + * explanation is per-card and does not change the recommendation list, + * health scores, or the report DTO. Pulling on those caches would churn + * unrelated UI for no reason. + * + * The mutation's return value is the freshly-fetched explanation; callers + * keep it locally (e.g. `mutation.data`) and re-render the panel from it. + */ +export function useAnalyzeRecommendation() { + return useMutation< + RecommendationExplanationDto, + Error, + { projectName: string; targetRef: string; body: RecommendationExplainRequest } + >({ + mutationFn: ({ projectName, targetRef, body }) => + analyzeRecommendation(projectName, targetRef, body), + }) +} diff --git a/apps/web/test/why-this-panel.test.tsx b/apps/web/test/why-this-panel.test.tsx new file mode 100644 index 00000000..72120fe1 --- /dev/null +++ b/apps/web/test/why-this-panel.test.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, expect, test, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { ExplanationBody, WhyThisPanel } from '../src/pages/ReportPage.js' +import { mockFetch, jsonResponse, pathOf } from './mock-fetch.js' + +afterEach(cleanup) + +function renderPanel(targetRef = 'tgt_abc123') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onClose = vi.fn() + return { + onClose, + ...render( + + + , + ), + } +} + +// ─── ExplanationBody ──────────────────────────────────────────────────────── + +test('ExplanationBody renders dash-prefixed lines as a bulleted list', () => { + render() + const items = screen.getAllByRole('listitem') + expect(items).toHaveLength(3) + expect(items[0]!.textContent).toBe('Competitors cited') + expect(items[2]!.textContent).toBe('Win citations') +}) + +test('ExplanationBody falls back to paragraphs when no bullets are present', () => { + render() + expect(screen.queryByRole('listitem')).toBeNull() + expect(screen.getByText('Plain explanation paragraph.')).toBeTruthy() +}) + +test('ExplanationBody renders bullets + paragraphs together', () => { + render() + expect(screen.getByText('Some context paragraph.')).toBeTruthy() + expect(screen.getAllByRole('listitem')).toHaveLength(2) +}) + +// ─── WhyThisPanel ─────────────────────────────────────────────────────────── + +const cachedResponse = { + targetRef: 'tgt_abc123', + promptVersion: 'v1', + provider: 'claude', + model: 'claude-sonnet-4-6', + responseText: '- Cached reason\n- Cached action\n- Cached outcome', + costMillicents: 42, + generatedAt: '2026-05-18T01:23:45.000Z', +} + +const freshResponse = { + targetRef: 'tgt_abc123', + promptVersion: 'v1', + provider: 'gemini', + model: 'gemini-2.5-flash', + responseText: '- Fresh reason\n- Fresh action\n- Fresh outcome', + costMillicents: 7, + generatedAt: '2026-05-18T01:24:00.000Z', +} + +test('hydrates from cached analysis when GET returns 200', async () => { + const restoreFetch = mockFetch((url) => { + const path = pathOf(url) + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) { + return jsonResponse(cachedResponse) + } + throw new Error(`unexpected fetch: ${path}`) + }) + try { + renderPanel() + await waitFor(() => { + expect(screen.getByText('Cached reason')).toBeTruthy() + }) + // Should not have triggered POST — cache was warm. + expect(screen.queryByText('Analyzing recommendation…')).toBeNull() + // Footer should show provider + model + cost (in cents). + expect(screen.getByText(/claude-sonnet-4-6/)).toBeTruthy() + // 42 millicents / 1000 = 0.042 cents → renders as ~0.0420¢ + expect(screen.getByText(/0\.0420¢/)).toBeTruthy() + } finally { + restoreFetch() + } +}) + +test('falls through to POST analyze when GET 404s', async () => { + let postCount = 0 + const restoreFetch = mockFetch((url, init) => { + const path = pathOf(url) + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) { + return jsonResponse({ error: { code: 'NOT_FOUND', message: 'none' } }, 404) + } + if ( + path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') + && init?.method === 'POST' + ) { + postCount++ + return jsonResponse(cachedResponse) + } + throw new Error(`unexpected fetch: ${path}`) + }) + try { + renderPanel() + await waitFor(() => { + expect(screen.getByText('Cached reason')).toBeTruthy() + }) + expect(postCount).toBe(1) + } finally { + restoreFetch() + } +}) + +test('renders error state when analyze fails after a GET 404', async () => { + const restoreFetch = mockFetch((url, init) => { + const path = pathOf(url) + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) { + return jsonResponse({ error: { code: 'NOT_FOUND', message: 'none' } }, 404) + } + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') && init?.method === 'POST') { + return jsonResponse( + { error: { code: 'PROVIDER_ERROR', message: 'No provider configured' } }, + 502, + ) + } + throw new Error(`unexpected fetch: ${path}`) + }) + try { + renderPanel() + await waitFor(() => { + // The error toast text comes from ApiError.message which formats as + // ": " via the global handler. Match the substring. + expect(screen.getByText(/No provider configured/i)).toBeTruthy() + }) + expect(screen.getByText(/Try again/i)).toBeTruthy() + } finally { + restoreFetch() + } +}) + +test('regenerate button forces a fresh POST with forceRefresh=true', async () => { + const analyzeBodies: string[] = [] + const restoreFetch = mockFetch((url, init) => { + const path = pathOf(url) + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) { + return jsonResponse(cachedResponse) + } + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') && init?.method === 'POST') { + analyzeBodies.push(typeof init.body === 'string' ? init.body : '') + return jsonResponse(freshResponse) + } + throw new Error(`unexpected fetch: ${path}`) + }) + try { + renderPanel() + await waitFor(() => expect(screen.getByText('Cached reason')).toBeTruthy()) + const regenerate = screen.getByRole('button', { name: /Regenerate/i }) + await act(async () => { + fireEvent.click(regenerate) + }) + await waitFor(() => expect(screen.getByText('Fresh reason')).toBeTruthy()) + expect(analyzeBodies).toHaveLength(1) + const body = JSON.parse(analyzeBodies[0]!) as { forceRefresh?: boolean; provider?: string } + expect(body.forceRefresh).toBe(true) + expect(body.provider).toBeUndefined() // no override picked + } finally { + restoreFetch() + } +}) + +test('changing the provider dropdown triggers an immediate regenerate with that provider', async () => { + let lastBody: { forceRefresh?: boolean; provider?: string } | null = null + const restoreFetch = mockFetch((url, init) => { + const path = pathOf(url) + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) { + return jsonResponse(cachedResponse) + } + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') && init?.method === 'POST') { + lastBody = JSON.parse(typeof init.body === 'string' ? init.body : '{}') + return jsonResponse({ ...freshResponse, provider: 'openai', model: 'gpt-5-mini' }) + } + throw new Error(`unexpected fetch: ${path}`) + }) + try { + renderPanel() + await waitFor(() => expect(screen.getByText('Cached reason')).toBeTruthy()) + const select = screen.getByLabelText(/Provider/i) as HTMLSelectElement + await act(async () => { + fireEvent.change(select, { target: { value: 'openai' } }) + }) + await waitFor(() => expect(screen.getByText('Fresh reason')).toBeTruthy()) + expect(lastBody).not.toBeNull() + expect(lastBody!.provider).toBe('openai') + expect(lastBody!.forceRefresh).toBe(true) + } finally { + restoreFetch() + } +}) + +test('Hide button calls onClose', async () => { + const restoreFetch = mockFetch((url) => { + const path = pathOf(url) + if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) { + return jsonResponse(cachedResponse) + } + throw new Error(`unexpected fetch: ${path}`) + }) + try { + const { onClose } = renderPanel() + await waitFor(() => expect(screen.getByText('Cached reason')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: /^Hide$/ })) + expect(onClose).toHaveBeenCalled() + } finally { + restoreFetch() + } +}) diff --git a/package.json b/package.json index e5daf7de..f63fca80 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canonry", "private": true, - "version": "4.51.0", + "version": "4.52.0", "type": "module", "packageManager": "pnpm@10.28.2", "scripts": { diff --git a/packages/canonry/package.json b/packages/canonry/package.json index 47bc885f..6de330d9 100644 --- a/packages/canonry/package.json +++ b/packages/canonry/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/canonry", - "version": "4.51.0", + "version": "4.52.0", "type": "module", "description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain", "license": "FSL-1.1-ALv2",