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
169fd6884d
commit
402442f86b
|
|
@ -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: 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 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-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 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 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`. |
|
| 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> {
|
async get(projectId: string, requestedScope: AiOverviewScope): Promise<AiOverviewResponse> {
|
||||||
const scope = await this.validateScope(projectId, requestedScope);
|
const scope = await this.validateScope(projectId, requestedScope);
|
||||||
const scopeKey = aiOverviewScopeKey(projectId, scope);
|
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;
|
let stale = false;
|
||||||
if (run?.status === 'succeeded') {
|
if (run?.status === 'succeeded' || previousSuccessfulRun) {
|
||||||
const current = await this.prepareInput(projectId, scope);
|
const current = await this.prepareInput(projectId, scope);
|
||||||
stale = current.hash !== run.inputHash
|
const saved = previousSuccessfulRun ?? run!;
|
||||||
|| run.promptVersion !== AI_OVERVIEW_PROMPT_VERSION
|
stale = current.hash !== saved.inputHash
|
||||||
|| run.outputSchemaVersion !== AI_OVERVIEW_OUTPUT_SCHEMA_VERSION
|
|| saved.promptVersion !== AI_OVERVIEW_PROMPT_VERSION
|
||||||
|| Boolean(this.client && run.model !== this.client.model);
|
|| 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> {
|
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));
|
const task = this.process(run, prepared).finally(() => this.activeTasks.delete(task));
|
||||||
this.activeTasks.add(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> {
|
async close(): Promise<void> {
|
||||||
|
|
@ -350,7 +355,7 @@ function stableResultKey(item: UnifiedResultProjection): string {
|
||||||
return [item.sourceKind, item.sourceTool ?? '', item.sourceRuleId ?? '', item.resultProjectionId].join('\u0000');
|
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 {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -359,7 +364,8 @@ function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean
|
||||||
state: run?.status ?? 'empty',
|
state: run?.status ?? 'empty',
|
||||||
stale,
|
stale,
|
||||||
pollAfterMs: run && ['queued', 'running'].includes(run.status) ? ACTIVE_POLL_MS : undefined,
|
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 availability = supported ? await this.availability() : { enabled: false };
|
||||||
const enabled = availability.enabled && supported;
|
const enabled = availability.enabled && supported;
|
||||||
const ruleKey = supported ? this.ruleKey(finding) : undefined;
|
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) : [];
|
const decisions = ruleKey ? await this.repository.listRegulatoryMappingDecisions(ruleKey) : [];
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export interface AiOverviewResponse {
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
pollAfterMs?: number;
|
pollAfterMs?: number;
|
||||||
run?: AiOverviewRun;
|
run?: AiOverviewRun;
|
||||||
|
previousSuccessfulRun?: AiOverviewRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiOverviewCreateRequest extends AiOverviewScope {
|
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 }) {
|
export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) {
|
||||||
const { response, loading, generating, error, generate } = controller;
|
const { response, loading, generating, error, generate } = controller;
|
||||||
const run = response?.run;
|
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 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">
|
return <div className="dashboard-page client-command-center ai-overview-page">
|
||||||
{navigation}
|
{navigation}
|
||||||
<header className="ai-overview-header">
|
<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><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">
|
<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}
|
{response?.generationEnabled && <button className="btn primary" type="button" disabled={Boolean(active) || generating}
|
||||||
onClick={() => void generate()}>{result || run ? 'Regenerate' : 'Generate'}</button>}
|
onClick={() => void generate()}>{result || run ? 'Regenerate' : 'Generate'}</button>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>}
|
{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>}
|
{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 || 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">
|
{!loading && !result && !active && <section className="ai-overview-empty">
|
||||||
<h2>{run?.status === 'failed' ? 'Generation failed' : 'No AI Overview yet'}</h2>
|
<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.'
|
? 'Generate an advisory summary from the privacy-safe assessment export.'
|
||||||
: 'Work LiteLLM is not configured on this server.')}</p>
|
: 'Work LiteLLM is not configured on this server.')}</p>
|
||||||
</section>}
|
</section>}
|
||||||
{result && <AiOverviewResultView result={result} generatedAt={run?.completedAt} model={run?.model} />}
|
{result && <AiOverviewResultView result={result} generatedAt={displayedRun?.completedAt} model={displayedRun?.model} />}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { describe, expect, it } from 'vitest';
|
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 { validAiOverviewResult } from '../fixtures/ai-overview';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,25 @@ describe('Client Info AI Overview', () => {
|
||||||
expect(html).toContain('Advisory AI output');
|
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', () => {
|
it('adds the AI tab beside the Client Info overview', () => {
|
||||||
const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
||||||
expect(dashboard).toContain("['overview', 'Overview'] as const,");
|
expect(dashboard).toContain("['overview', 'Overview'] as const,");
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,12 @@ describe('AI overview service', () => {
|
||||||
const complete = vi.fn()
|
const complete = vi.fn()
|
||||||
.mockResolvedValueOnce({ value: serviceResult })
|
.mockResolvedValueOnce({ value: serviceResult })
|
||||||
.mockResolvedValueOnce({ value: { schemaVersion: 1 } });
|
.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.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
|
||||||
await service.close();
|
await service.close();
|
||||||
|
currentTime = '2026-09-14T09:00:00.000Z';
|
||||||
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' });
|
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' });
|
||||||
await service.close();
|
await service.close();
|
||||||
|
|
||||||
|
|
@ -72,5 +74,33 @@ describe('AI overview service', () => {
|
||||||
expect(runs.map(run => run.status).sort()).toEqual(['failed', 'succeeded']);
|
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 === 'succeeded')?.result?.executiveSummary).toBe('Synthetic executive summary.');
|
||||||
expect(runs.find(run => run.status === 'failed')?.errorCode).toBe('ai_output_invalid');
|
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' }]);
|
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 () => {
|
it('drops one ungrounded evidence id but keeps the otherwise-valid mapping', async () => {
|
||||||
const { svc } = await runWith({
|
const { svc } = await runWith({
|
||||||
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue