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:
parent
18169f16d6
commit
0466855c07
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export interface AiOverviewResponse {
|
|||
stale: boolean;
|
||||
pollAfterMs?: number;
|
||||
run?: AiOverviewRun;
|
||||
previousSuccessfulRun?: AiOverviewRun;
|
||||
}
|
||||
|
||||
export interface AiOverviewCreateRequest extends AiOverviewScope {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,");
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue