diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 712e1c2e6a..349a2cff9a 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -107,6 +107,11 @@ export class VendorsService { }, }, }, + // Linked task statuses are needed by the vendors table to compute + // the current (interpolated) severity score so the residual badge + // reflects treatment progress, not just the static residual + // probability/impact. Mirrors the risks service. + tasks: { select: { id: true, status: true } }, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx index 42520e71f9..b748b39dc6 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx @@ -625,7 +625,8 @@ export const RisksTable = ({ SEVERITY - RISK SCORE + INHERENT RISK + CURRENT RISK STATUS OWNER @@ -658,11 +659,19 @@ export const RisksTable = ({ {(() => { - // Both columns describe the *current* treatment-aware - // state. SEVERITY shows the qualitative level as - // plain text (no chip — the colored chip is on RISK - // SCORE, so a single visual signal carries the band - // and the second column adds the precise number). + // Three score columns paint the before-vs-now picture: + // SEVERITY = current treatment-aware level (text). + // INHERENT = raw score before treatment, fixed. + // CURRENT = treatment-aware score interpolated by + // linked-task completion. Named "Current" + // (not "Residual") because the canonical + // residual is the *target* score at 100% + // completion — what's shown here moves + // with progress and matches the hero's + // "Currently X/10" subline. + // SEVERITY is plain text and CURRENT carries the + // colored chip so we don't double-paint the band. + const inherentScore = getRiskScore(risk.likelihood, risk.impact).score; const score = currentSeverityScore(risk); const level = getRiskLevelFromScore(score); return ( @@ -670,6 +679,9 @@ export const RisksTable = ({ {LEVEL_LABEL[level]} + + + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx index 962d7a4ea8..806fabd745 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx @@ -6,6 +6,12 @@ import { VendorStatus } from '@/components/vendor-status'; import { usePermissions } from '@/hooks/use-permissions'; import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors'; import { getRiskScore } from '@/lib/risk-score'; +import { + interpolatedResidualScore, + previewResidual, + suggestedResidual, +} from '@/lib/suggested-residual'; +import type { TaskStatus } from '@db'; import { AlertDialog, AlertDialogAction, @@ -54,6 +60,40 @@ export type VendorRow = Vendor & { isAssessing?: boolean; }; +/** + * Mirrors `currentSeverityScore` in the risks table — projects the vendor's + * inherent + treatment-strategy + linked-task completion into the same + * interpolated 1–10 score the Treatment Plan hero shows. Falls back to + * inherent when there's no linked work or strategy doesn't reduce. + */ +function currentVendorSeverityScore(vendor: { + inherentProbability: VendorRow['inherentProbability']; + inherentImpact: VendorRow['inherentImpact']; + treatmentStrategy: VendorRow['treatmentStrategy']; + tasks?: Array<{ status: TaskStatus }>; +}): number { + const inherent = getRiskScore(vendor.inherentProbability, vendor.inherentImpact); + const tasks = vendor.tasks ?? []; + const target = previewResidual({ + inherentLikelihood: vendor.inherentProbability, + inherentImpact: vendor.inherentImpact, + strategy: vendor.treatmentStrategy, + hasLinkedWork: tasks.length > 0, + }); + const targetScore = getRiskScore(target.likelihood, target.impact).score; + const completion = suggestedResidual({ + likelihood: vendor.inherentProbability, + impact: vendor.inherentImpact, + strategy: vendor.treatmentStrategy, + tasks, + }).completion; + return interpolatedResidualScore({ + inherentScore: inherent.score, + targetScore, + completion, + }); +} + type AssigneeMember = { id: string; role: string; @@ -349,8 +389,11 @@ export function VendorsTable({ const aAssessed = a.status === 'assessed'; const bAssessed = b.status === 'assessed'; if (aAssessed !== bAssessed) return aAssessed ? -1 : 1; - const aScore = getRiskScore(a.residualProbability, a.residualImpact).raw; - const bScore = getRiskScore(b.residualProbability, b.residualImpact).raw; + // Sort by the SAME interpolated score the badge renders so the + // sort order matches what the user sees (treatment-progress + // aware), not the static residual fields. + const aScore = currentVendorSeverityScore(a); + const bScore = currentVendorSeverityScore(b); const comparison = aScore - bScore; return sort.desc ? -comparison : comparison; } @@ -571,7 +614,7 @@ export function VendorsTable({ onClick={() => handleSort('residualRisk')} className="flex items-center hover:text-foreground" > - RESIDUAL RISK + CURRENT RISK {getSortIcon('residualRisk')} @@ -610,10 +653,12 @@ export function VendorsTable({ {vendor.status === 'not_assessed' ? ( ) : ( - + // Show the current (interpolated) score that + // reflects how far the linked tasks have driven + // the residual down — same logic the risks table + // uses. Static residualProbability / Impact alone + // can't reflect mid-treatment progress. + )} diff --git a/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx b/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx index acd55266b4..becb4fd583 100644 --- a/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx +++ b/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx @@ -113,19 +113,50 @@ export function DescriptionEditor({ // Regenerate-with-AI bypasses the in-edit guard above. When a regen run // terminates (`regenRun` flips from set → null), the user explicitly // asked to overwrite whatever they had — keeping the stale draft and - // requiring a refresh to see the new prose was confusing. Force a - // preview reset so the new value lands immediately. + // requiring a refresh to see the new prose was confusing. + // + // The new prose may already be in `value` at the moment regenRun + // clears (sync write before the parent flips the run handle), or it + // may arrive in a later render after SWR refetches. Both paths are + // handled: + // + // 1. Sync arrival: when regenRun flips set→null, immediately apply + // the current value and force preview. + // 2. Async arrival: capture the value-at-clear-time. The next render + // where `value` differs from the captured snapshot is the AI prose + // landing — apply it, force preview, and clear the latch. + // + // Without (2), a regen that completes BEFORE the SWR refetch would + // sync-apply the OLD value, and the new prose arriving moments later + // would be ignored because the in-edit guard skips resync while + // mode === 'edit'. const prevRegenRunRef = useRef(regenRun); + const valueAtRegenClearRef = useRef(null); useEffect(() => { - const wasRunning = prevRegenRunRef.current !== null && prevRegenRunRef.current !== undefined; - const isRunning = regenRun !== null && regenRun !== undefined; + const wasRunning = prevRegenRunRef.current != null; + const isRunning = regenRun != null; prevRegenRunRef.current = regenRun; if (wasRunning && !isRunning) { + // Path 1: sync arrival — value has already updated. + valueAtRegenClearRef.current = value; setDraft(value); if (value.trim().length > 0) setMode('preview'); } }, [regenRun, value]); + useEffect(() => { + const captured = valueAtRegenClearRef.current; + if (captured === null) return; + if (value === captured) return; + // Path 2: async arrival — value just changed since regen cleared, + // so this is the AI prose landing. Overwrite even if user is in + // edit mode (they explicitly opted into the overwrite by clicking + // Regenerate). + valueAtRegenClearRef.current = null; + setDraft(value); + if (value.trim().length > 0) setMode('preview'); + }, [value]); + // Auto-grow the textarea to fit content, but cap at TEXTAREA_MAX_PX so a // long draft doesn't stretch the Treatment plan column past the Strategy // / Linked Work columns. Internal scroll kicks in past the cap. diff --git a/apps/app/src/lib/strategy-descriptions.ts b/apps/app/src/lib/strategy-descriptions.ts index 6a2873c141..0f48161c79 100644 --- a/apps/app/src/lib/strategy-descriptions.ts +++ b/apps/app/src/lib/strategy-descriptions.ts @@ -27,3 +27,52 @@ export function mirrorActiveDescriptionIntoMap({ } return map; } + +/** + * Build the data fields to write when the AI mitigation generator emits a + * fresh treatment plan. Always lands the plan under the `mitigate` slot + * (the plan IS a mitigation plan), forces the active strategy to + * mitigate so the user sees the new plan in the correct column, and + * preserves any existing non-mitigate description under its own slot so + * the user's prior Accept / Transfer / Avoid rationale isn't lost. + * + * Used by both generate-risk-mitigation and generate-vendor-mitigation. + * Without this guarantee, entities created with a non-mitigate default + * have the AI plan stored under the wrong strategy and switching back + * to mitigate looks empty (the bug fixed in this commit). + */ +export function applyMitigationPlanFields({ + plan, + currentStrategy, + currentDescription, + currentMap, +}: { + plan: string; + currentStrategy: string; + currentDescription: string | null; + currentMap: unknown; +}): { + treatmentStrategy: 'mitigate'; + treatmentStrategyDescription: string; + strategyDescriptions: Record; +} { + const map: Record = {}; + if (currentMap && typeof currentMap === 'object' && !Array.isArray(currentMap)) { + for (const [k, v] of Object.entries(currentMap as Record)) { + if (typeof v === 'string' && v.length > 0) map[k] = v; + } + } + if ( + currentStrategy !== 'mitigate' && + currentDescription && + currentDescription.length > 0 + ) { + map[currentStrategy] = currentDescription; + } + map.mitigate = plan; + return { + treatmentStrategy: 'mitigate', + treatmentStrategyDescription: plan, + strategyDescriptions: map, + }; +} diff --git a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts index 7399e9ee00..b67179d282 100644 --- a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts +++ b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts @@ -17,7 +17,10 @@ import { generateObject, jsonSchema } from 'ai'; import axios from 'axios'; import { z } from 'zod'; import type { researchVendor } from '../scrape/research'; -import { mirrorActiveDescriptionIntoMap } from '@/lib/strategy-descriptions'; +import { + applyMitigationPlanFields, + mirrorActiveDescriptionIntoMap, +} from '@/lib/strategy-descriptions'; import { buildCitationsHeading } from './build-citations-heading'; import { RISK_MITIGATION_PROMPT } from './prompts/risk-mitigation'; import { @@ -667,11 +670,11 @@ ${formatCitationsBlock(citations)}`; schema: sentencesSchema, }); - const treatmentStrategy = - typeof vendor.treatmentStrategy === 'string' ? vendor.treatmentStrategy : 'mitigate'; - + // See createRiskMitigationComment — pin the prose label to 'mitigate' + // since the saved strategy is forced to mitigate by + // `applyMitigationPlanFields` below. const finalText = combineSentencesWithCitations({ - treatmentStrategy, + treatmentStrategy: 'mitigate', sentences: result.object.sentences, citations, linkedTotals: { @@ -680,20 +683,22 @@ ${formatCitationsBlock(citations)}`; }, }); - // Mirror the new text into the per-strategy map so switching strategies - // doesn't lose this draft. - const vendorActiveStrategy = - typeof vendor.treatmentStrategy === 'string' ? vendor.treatmentStrategy : 'mitigate'; + // The AI generated a mitigation plan — force the strategy to mitigate + // so the plan lands in the correct slot, even if the vendor was + // previously on Accept / Transfer / Avoid (e.g. older rows created + // before the schema default flipped to mitigate). Any prior non- + // mitigate text is preserved under its own slot. await db.vendor.update({ where: { id: vendor.id, organizationId }, - data: { - treatmentStrategyDescription: finalText, - strategyDescriptions: mirrorActiveDescriptionIntoMap({ - strategy: vendorActiveStrategy, - description: finalText, - current: vendor.strategyDescriptions, - }), - }, + data: applyMitigationPlanFields({ + plan: finalText, + currentStrategy: + typeof vendor.treatmentStrategy === 'string' + ? vendor.treatmentStrategy + : 'mitigate', + currentDescription: vendor.treatmentStrategyDescription ?? null, + currentMap: vendor.strategyDescriptions, + }), }); logger.info( @@ -1001,12 +1006,18 @@ export async function createRiskMitigationComment( gapHint: GAP_HINT_BY_RISK_CATEGORY[risk.category] ?? 'general', }); + // The AI mitigation generator always produces a *mitigation* plan, and + // `applyMitigationPlanFields` below forces the saved strategy to + // 'mitigate'. Pin the prompt + prose label to 'mitigate' too so the + // generated text isn't labeled with the row's previous strategy + // (e.g. "Treatment plan (accept)" stored under strategy=mitigate). + const PLAN_STRATEGY = 'mitigate'; const userPrompt = `Risk: ${risk.title} Description: ${risk.description} Category: ${risk.category} Department: ${risk.department ?? 'unspecified'} Residual: likelihood=${risk.residualLikelihood}, impact=${risk.residualImpact} -Treatment strategy: ${risk.treatmentStrategy} +Treatment strategy: ${PLAN_STRATEGY} Citations (write one sentence per item, in order): ${formatCitationsBlock(citations)}`; @@ -1019,7 +1030,7 @@ ${formatCitationsBlock(citations)}`; }); const finalText = combineSentencesWithCitations({ - treatmentStrategy: risk.treatmentStrategy, + treatmentStrategy: PLAN_STRATEGY, sentences: result.object.sentences, citations, linkedTotals: { @@ -1028,16 +1039,17 @@ ${formatCitationsBlock(citations)}`; }, }); + // See createVendorRiskMitigationComment — the AI plan is a mitigation + // plan, so force strategy=mitigate and preserve any prior non-mitigate + // description under its own slot. await db.risk.update({ where: { id: risk.id, organizationId }, - data: { - treatmentStrategyDescription: finalText, - strategyDescriptions: mirrorActiveDescriptionIntoMap({ - strategy: risk.treatmentStrategy, - description: finalText, - current: risk.strategyDescriptions, - }), - }, + data: applyMitigationPlanFields({ + plan: finalText, + currentStrategy: risk.treatmentStrategy, + currentDescription: risk.treatmentStrategyDescription, + currentMap: risk.strategyDescriptions, + }), }); logger.info( @@ -1159,7 +1171,11 @@ export async function createRisksFromData( metadata.set(`risk_temp_${index}_status`, 'processing'); }); - // Create all risks concurrently + // Create all risks concurrently. Strategy is intentionally NOT taken + // from the LLM extraction — we always start with mitigate (the + // workhorse strategy + schema default) so the AI mitigation plan that + // runs immediately after lands in the correct slot. The user can + // switch to accept / transfer / avoid manually if needed. const createPromises = riskData.map((risk) => db.risk.create({ data: { @@ -1169,8 +1185,6 @@ export async function createRisksFromData( department: risk.department, likelihood: risk.risk_residual_probability, impact: risk.risk_residual_impact, - treatmentStrategy: risk.risk_treatment_strategy, - treatmentStrategyDescription: risk.risk_treatment_strategy_description, organizationId, }, }), @@ -1220,6 +1234,9 @@ async function createRisksFromDataWithBaseline( }, }); } else if (risk.riskData) { + // Same rationale as createRisksFromData — strategy is fixed to + // mitigate (schema default) so the AI plan generated right after + // lands in the correct slot. return db.risk.create({ data: { title: risk.riskData.risk_name, @@ -1228,8 +1245,6 @@ async function createRisksFromDataWithBaseline( department: risk.riskData.department, likelihood: risk.riskData.risk_residual_probability, impact: risk.riskData.risk_residual_impact, - treatmentStrategy: risk.riskData.risk_treatment_strategy, - treatmentStrategyDescription: risk.riskData.risk_treatment_strategy_description, organizationId, }, }); diff --git a/apps/app/src/trigger/tasks/onboarding/update-policy.ts b/apps/app/src/trigger/tasks/onboarding/update-policy.ts index 5607c01b03..f17bcbafde 100644 --- a/apps/app/src/trigger/tasks/onboarding/update-policy.ts +++ b/apps/app/src/trigger/tasks/onboarding/update-policy.ts @@ -6,8 +6,17 @@ if (!process.env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY is not set'); } -// v4: define queue ahead of time -export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 50 }); +// v4: define queue ahead of time. +// concurrencyLimit is intentionally low so a 30+ policy onboarding fan-out +// can't hog all of dev's 25-slot env budget. Each policy update takes ~20-40s +// (LLM call + DB writes), while risk + vendor mitigations take ~8-12s. With +// no cap the slower policies fill every slot first and the user-visible +// mitigation tasks sit queued for minutes. Capping at 5 leaves ≥20 slots +// for the faster, latency-sensitive mitigation fan-out to finish quickly, +// then policies drain through. Total wall time is ~the same, but the user +// sees their treatment plans populate immediately instead of waiting for +// every policy to finish first. +export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 5 }); export const updatePolicy = schemaTask({ id: 'update-policy', diff --git a/apps/app/src/trigger/tasks/scrape/research.ts b/apps/app/src/trigger/tasks/scrape/research.ts index a1c1e75cac..8681d5787a 100644 --- a/apps/app/src/trigger/tasks/scrape/research.ts +++ b/apps/app/src/trigger/tasks/scrape/research.ts @@ -1,8 +1,19 @@ import { researchJobCore } from '@/trigger/lib/research'; import { db } from '@db/server'; -import { schemaTask } from '@trigger.dev/sdk'; +import { queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; +// Each research run can hold a slot for minutes (firecrawl scrape + LLM +// extraction). Without a cap, a 10-vendor onboarding can hog the whole +// 25-slot dev env budget for the entire research window — starving the +// mitigation + policy fan-outs that fire right after. Capping at 5 lets +// research progress in batches while leaving 20 slots free for the rest +// of the onboarding pipeline. +const researchVendorQueue = queue({ + name: 'research-vendor', + concurrencyLimit: 5, +}); + const firecrawlDataSchema = z.object({ company_name: z.string().optional().nullable(), legal_name: z.string().optional().nullable(), @@ -35,6 +46,7 @@ const firecrawlDataSchema = z.object({ export const researchVendor = schemaTask({ id: 'research-vendor', + queue: researchVendorQueue, schema: z.object({ website: z.string().url(), }), diff --git a/packages/db/prisma/migrations/20260506130119_default_treatment_strategy_to_mitigate/migration.sql b/packages/db/prisma/migrations/20260506130119_default_treatment_strategy_to_mitigate/migration.sql new file mode 100644 index 0000000000..dec23b75e2 --- /dev/null +++ b/packages/db/prisma/migrations/20260506130119_default_treatment_strategy_to_mitigate/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Risk" ALTER COLUMN "treatmentStrategy" SET DEFAULT 'mitigate'; + +-- AlterTable +ALTER TABLE "Vendor" ALTER COLUMN "treatmentStrategy" SET DEFAULT 'mitigate'; diff --git a/packages/db/prisma/schema/risk.prisma b/packages/db/prisma/schema/risk.prisma index fcb6c25a13..b4a95441dd 100644 --- a/packages/db/prisma/schema/risk.prisma +++ b/packages/db/prisma/schema/risk.prisma @@ -11,7 +11,11 @@ model Risk { residualLikelihood Likelihood @default(very_unlikely) residualImpact Impact @default(insignificant) treatmentStrategyDescription String? - treatmentStrategy RiskTreatmentType @default(accept) + // Mitigate is the workhorse — the AI mitigation generator writes a + // mitigation plan for every new risk, and the plan is stored under + // the active strategy. Defaulting to anything other than mitigate + // would put the AI plan under the wrong slot. + treatmentStrategy RiskTreatmentType @default(mitigate) // Per-strategy text store. When the user switches strategies, the current // `treatmentStrategyDescription` is moved into this map under the OLD // strategy key, and the NEW strategy's saved value is loaded back into the diff --git a/packages/db/prisma/schema/vendor.prisma b/packages/db/prisma/schema/vendor.prisma index 5679053a8c..afdd196e3a 100644 --- a/packages/db/prisma/schema/vendor.prisma +++ b/packages/db/prisma/schema/vendor.prisma @@ -8,7 +8,9 @@ model Vendor { inherentImpact Impact @default(insignificant) residualProbability Likelihood @default(very_unlikely) residualImpact Impact @default(insignificant) - treatmentStrategy RiskTreatmentType @default(accept) + // See Risk.treatmentStrategy — default mitigate so AI plans land in + // the right slot. + treatmentStrategy RiskTreatmentType @default(mitigate) treatmentStrategyDescription String? // See `Risk.strategyDescriptions`. strategyDescriptions Json?