Skip to content

Commit 704973c

Browse files
devversionAndrewKushnir
authored andcommitted
feat: add ability to ask LLM questions about a report
Often times I find the AI Summary to be super helpful, but there are also cases where the summary is lacking critical information, or giving me too verbose information. Every user might look for different things here. To address this need, and leverage the full power of AI for e.g. quantifying findings (instead of manual chore work, clicking through every app), this PR introduces an AI assistant chat. Users can ask questions about a report and get answers! This is an extension of the AI Summary and can be extremely helpful.
1 parent e0aa394 commit 704973c

File tree

20 files changed

+578
-14441
lines changed

20 files changed

+578
-14441
lines changed

package-lock.json

Lines changed: 0 additions & 14347 deletions
This file was deleted.

report-app/angular.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,19 @@
1616
"browser": "src/main.ts",
1717
"polyfills": ["zone.js"],
1818
"tsConfig": "tsconfig.app.json",
19-
"externalDependencies": ["@firebase/app", "@firebase/firestore"],
19+
"externalDependencies": [
20+
"@firebase/app",
21+
"@firebase/firestore",
22+
"tiktoken",
23+
"genkit",
24+
"@genkit-ai/compat-oai",
25+
"@genkit-ai/googleai",
26+
"@genkit-ai/mcp",
27+
"@google/genai",
28+
"@google/generative-ai",
29+
"genkitx-anthropic",
30+
"node-fetch"
31+
],
2032
"allowedCommonJsDependencies": [
2133
"prettier/plugins/angular.js",
2234
"prettier/plugins/typescript.js",

report-app/report-server.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import {
77
import express from 'express';
88
import {dirname, isAbsolute, join, resolve} from 'node:path';
99
import {fileURLToPath} from 'node:url';
10-
import {FetchedLocalReports, fetchReportsFromDisk} from '../runner/reporting/report-local-disk';
11-
import {RunInfo} from '../runner/shared-interfaces';
10+
import {chatWithReportAI} from '../runner/reporting/report-ai-chat';
1211
import {convertV2ReportToV3Report} from '../runner/reporting/migrations/v2_to_v3';
12+
import {FetchedLocalReports, fetchReportsFromDisk} from '../runner/reporting/report-local-disk';
13+
import {AiChatRequest, RunInfo} from '../runner/shared-interfaces';
14+
15+
// This will result in a lot of loading and would slow down the serving,
16+
// so it's loaded lazily below.
17+
import {type GenkitRunner} from '../runner/codegen/genkit/genkit-runner';
1318

1419
const app = express();
1520
const reportsLoader = await getReportLoader();
@@ -19,6 +24,8 @@ const browserDistFolder = resolve(serverDistFolder, '../browser');
1924
const angularApp = new AngularNodeAppEngine();
2025
let localDataPromise: Promise<FetchedLocalReports> | null = null;
2126

27+
app.use(express.json());
28+
2229
// Endpoint for fetching all available report groups.
2330
app.get('/api/reports', async (_, res) => {
2431
const [remoteGroups, localData] = await Promise.all([
@@ -34,9 +41,7 @@ app.get('/api/reports', async (_, res) => {
3441
res.json(results);
3542
});
3643

37-
// Endpoint for fetching a specific report group.
38-
app.get('/api/reports/:id', async (req, res) => {
39-
const id = req.params.id;
44+
async function fetchAndMigrateReports(id: string): Promise<RunInfo[] | null> {
4045
const localData = await resolveLocalData(options.reportsRoot);
4146
let result: RunInfo[] | null = null;
4247

@@ -46,10 +51,56 @@ app.get('/api/reports/:id', async (req, res) => {
4651
result = await reportsLoader.getGroupedReports(id);
4752
}
4853

54+
if (result === null) {
55+
return null;
56+
}
57+
4958
// Convert potential older v2 reports.
50-
result = result.map(r => convertV2ReportToV3Report(r));
59+
return result.map(r => convertV2ReportToV3Report(r));
60+
}
61+
62+
// Endpoint for fetching a specific report group.
63+
app.get('/api/reports/:id', async (req, res) => {
64+
const id = req.params.id;
65+
const result = await fetchAndMigrateReports(id);
66+
67+
res.json(result ?? []);
68+
});
69+
70+
let llm: Promise<GenkitRunner> | null = null;
71+
72+
/** Lazily initializes and returns the genkit runner. */
73+
async function getOrCreateGenkitLlmRunner() {
74+
const llm = new (await import('../runner/codegen/genkit/genkit-runner')).GenkitRunner();
75+
// Gracefully shut down the runner on exit.
76+
process.on('SIGINT', () => llm!.dispose());
77+
process.on('SIGTERM', () => llm!.dispose());
78+
return llm;
79+
}
80+
81+
// Endpoint for fetching a specific report group.
82+
app.post('/api/reports/:id/chat', async (req, res) => {
83+
const id = req.params.id;
84+
const reports = await fetchAndMigrateReports(id);
85+
86+
if (reports === null) {
87+
res.status(404).send('Not found');
88+
return;
89+
}
5190

52-
res.json(result);
91+
const {prompt, pastMessages, model} = req.body as AiChatRequest;
92+
const assessments = reports.flatMap(run => run.results);
93+
const abortController = new AbortController();
94+
const summary = await chatWithReportAI(
95+
await (llm ?? getOrCreateGenkitLlmRunner()),
96+
prompt,
97+
abortController.signal,
98+
assessments,
99+
pastMessages,
100+
model,
101+
);
102+
103+
res.json(summary);
53104
});
54105

55106
app.use(
@@ -106,6 +157,7 @@ async function getReportLoader() {
106157
return {
107158
getGroupedReports: () => Promise.resolve([]),
108159
getGroupsList: () => Promise.resolve([]),
160+
configureEndpoints: async () => {},
109161
} satisfies ReportLoader;
110162
}
111163

report-app/src/app/pages/report-viewer/report-viewer.html

Lines changed: 27 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,7 @@ <h3 class="chart-title">
7070
<span>Build</span>
7171
</h3>
7272
<div class="summary-card-item">
73-
<stacked-bar-chart
74-
[data]="buildsAsGraphData(overview.stats.builds)"
75-
[compact]="true"
76-
/>
73+
<stacked-bar-chart [data]="buildsAsGraphData(overview.stats.builds)" [compact]="true" />
7774
</div>
7875
</div>
7976
@if (overview.stats.runtime) {
@@ -176,13 +173,15 @@ <h4>Repair System Prompt</h4>
176173
</expansion-panel>
177174

178175
@if (report.details.summary.aiSummary !== undefined) {
179-
<expansion-panel size="large" class="root-section">
180-
<expansion-panel-header>
181-
<img src="gemini.webp" alt="Gemini Logo" height="30" width="30" />
182-
AI Summary
183-
</expansion-panel-header>
184-
<div [innerHTML]="report.details.summary.aiSummary"></div>
185-
</expansion-panel>
176+
<button class="fab" (click)="isAiAssistantVisible.set(true)">
177+
<span class="material-symbols-outlined">smart_toy</span>
178+
</button>
179+
180+
<app-ai-assistant
181+
[class.hidden]="!isAiAssistantVisible()"
182+
[reportGroupId]="reportGroupId()"
183+
(close)="isAiAssistantVisible.set(false)"
184+
/>
186185
}
187186

188187
@if (missingDeps().length > 0) {
@@ -232,9 +231,7 @@ <h4>Logs</h4>
232231
<h2>Generated applications</h2>
233232
@if (allFailedChecks().length > 0) {
234233
<details class="filter-dropdown" #dropdown>
235-
<summary>
236-
Filter by failed checks ({{ selectedChecks().size }} selected)
237-
</summary>
234+
<summary>Filter by failed checks ({{ selectedChecks().size }} selected)</summary>
238235
<div class="dropdown-content">
239236
<failed-checks-filter
240237
[allFailedChecks]="allFailedChecks()"
@@ -300,9 +297,7 @@ <h5>
300297
@if (isSkippedAssessment(check)) {
301298
<span class="status"></span>
302299
<span class="name">{{ check.name }}</span>
303-
<span class="status-text points"
304-
>Skipped: {{ check.message }}</span
305-
>
300+
<span class="status-text points">Skipped: {{ check.message }}</span>
306301
} @else {
307302
@let isMax = check.successPercentage === 1;
308303
<span
@@ -340,9 +335,11 @@ <h5>
340335
[class.warn]="totalPercent < 90 && totalPercent >= 80"
341336
[class.error]="totalPercent < 80"
342337
>
343-
{{ result.score.totalPoints }} /
344-
{{ result.score.maxOverallPoints }} points ({{
338+
{{ result.score.totalPoints }} / {{ result.score.maxOverallPoints }} points ({{
345339
totalPercent
340+
341+
342+
346343
}}%)
347344
</span>
348345
</div>
@@ -352,10 +349,8 @@ <h5>
352349
<h4>Additional info</h4>
353350
@for (attempt of result.attemptDetails; track attempt) {
354351
@let isBuilt = attempt.buildResult.status === 'success';
355-
@let axeViolations =
356-
attempt.serveTestingResult?.axeViolations;
357-
@let hasAxeViolations =
358-
axeViolations && axeViolations.length > 0;
352+
@let axeViolations = attempt.serveTestingResult?.axeViolations;
353+
@let hasAxeViolations = axeViolations && axeViolations.length > 0;
359354

360355
<expansion-panel #expansionPanel>
361356
<expansion-panel-header>
@@ -385,9 +380,7 @@ <h4>Additional info</h4>
385380
@if (expansionPanel.opened()) {
386381
@if (attempt.reasoning) {
387382
<details class="thoughts-button">
388-
<summary class="neutral-button">
389-
See LLM Thoughts
390-
</summary>
383+
<summary class="neutral-button">See LLM Thoughts</summary>
391384
<pre class="callout neutral code">{{
392385
attempt.reasoning
393386
}}</pre>
@@ -430,25 +423,16 @@ <h4>Generated Code</h4>
430423
Format source code
431424
</button>
432425
</div>
433-
<app-code-viewer
434-
[code]="formatted().get(file) ?? file.code"
435-
/>
426+
<app-code-viewer [code]="formatted().get(file) ?? file.code" />
436427
}
437428
}
438429
</expansion-panel>
439430
}
440431

441-
@if (
442-
result.userJourneys && result.userJourneys.result.length > 0
443-
) {
432+
@if (result.userJourneys && result.userJourneys.result.length > 0) {
444433
<expansion-panel>
445-
<expansion-panel-header
446-
>User Journeys</expansion-panel-header
447-
>
448-
@for (
449-
journey of result.userJourneys.result;
450-
track journey.name
451-
) {
434+
<expansion-panel-header>User Journeys</expansion-panel-header>
435+
@for (journey of result.userJourneys.result; track journey.name) {
452436
<h4>{{ journey.name }}</h4>
453437

454438
<ol>
@@ -467,7 +451,8 @@ <h4>Debugging Tools</h4>
467451
<button
468452
class="neutral-button"
469453
title="Download a ZIP for debugging. You can upload the ZIP to AI Studio for further analysis of a specific app."
470-
(click)="downloadDebuggingZip(result)">
454+
(click)="downloadDebuggingZip(result)"
455+
>
471456
Download ZIP for debugging
472457
</button>
473458

@@ -487,8 +472,7 @@ <h4>Debugging Tools</h4>
487472
<details class="details mcp-log-entry">
488473
@let name = log.request.name;
489474
<summary>
490-
Log Entry #{{ $index + 1
491-
}}{{ name ? ' - ' + name : '' }}
475+
Log Entry #{{ $index + 1}}{{ name ? ' - ' + name : '' }}
492476
</summary>
493477
<div class="mcp-log-content">
494478
<h5>Request</h5>
@@ -512,8 +496,7 @@ <h5>Response</h5>
512496
}
513497
</div>
514498

515-
@let finalRuntimeErrors =
516-
finalAttempt.serveTestingResult?.runtimeErrors;
499+
@let finalRuntimeErrors = finalAttempt.serveTestingResult?.runtimeErrors;
517500
@if (finalRuntimeErrors) {
518501
<div class="app-details-section">
519502
<h4>Runtime errors</h4>

report-app/src/app/pages/report-viewer/report-viewer.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,26 @@ expansion-panel {
262262
list-style: none;
263263
padding: 0;
264264
}
265+
266+
.fab {
267+
position: fixed;
268+
bottom: 20px;
269+
right: 20px;
270+
z-index: 999;
271+
display: flex;
272+
align-items: center;
273+
gap: 8px;
274+
padding: 12px 18px;
275+
border-radius: 28px;
276+
background-color: #007bff;
277+
color: white;
278+
border: none;
279+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
280+
cursor: pointer;
281+
font-size: 1em;
282+
font-weight: 500;
283+
}
284+
285+
.hidden {
286+
visibility: hidden;
287+
}

report-app/src/app/pages/report-viewer/report-viewer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Clipboard} from '@angular/cdk/clipboard';
22
import {DatePipe, DecimalPipe} from '@angular/common';
3+
import {HttpClient} from '@angular/common/http';
34
import {
45
afterNextRender,
56
Component,
@@ -17,6 +18,7 @@ import {
1718
BuildResultStatus,
1819
} from '../../../../../runner/workers/builder/builder-types';
1920
import {
21+
AiChatResponse,
2022
AssessmentResult,
2123
IndividualAssessment,
2224
IndividualAssessmentState,
@@ -42,6 +44,8 @@ import {bucketToScoreVariable, formatScore, ScoreCssVariable} from '../../shared
4244
import {ExpansionPanel} from '../../shared/expansion-panel/expansion-panel';
4345
import {ExpansionPanelHeader} from '../../shared/expansion-panel/expansion-panel-header';
4446
import {ProviderLabel} from '../../shared/provider-label';
47+
import {firstValueFrom} from 'rxjs';
48+
import {AiAssistant} from '../../shared/ai-assistant/ai-assistant';
4549

4650
const localReportRegex = /-l\d+$/;
4751

@@ -58,6 +62,7 @@ const localReportRegex = /-l\d+$/;
5862
ExpansionPanelHeader,
5963
ProviderLabel,
6064
NgxJsonViewerModule,
65+
AiAssistant,
6166
],
6267
templateUrl: './report-viewer.html',
6368
styleUrls: ['./report-viewer.scss'],
@@ -68,6 +73,7 @@ const localReportRegex = /-l\d+$/;
6873
export class ReportViewer {
6974
private clipboard = inject(Clipboard);
7075
private reportsFetcher = inject(ReportsFetcher);
76+
private http = inject(HttpClient);
7177

7278
constructor() {
7379
// Scroll the page to the top since it seems to always land slightly scrolled down.
@@ -79,6 +85,7 @@ export class ReportViewer {
7985
protected formatted = signal<Map<LlmResponseFile, string>>(new Map());
8086
protected formatScore = formatScore;
8187
protected error = computed(() => this.selectedReport.error());
88+
protected isAiAssistantVisible = signal(false);
8289

8390
private selectedReport = resource({
8491
params: () => ({groupId: this.reportGroupId()}),

0 commit comments

Comments
 (0)