From 402442f86b3db8cf7f2318dd622c2d6b25e4868b Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 2 Oct 2026 11:38:44 +0200 Subject: [PATCH] fix: keep saved AI overviews and ISO mappings available Partial pick of 95ec322: a failed or queued regeneration keeps the previous successful AI overview visible, and a stored ISO mapping stays readable while the retrieval index is unavailable. The Defender device risk-acceptance fix of that commit is not taken here. --- docs/IMPLEMENTATION_PLAN.md | 1 + server/ai/ai-overview-service.ts | 26 +++++++++------ .../regulatory/regulatory-mapping-service.ts | 2 +- src/domain/ai-overview.ts | 1 + src/features/dashboard/AiOverviewPanel.tsx | 9 ++++-- tests/features/ai-overview-panel.test.tsx | 22 ++++++++++++- tests/server/ai-overview-service.test.ts | 32 ++++++++++++++++++- .../server/regulatory-mapping-service.test.ts | 22 +++++++++++++ 8 files changed, 99 insertions(+), 16 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index fda12ce..a9faef9 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4223,6 +4223,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client | 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. | | 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. | | 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. | +| 2026-10-02 | Phase 7/13F: Aus `95ec322` die AI-Overview- und ISO-Mapping-Korrekturen uebernommen. Schlaegt eine Regeneration fehl oder laeuft sie noch, liefert der AI-Overview-Service zusaetzlich `previousSuccessfulRun`; das Panel zeigt die letzte gespeicherte Uebersicht mit der Fehlermeldung weiter, `Stale` gilt nur fuer einen erfolgreichen, veralteten Lauf. Ein gespeichertes ISO-Mapping bleibt lesbar, wenn Qdrant oder der Embeddingdienst nicht erreichbar ist; die Generierung bleibt dann deaktiviert. Synthetische Service- und Paneltests. Die Defender-Geraete-Risikoakzeptanz desselben Commits folgt mit den Defender-Commits. | | 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. | | 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. | | 2026-10-02 | Phase 7: Privacy-Safe-`Export JSON` um GPO-Vergleiche ergaenzt. Handlungsrelevante `policy-evaluation`-Findings tragen einen `policy`-Block (Status, Baseline-Familie, Sollwert, Vergleich, Kundenzustand, Scope, Delivery, Diagnosecodes) ausschliesslich aus der bestehenden Result-DB-Disclosure-Projektion; der Kundenwert bleibt zurueckgehalten. Rohe `policy-observation`-Zeilen zaehlen nicht mehr als Findings, sondern separat als `policyObservationResults`; `policyStatuses` zaehlt die GPO-Urteile. Synthetischer Test in `tests/exports/result-db-copy.test.ts`. | diff --git a/server/ai/ai-overview-service.ts b/server/ai/ai-overview-service.ts index 85c856f..d560213 100644 --- a/server/ai/ai-overview-service.ts +++ b/server/ai/ai-overview-service.ts @@ -96,16 +96,19 @@ export class AiOverviewService { async get(projectId: string, requestedScope: AiOverviewScope): Promise { const scope = await this.validateScope(projectId, requestedScope); const scopeKey = aiOverviewScopeKey(projectId, scope); - const run = (await this.repository.listAiOverviewRuns(projectId, scopeKey))[0]; + const runs = await this.repository.listAiOverviewRuns(projectId, scopeKey); + const run = runs[0]; + const previousSuccessfulRun = run?.status === 'succeeded' ? undefined : runs.find(item => item.status === 'succeeded'); let stale = false; - if (run?.status === 'succeeded') { + if (run?.status === 'succeeded' || previousSuccessfulRun) { const current = await this.prepareInput(projectId, scope); - stale = current.hash !== run.inputHash - || run.promptVersion !== AI_OVERVIEW_PROMPT_VERSION - || run.outputSchemaVersion !== AI_OVERVIEW_OUTPUT_SCHEMA_VERSION - || Boolean(this.client && run.model !== this.client.model); + const saved = previousSuccessfulRun ?? run!; + stale = current.hash !== saved.inputHash + || saved.promptVersion !== AI_OVERVIEW_PROMPT_VERSION + || saved.outputSchemaVersion !== AI_OVERVIEW_OUTPUT_SCHEMA_VERSION + || Boolean(this.client && saved.model !== this.client.model); } - return responseFor(projectId, scope, this.generationEnabled, run, stale); + return responseFor(projectId, scope, this.generationEnabled, run, stale, previousSuccessfulRun); } async start(projectId: string, request: AiOverviewCreateRequest): Promise { @@ -138,7 +141,9 @@ export class AiOverviewService { const task = this.process(run, prepared).finally(() => this.activeTasks.delete(task)); this.activeTasks.add(task); } - return responseFor(projectId, scope, true, run, false); + const previousSuccessfulRun = run.status === 'succeeded' ? undefined + : (await this.repository.listAiOverviewRuns(projectId, run.scopeKey)).find(item => item.status === 'succeeded'); + return responseFor(projectId, scope, true, run, false, previousSuccessfulRun); } async close(): Promise { @@ -350,7 +355,7 @@ function stableResultKey(item: UnifiedResultProjection): string { return [item.sourceKind, item.sourceTool ?? '', item.sourceRuleId ?? '', item.resultProjectionId].join('\u0000'); } -function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean, run?: AiOverviewRun, stale = false): AiOverviewResponse { +function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean, run?: AiOverviewRun, stale = false, previousSuccessfulRun?: AiOverviewRun): AiOverviewResponse { return { version: 1, projectId, @@ -359,7 +364,8 @@ function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean state: run?.status ?? 'empty', stale, pollAfterMs: run && ['queued', 'running'].includes(run.status) ? ACTIVE_POLL_MS : undefined, - run + run, + previousSuccessfulRun }; } diff --git a/server/regulatory/regulatory-mapping-service.ts b/server/regulatory/regulatory-mapping-service.ts index 5705b87..2a42a6c 100644 --- a/server/regulatory/regulatory-mapping-service.ts +++ b/server/regulatory/regulatory-mapping-service.ts @@ -104,7 +104,7 @@ export class RegulatoryMappingService { const availability = supported ? await this.availability() : { enabled: false }; const enabled = availability.enabled && supported; const ruleKey = supported ? this.ruleKey(finding) : undefined; - const run = enabled && ruleKey ? await this.repository.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null; + const run = ruleKey && this.options.client ? await this.repository.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null; const decisions = ruleKey ? await this.repository.listRegulatoryMappingDecisions(ruleKey) : []; return { version: 1, diff --git a/src/domain/ai-overview.ts b/src/domain/ai-overview.ts index 74982ee..9a4d5fc 100644 --- a/src/domain/ai-overview.ts +++ b/src/domain/ai-overview.ts @@ -81,6 +81,7 @@ export interface AiOverviewResponse { stale: boolean; pollAfterMs?: number; run?: AiOverviewRun; + previousSuccessfulRun?: AiOverviewRun; } export interface AiOverviewCreateRequest extends AiOverviewScope { diff --git a/src/features/dashboard/AiOverviewPanel.tsx b/src/features/dashboard/AiOverviewPanel.tsx index 05dda5f..51c49a3 100644 --- a/src/features/dashboard/AiOverviewPanel.tsx +++ b/src/features/dashboard/AiOverviewPanel.tsx @@ -89,20 +89,23 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) { const { response, loading, generating, error, generate } = controller; const run = response?.run; - const result = run?.result; + const displayedRun = run?.result ? run : response?.previousSuccessfulRun; + const result = displayedRun?.result; const active = response && ['queued', 'running'].includes(response.state); + const staleState = response?.stale && run?.status === 'succeeded'; return
{navigation}
Advisory analysis

AI Overview

Generated from the complete privacy-safe export for this assessment timeslot.

- {run && {response?.stale ? 'Stale' : run.status}} + {run && {staleState ? 'Stale' : run.status}} {response?.generationEnabled && }
{response?.stale && result &&

The assessment data, model, or analysis contract changed. This saved overview remains visible until you regenerate it.

} {error &&

{error}

} + {run?.status === 'failed' && result &&

{run.errorMessage ?? 'Generation failed.'} Showing the previous saved overview.

} {(loading || active || generating) && !result &&

{active ? 'Generation in progress' : 'Loading AI Overview'}

{active ? 'The server is analyzing the complete timeslot. You can leave this tab while it runs.' : 'Loading the latest saved analysis…'}

} {!loading && !result && !active &&

{run?.status === 'failed' ? 'Generation failed' : 'No AI Overview yet'}

@@ -110,7 +113,7 @@ export function AiOverviewPanel({ controller, navigation }: { controller: AiOver ? 'Generate an advisory summary from the privacy-safe assessment export.' : 'Work LiteLLM is not configured on this server.')}

} - {result && } + {result && }
; } diff --git a/tests/features/ai-overview-panel.test.tsx b/tests/features/ai-overview-panel.test.tsx index 8017b19..4173515 100644 --- a/tests/features/ai-overview-panel.test.tsx +++ b/tests/features/ai-overview-panel.test.tsx @@ -1,6 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { AiOverviewResultView } from '../../src/features/dashboard/AiOverviewPanel'; +import { AiOverviewPanel, AiOverviewResultView } from '../../src/features/dashboard/AiOverviewPanel'; +import type { AiOverviewResponse, AiOverviewRun } from '../../src/domain/ai-overview'; import { validAiOverviewResult } from '../fixtures/ai-overview'; import { readFileSync } from 'node:fs'; @@ -17,6 +18,25 @@ describe('Client Info AI Overview', () => { expect(html).toContain('Advisory AI output'); }); + it('shows the failed regeneration and the previous saved overview together', () => { + const base: AiOverviewRun = { + version: 1, id: 'run-1', projectId: 'project-1', scopeKey: 'scope-1', assessmentIds: [], scanRunIds: [], + idempotencyKey: 'request-1', status: 'succeeded', inputHash: 'a'.repeat(64), promptVersion: '1', + outputSchemaVersion: 1, model: 'work-model', createdAt: '2026-09-14T08:00:00.000Z', + updatedAt: '2026-09-14T08:00:00.000Z', callCount: 1, result: validAiOverviewResult() + }; + const response: AiOverviewResponse = { + version: 1, projectId: 'project-1', scope: { assessmentIds: [], scanRunIds: [] }, + generationEnabled: true, state: 'failed', stale: false, + run: { ...base, id: 'run-2', status: 'failed', result: undefined, errorMessage: 'Generation failed safely.' }, + previousSuccessfulRun: base + }; + const html = renderToStaticMarkup( {} }} />); + expect(html).toContain('Generation failed safely.'); + expect(html).toContain('Synthetic executive summary.'); + expect(html).toContain('previous saved overview'); + }); + it('adds the AI tab beside the Client Info overview', () => { const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8'); expect(dashboard).toContain("['overview', 'Overview'] as const,"); diff --git a/tests/server/ai-overview-service.test.ts b/tests/server/ai-overview-service.test.ts index b80e96c..02ff6db 100644 --- a/tests/server/ai-overview-service.test.ts +++ b/tests/server/ai-overview-service.test.ts @@ -61,10 +61,12 @@ describe('AI overview service', () => { const complete = vi.fn() .mockResolvedValueOnce({ value: serviceResult }) .mockResolvedValueOnce({ value: { schemaVersion: 1 } }); - const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(stamp) }); + let currentTime = stamp; + const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(currentTime) }); await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' }); await service.close(); + currentTime = '2026-09-14T09:00:00.000Z'; await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' }); await service.close(); @@ -72,5 +74,33 @@ describe('AI overview service', () => { expect(runs.map(run => run.status).sort()).toEqual(['failed', 'succeeded']); expect(runs.find(run => run.status === 'succeeded')?.result?.executiveSummary).toBe('Synthetic executive summary.'); expect(runs.find(run => run.status === 'failed')?.errorCode).toBe('ai_output_invalid'); + const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] }); + expect(response).toMatchObject({ + state: 'failed', run: { errorCode: 'ai_output_invalid' }, + previousSuccessfulRun: { result: { executiveSummary: 'Synthetic executive summary.' } } + }); + }); + + it('marks the previous success stale while a regeneration is queued', async () => { + const repository = new LocalAssessorRepository(); + await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp }); + await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp }); + vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] }); + const serviceResult = validAiOverviewResult(); + serviceResult.coverageGaps = []; + serviceResult.environmentOverview.factsUsed = []; + const complete = vi.fn().mockResolvedValueOnce({ value: serviceResult }); + const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(stamp) }); + await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' }); + await service.close(); + const [saved] = await repository.listAiOverviewRuns('project-1'); + await repository.createAiOverviewRun({ + ...saved, id: 'ai-overview-queued', idempotencyKey: 'request-queued', status: 'queued', inputHash: 'b'.repeat(64), + result: undefined, completedAt: undefined, createdAt: '2026-09-14T09:00:00.000Z', updatedAt: '2026-09-14T09:00:00.000Z', callCount: 0 + }); + vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [{ ...syntheticResult(), title: 'Changed synthetic finding' }] }); + + const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] }); + expect(response).toMatchObject({ state: 'queued', stale: true, previousSuccessfulRun: { id: saved.id } }); }); }); diff --git a/tests/server/regulatory-mapping-service.test.ts b/tests/server/regulatory-mapping-service.test.ts index ebc49fb..bc8b0f4 100644 --- a/tests/server/regulatory-mapping-service.test.ts +++ b/tests/server/regulatory-mapping-service.test.ts @@ -142,6 +142,28 @@ describe('regulatory mapping service', () => { expect(afterDecision.decisions.map(d => ({ controlId: d.controlId, decision: d.decision }))).toEqual([{ controlId: '5.1', decision: 'accepted' }]); }); + it('keeps a saved mapping readable when the retrieval index is unavailable', async () => { + const repository = mockRepository([finding()]); + const complete = vi.fn(async (_system: string, _input: string) => ({ value: { + schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high', + rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] + } })); + const online = service(repository, complete); + await online.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' }); + await online.close(); + + const offline = new RegulatoryMappingService(repository, { + findingRetrieval: {} as never, + index: { list: vi.fn(async () => { throw new Error('Index unavailable'); }) } as never, + client: { model: 'work-model', complete } as never, + corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3' + }); + const response = await offline.get('project-1', 'scanner-finding:occurrence-1'); + expect(response).toMatchObject({ generationEnabled: false, state: 'succeeded', run: { status: 'succeeded' } }); + expect(response.run?.result?.mappings[0].controlId).toBe('5.1'); + expect(response.run?.candidates.every(candidate => candidate.evidence.every(item => item.text === undefined))).toBe(true); + }); + it('drops one ungrounded evidence id but keeps the otherwise-valid mapping', async () => { const { svc } = await runWith({ schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',