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.
This commit is contained in:
Binh 2026-10-02 11:38:44 +02:00
parent 18169f16d6
commit 0466855c07
8 changed files with 99 additions and 16 deletions

View File

@ -3644,6 +3644,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung |
| 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:<technische Setting-ID>`, 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`. |

View File

@ -96,16 +96,19 @@ export class AiOverviewService {
async get(projectId: string, requestedScope: AiOverviewScope): Promise<AiOverviewResponse> {
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<AiOverviewResponse> {
@ -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<void> {
@ -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
};
}

View File

@ -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,

View File

@ -81,6 +81,7 @@ export interface AiOverviewResponse {
stale: boolean;
pollAfterMs?: number;
run?: AiOverviewRun;
previousSuccessfulRun?: AiOverviewRun;
}
export interface AiOverviewCreateRequest extends AiOverviewScope {

View File

@ -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 <div className="dashboard-page client-command-center ai-overview-page">
{navigation}
<header className="ai-overview-header">
<div><span>Advisory analysis</span><h1>AI Overview</h1><p>Generated from the complete privacy-safe export for this assessment timeslot.</p></div>
<div className="ai-overview-actions">
{run && <span className={`ai-overview-state is-${response?.stale ? 'stale' : run.status}`}>{response?.stale ? 'Stale' : run.status}</span>}
{run && <span className={`ai-overview-state is-${staleState ? 'stale' : run.status}`}>{staleState ? 'Stale' : run.status}</span>}
{response?.generationEnabled && <button className="btn primary" type="button" disabled={Boolean(active) || generating}
onClick={() => void generate()}>{result || run ? 'Regenerate' : 'Generate'}</button>}
</div>
</header>
{response?.stale && result && <p className="ai-overview-notice">The assessment data, model, or analysis contract changed. This saved overview remains visible until you regenerate it.</p>}
{error && <p className="ai-overview-error" role="alert">{error}</p>}
{run?.status === 'failed' && result && <p className="ai-overview-error" role="alert">{run.errorMessage ?? 'Generation failed.'} Showing the previous saved overview.</p>}
{(loading || active || generating) && !result && <section className="ai-overview-empty"><h2>{active ? 'Generation in progress' : 'Loading AI Overview'}</h2><p>{active ? 'The server is analyzing the complete timeslot. You can leave this tab while it runs.' : 'Loading the latest saved analysis…'}</p></section>}
{!loading && !result && !active && <section className="ai-overview-empty">
<h2>{run?.status === 'failed' ? 'Generation failed' : 'No AI Overview yet'}</h2>
@ -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.')}</p>
</section>}
{result && <AiOverviewResultView result={result} generatedAt={run?.completedAt} model={run?.model} />}
{result && <AiOverviewResultView result={result} generatedAt={displayedRun?.completedAt} model={displayedRun?.model} />}
</div>;
}

View File

@ -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(<AiOverviewPanel controller={{ response, loading: false, generating: false, generate: async () => {} }} />);
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,");

View File

@ -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 } });
});
});

View File

@ -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',