Auto-rebuild AdvisoryAI knowledge corpus on startup

This commit is contained in:
master
2026-03-10 20:18:12 +02:00
parent d93006a8fa
commit f727ec24fd
7 changed files with 435 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ using StellaOps.AdvisoryAI.Evidence;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Inference.LlmProviders;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Outputs;
@@ -53,6 +54,7 @@ builder.Configuration
builder.Services.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddUnifiedSearch(builder.Configuration);
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, KnowledgeSearchStartupRebuildService>());
var llmAdapterEnabled = builder.Configuration.GetValue<bool?>("AdvisoryAI:Adapters:Llm:Enabled") ?? false;
if (llmAdapterEnabled)

View File

@@ -54,6 +54,8 @@ public sealed class KnowledgeSearchOptions
public List<string> OpenApiRoots { get; set; } = ["src", "devops/compose"];
public bool KnowledgeAutoIndexOnStartup { get; set; } = true;
public string UnifiedFindingsSnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/findings.snapshot.json";

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
internal sealed class KnowledgeSearchStartupRebuildService : IHostedService
{
private readonly KnowledgeSearchOptions _options;
private readonly IKnowledgeIndexer _indexer;
private readonly ILogger<KnowledgeSearchStartupRebuildService> _logger;
public KnowledgeSearchStartupRebuildService(
IOptions<KnowledgeSearchOptions> options,
IKnowledgeIndexer indexer,
ILogger<KnowledgeSearchStartupRebuildService> logger)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? new KnowledgeSearchOptions();
_indexer = indexer ?? throw new ArgumentNullException(nameof(indexer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
_logger.LogDebug("AdvisoryAI knowledge search is disabled; skipping startup rebuild.");
return;
}
if (!_options.KnowledgeAutoIndexOnStartup)
{
_logger.LogDebug("AdvisoryAI knowledge startup rebuild is disabled.");
return;
}
try
{
var summary = await _indexer.RebuildAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"AdvisoryAI knowledge startup rebuild completed: documents={DocumentCount}, chunks={ChunkCount}, api_specs={ApiSpecCount}, api_operations={ApiOperationCount}, doctor_projections={DoctorProjectionCount}, duration_ms={DurationMs}",
summary.DocumentCount,
summary.ChunkCount,
summary.ApiSpecCount,
summary.ApiOperationCount,
summary.DoctorProjectionCount,
summary.DurationMs);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "AdvisoryAI knowledge startup rebuild failed.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,81 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.KnowledgeSearch;
[Trait("Category", "Unit")]
public sealed class KnowledgeSearchStartupRebuildServiceTests
{
[Fact]
public async Task StartAsync_rebuilds_knowledge_index_when_enabled()
{
var indexer = new RecordingKnowledgeIndexer();
var service = new KnowledgeSearchStartupRebuildService(
Options.Create(new KnowledgeSearchOptions
{
Enabled = true,
KnowledgeAutoIndexOnStartup = true,
}),
indexer,
NullLogger<KnowledgeSearchStartupRebuildService>.Instance);
await service.StartAsync(CancellationToken.None);
Assert.Equal(1, indexer.RebuildCallCount);
}
[Fact]
public async Task StartAsync_skips_rebuild_when_startup_bootstrap_is_disabled()
{
var indexer = new RecordingKnowledgeIndexer();
var service = new KnowledgeSearchStartupRebuildService(
Options.Create(new KnowledgeSearchOptions
{
Enabled = true,
KnowledgeAutoIndexOnStartup = false,
}),
indexer,
NullLogger<KnowledgeSearchStartupRebuildService>.Instance);
await service.StartAsync(CancellationToken.None);
Assert.Equal(0, indexer.RebuildCallCount);
}
[Fact]
public async Task StartAsync_skips_rebuild_when_knowledge_search_is_disabled()
{
var indexer = new RecordingKnowledgeIndexer();
var service = new KnowledgeSearchStartupRebuildService(
Options.Create(new KnowledgeSearchOptions
{
Enabled = false,
KnowledgeAutoIndexOnStartup = true,
}),
indexer,
NullLogger<KnowledgeSearchStartupRebuildService>.Instance);
await service.StartAsync(CancellationToken.None);
Assert.Equal(0, indexer.RebuildCallCount);
}
private sealed class RecordingKnowledgeIndexer : IKnowledgeIndexer
{
public int RebuildCallCount { get; private set; }
public Task<KnowledgeRebuildSummary> RebuildAsync(CancellationToken cancellationToken)
{
RebuildCallCount++;
return Task.FromResult(new KnowledgeRebuildSummary(
DocumentCount: 470,
ChunkCount: 9050,
ApiSpecCount: 1,
ApiOperationCount: 2190,
DoctorProjectionCount: 8,
DurationMs: 42));
}
}
}

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDirectory = path.join(webRoot, 'output', 'playwright');
const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
const resultPath = path.join(outputDirectory, 'live-frontdoor-unified-search-route-matrix.json');
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
const routeMatrix = [
{
key: 'doctor',
label: 'Doctor',
route: '/ops/operations/doctor',
expectedContext: /doctor diagnostics/i,
},
{
key: 'triage',
label: 'Security Triage',
route: '/security/triage',
expectedContext: /(security\s*\/\s*triage|findings triage)/i,
},
{
key: 'policy',
label: 'Policy',
route: '/ops/policy',
expectedContext: /(policy|policy workspace)/i,
},
{
key: 'vex',
label: 'Advisories & VEX',
route: '/security/advisories-vex',
expectedContext: /(advisories\s*&\s*vex|vex intelligence)/i,
},
];
function createRuntime() {
return {
consoleErrors: [],
pageErrors: [],
};
}
function attachRuntimeListeners(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push({
timestamp: Date.now(),
page: page.url(),
text: message.text(),
});
}
});
page.on('pageerror', (error) => {
runtime.pageErrors.push({
timestamp: Date.now(),
page: page.url(),
message: error.message,
});
});
}
async function readVisibleTexts(locator) {
return locator.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
.filter(Boolean),
).catch(() => []);
}
async function openSearch(page, route) {
await page.goto(`https://stella-ops.local${route}?${scopeQuery}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForTimeout(2_500);
const input = page.locator('app-global-search input[type="text"]').first();
await input.click({ timeout: 10_000 });
await page.waitForSelector('.search__results', { state: 'visible', timeout: 10_000 });
await page.waitForTimeout(4_000);
}
async function captureSnapshot(page, routeConfig, runtime, routeStartedAt) {
return {
route: routeConfig.route,
url: page.url(),
contextTitle: (await page.locator('.search__context-title').first().textContent().catch(() => '')).trim(),
starterChips: await readVisibleTexts(page.locator('.search__suggestions .search__chip')),
degradedBanners: await readVisibleTexts(page.locator('.search__degraded-banner')),
emptyStates: await readVisibleTexts(page.locator('.search__empty, .search__empty-state-copy')),
answerStatuses: await page.locator('[data-answer-status]').evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute('data-answer-status') || '')
.filter(Boolean),
).catch(() => []),
cardTitles: await readVisibleTexts(page.locator('.search__cards .entity-card__title')),
consoleErrors: runtime.consoleErrors.filter((entry) => entry.timestamp >= routeStartedAt),
pageErrors: runtime.pageErrors.filter((entry) => entry.timestamp >= routeStartedAt),
};
}
async function executeStarter(page, routeConfig, starterIndex, runtime) {
const routeStartedAt = Date.now();
await openSearch(page, routeConfig.route);
const chips = page.locator('.search__suggestions .search__chip');
const count = await chips.count().catch(() => 0);
if (count <= starterIndex) {
throw new Error(`Starter chip index ${starterIndex} is not available on ${routeConfig.route}`);
}
const chip = chips.nth(starterIndex);
const starterText = ((await chip.textContent().catch(() => '')) || '').trim();
if (!starterText) {
throw new Error(`Starter chip index ${starterIndex} is blank on ${routeConfig.route}`);
}
await chip.click({ timeout: 10_000 });
await page.waitForTimeout(5_000);
const snapshot = await captureSnapshot(page, routeConfig, runtime, routeStartedAt);
const answerStatus = snapshot.answerStatuses[0] ?? null;
return {
starterIndex,
starterText,
answerStatus,
ok: answerStatus === 'grounded' && snapshot.cardTitles.length > 0,
snapshot,
};
}
function buildIssues(routeResult) {
const issues = [];
if (!routeResult.contextMatchesExpected) {
issues.push(`Unexpected context title on ${routeResult.route}: ${routeResult.contextTitle || '<empty>'}`);
}
if (routeResult.snapshot.degradedBanners.length > 0) {
issues.push(`Degraded banner visible on ${routeResult.route}: ${routeResult.snapshot.degradedBanners.join(' | ')}`);
}
if (routeResult.snapshot.starterChips.length === 0) {
issues.push(`No starter chips rendered on ${routeResult.route}`);
}
issues.push(...routeResult.snapshot.consoleErrors.map((entry) => `console:${entry.text}`));
issues.push(...routeResult.snapshot.pageErrors.map((entry) => `pageerror:${entry.message}`));
for (const starter of routeResult.executedStarters) {
if (!starter.ok) {
issues.push(`Starter index ${starter.starterIndex} "${starter.starterText}" did not resolve to grounded results on ${routeResult.route}`);
}
}
return issues;
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath,
reportPath,
headless: true,
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, { statePath });
const page = await context.newPage();
const runtime = createRuntime();
attachRuntimeListeners(page, runtime);
const results = [];
const runtimeIssues = [];
try {
for (const routeConfig of routeMatrix) {
const routeStartedAt = Date.now();
await openSearch(page, routeConfig.route);
const snapshot = await captureSnapshot(page, routeConfig, runtime, routeStartedAt);
const executedStarters = [];
for (let starterIndex = 0; starterIndex < Math.min(snapshot.starterChips.length, 1); starterIndex += 1) {
// eslint-disable-next-line no-await-in-loop
executedStarters.push(await executeStarter(page, routeConfig, starterIndex, runtime));
}
const routeResult = {
key: routeConfig.key,
label: routeConfig.label,
route: routeConfig.route,
contextTitle: snapshot.contextTitle,
contextMatchesExpected: routeConfig.expectedContext.test(snapshot.contextTitle),
snapshot,
executedStarters,
};
results.push(routeResult);
runtimeIssues.push(...buildIssues(routeResult));
}
} finally {
const summary = {
checkedAtUtc: new Date().toISOString(),
scopeQuery,
routesChecked: results.length,
results,
runtimeIssueCount: runtimeIssues.length,
runtimeIssues,
};
writeFileSync(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
await context.close();
await browser.close();
if (runtimeIssues.length > 0) {
throw new Error(runtimeIssues.join('; '));
}
process.stdout.write(`live-frontdoor-unified-search-route-matrix: ${results.length} routes checked, ${runtimeIssues.length} issues\n`);
}
}
main().catch((error) => {
process.stderr.write(`[live-frontdoor-unified-search-route-matrix] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});