Skip to content

Commit c9b390d

Browse files
committed
feat: Ask v1
1 parent f196dd0 commit c9b390d

File tree

5 files changed

+319
-3
lines changed

5 files changed

+319
-3
lines changed

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const CompareCommand = require('./cmds/compare/compare');
3636
const CompareReportCommand = require('./cmds/compare-report/compareReport');
3737
const InventoryCommand = require('./cmds/inventory/inventory');
3838
const InventoryReportCommand = require('./cmds/inventory-report/inventoryReport');
39+
const Ask = require('./cmds/ask/ask');
3940
import UploadCommand from './cmds/upload';
4041
import { default as sqlErrorLog } from './lib/sqlErrorLog';
4142

@@ -192,6 +193,7 @@ yargs(process.argv.slice(2))
192193
.command(CompareReportCommand)
193194
.command(InventoryCommand)
194195
.command(InventoryReportCommand)
196+
.command(Ask)
195197
.option('verbose', {
196198
alias: 'v',
197199
type: 'boolean',

packages/cli/src/cmds/ask/ask.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { warn } from 'console';
2+
import { handleWorkingDirectory } from '../../lib/handleWorkingDirectory';
3+
import { locateAppMapDir } from '../../lib/locateAppMapDir';
4+
import { exists, verbose } from '../../utils';
5+
import OpenAI from 'openai';
6+
import { ChatCompletionMessageParam } from 'openai/resources';
7+
import Context from '../../inspect/context';
8+
import { EventMatch } from '../../search/types';
9+
import { readFile, writeFile } from 'fs/promises';
10+
import { join } from 'path';
11+
import { AppMapFilter, Metadata, buildAppMap } from '@appland/models';
12+
import { loadSequenceDiagram } from '../compare/loadSequenceDiagram';
13+
import {
14+
Action,
15+
Diagram,
16+
FormatType,
17+
Specification,
18+
buildDiagram,
19+
format,
20+
nodeName,
21+
} from '@appland/sequence-diagram';
22+
import { match } from 'assert';
23+
24+
export const command = 'ask <question>';
25+
export const describe =
26+
'Ask a plain text question and get a filtered and configured AppMap as a response';
27+
28+
export const builder = (args) => {
29+
args.positional('question', {
30+
describe: 'plain text question about the code base',
31+
});
32+
args.option('directory', {
33+
describe: 'program working directory',
34+
type: 'string',
35+
alias: 'd',
36+
});
37+
return args.strict();
38+
};
39+
40+
function buildOpenAI(): OpenAI {
41+
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
42+
if (!OPENAI_API_KEY) {
43+
throw new Error('OPENAI_API_KEY environment variable must be set');
44+
}
45+
return new OpenAI({ apiKey: OPENAI_API_KEY });
46+
}
47+
48+
type SearchDiagramsParam = {
49+
keywords: string[];
50+
};
51+
52+
type DiagramEvent = {
53+
name: string;
54+
id: number;
55+
};
56+
57+
type SearchDiagramResult = {
58+
diagramId: string;
59+
};
60+
61+
type DiagramDetailsParam = {
62+
diagramIds: string[];
63+
};
64+
65+
type DiagramDetailsResult = {
66+
metadata: Metadata;
67+
summary: string;
68+
};
69+
70+
class Ask {
71+
constructor(public appmapDir: string) {}
72+
73+
async searchDiagrams(keywords: string[]): Promise<SearchDiagramResult[] | undefined> {
74+
const regexpOfGlob = (glob: string) =>
75+
glob.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
76+
77+
// TODO: Use proper keyword search
78+
const searchTerms = keywords.map((keyword) => keyword.split(' ')).flat();
79+
const matchesForKeyword = new Array<Set<string>>();
80+
for (const keyword of searchTerms) {
81+
matchesForKeyword.push(new Set<string>());
82+
const queryStr = `function:%r{.*${regexpOfGlob(keyword)}.*}`;
83+
const context = new Context(this.appmapDir, queryStr);
84+
await context.findCodeObjects();
85+
if (!context.codeObjectMatches) continue;
86+
87+
const stats = await context.buildStats();
88+
/*
89+
const eventName = (eventMatch: EventMatch): string => {
90+
if (eventMatch.event.sqlQuery) return eventMatch.event.sqlQuery;
91+
else if (eventMatch.event.httpServerRequest)
92+
return eventMatch.event.httpServerRequest.normalized_path;
93+
94+
return [eventMatch.event.definedClass, eventMatch.event.methodId].join('#');
95+
};
96+
*/
97+
98+
stats.eventMatches.forEach((eventMatch) => {
99+
const diagramId = eventMatch.appmap;
100+
matchesForKeyword[matchesForKeyword.length - 1].add(diagramId);
101+
/*
102+
const event: DiagramEvent = {
103+
name: eventName(eventMatch),
104+
id: eventMatch.event.id,
105+
};
106+
const eventMatches = eventMatchesByDiagramId.get(diagramId);
107+
if (eventMatches) {
108+
eventMatches.push(event);
109+
} else {
110+
eventMatchesByDiagramId.set(diagramId, [event]);
111+
}
112+
*/
113+
});
114+
}
115+
116+
// Collect all diagrams that match all keywords
117+
const matchingDiagrams = new Set<string>();
118+
for (const diagramId of matchesForKeyword[0]) {
119+
let isMatch = true;
120+
for (const matches of matchesForKeyword) {
121+
if (!matches.has(diagramId)) {
122+
isMatch = false;
123+
break;
124+
}
125+
}
126+
if (isMatch) matchingDiagrams.add(diagramId);
127+
}
128+
129+
return Array.from(matchingDiagrams).map((diagramId) => ({ diagramId }));
130+
// return Array.from(eventMatchesByDiagramId.entries()).map(([diagramId, matchingEvents]) => ({
131+
// diagramId,
132+
// matchingEvents,
133+
// }));
134+
}
135+
}
136+
137+
export const handler = async (argv: any) => {
138+
verbose(argv.verbose);
139+
handleWorkingDirectory(argv.directory);
140+
const appmapDir = await locateAppMapDir(argv.appmapDir);
141+
142+
const ask = new Ask(appmapDir);
143+
144+
function showPlan(paramStr: string) {
145+
const params = JSON.parse(paramStr) as { plan: string };
146+
warn(`Plan:\n${params.plan}`);
147+
}
148+
149+
async function searchDiagrams(paramStr: string): Promise<SearchDiagramResult[]> {
150+
const params = JSON.parse(paramStr) as SearchDiagramsParam;
151+
let { keywords } = params;
152+
warn(`Searching for diagrams matching "${keywords}"`);
153+
const eventMatches = await ask.searchDiagrams(keywords);
154+
if (!eventMatches) return [];
155+
156+
return eventMatches;
157+
}
158+
159+
async function getDiagramDetails(paramStr: string): Promise<DiagramDetailsResult[]> {
160+
const params = JSON.parse(paramStr) as DiagramDetailsParam;
161+
const { diagramIds } = params;
162+
warn(`Getting details for diagram ${diagramIds}`);
163+
const result = new Array<DiagramDetailsResult>();
164+
for (const diagramId of diagramIds) {
165+
const metadata = JSON.parse(
166+
await readFile(join(diagramId, 'metadata.json'), 'utf-8')
167+
) as Metadata;
168+
delete metadata['git'];
169+
170+
warn(`Building sequence diagram for AppMap ${diagramId}`);
171+
const appmapFile = [diagramId, 'appmap.json'].join('.');
172+
const prunedAppMap = buildAppMap()
173+
.source(await readFile(appmapFile, 'utf-8'))
174+
.prune(10 * 1000 * 1000)
175+
.build();
176+
const filter = new AppMapFilter();
177+
if (metadata.language?.name !== 'java') filter.declutter.hideExternalPaths.on = true;
178+
179+
filter.declutter.limitRootEvents.on = true;
180+
const filteredAppMap = filter.filter(prunedAppMap, []);
181+
const specification = Specification.build(filteredAppMap, { loops: true });
182+
const diagram = buildDiagram(appmapFile, filteredAppMap, specification);
183+
184+
const diagramText: string[] = [];
185+
const maxDepth = 2;
186+
const collectAction = (action: Action, depth = 0) => {
187+
if (depth > maxDepth) return;
188+
189+
const indent = ' '.repeat(depth);
190+
diagramText.push(`${indent}${nodeName(action)}(events:${action.eventIds.join(', ')}})`);
191+
if (action.children) {
192+
action.children.forEach((child) => collectAction(child, depth + 1));
193+
}
194+
};
195+
diagram.rootActions.forEach((action) => collectAction(action));
196+
result.push({ metadata, summary: diagramText.join('\n') });
197+
}
198+
return result;
199+
}
200+
201+
const question = argv.question;
202+
203+
const systemMessages: ChatCompletionMessageParam[] = [
204+
'You are an assistant that answers questions about the design and architecture of code.',
205+
'You answer these questions by accessing a knowledge base of sequence diagrams.',
206+
'Each sequence diagram conists of a series of events, such as function calls, HTTP server requests, SQL queries, etc.',
207+
'You are given a question about the code, and you have to (a) Locate the sequence diagram that best answers the question (b) Focus the sequence diagram on the sub-section of the diagram that best answers the question.',
208+
'You use the function calling interface to choose the best diagram and configure the diagram view to reveal the answer.',
209+
'Always try multiple keywords, and collate the results together',
210+
'If a diagram keyword search returns no diagrams, try other keywords.',
211+
'The first function you should call is the "showPlan" function, to which you will provide a markdown document describing your general strategy for answering the question.',
212+
'In your response, include links to the diagram files, with anchor tags to events that are particular interest',
213+
'In your response, include links to source code files, with anchor tags to the lines of code that are of particular interest',
214+
].map((msg) => ({
215+
content: msg,
216+
role: 'system',
217+
}));
218+
219+
const userMessage: ChatCompletionMessageParam = {
220+
content: question,
221+
role: 'user',
222+
};
223+
224+
const messages = [...systemMessages, userMessage];
225+
226+
const openai = buildOpenAI();
227+
const runFunctions = openai.beta.chat.completions.runFunctions({
228+
model: 'gpt-4',
229+
messages,
230+
function_call: 'auto',
231+
functions: [
232+
{
233+
function: showPlan,
234+
description: 'Print the plan for answering the question',
235+
parameters: {
236+
type: 'object',
237+
properties: {
238+
plan: {
239+
type: 'string',
240+
description: 'The plan in Markdown format',
241+
},
242+
},
243+
},
244+
},
245+
{
246+
function: searchDiagrams,
247+
description: `List sequence diagrams that match a keyword. The response is an array of search matches. Each match includes a diagram id, plus information about the events (function calls, HTTP server requests, SQL queries, etc) within that diagram that match the search term.`,
248+
parameters: {
249+
type: 'object',
250+
properties: {
251+
keywords: {
252+
type: 'array',
253+
description: 'Array of keywords to find in the diagrams',
254+
items: {
255+
type: 'string',
256+
},
257+
},
258+
},
259+
},
260+
},
261+
{
262+
function: getDiagramDetails,
263+
description: `Get details about diagrams, including their name, code language, frameworks, source location, exceptions raised.`,
264+
parameters: {
265+
type: 'object',
266+
properties: {
267+
diagramIds: {
268+
type: 'array',
269+
description: 'Array of diagram ids',
270+
items: {
271+
type: 'string',
272+
},
273+
},
274+
},
275+
},
276+
},
277+
],
278+
});
279+
280+
runFunctions.on('functionCall', (data) => {
281+
warn(JSON.stringify(data));
282+
});
283+
runFunctions.on('finalFunctionCall', (data) => {
284+
warn(JSON.stringify(data));
285+
});
286+
runFunctions.on('functionCallResult', (data) => {
287+
warn(JSON.stringify(data));
288+
});
289+
runFunctions.on('finalFunctionCallResult', (data) => {
290+
warn(JSON.stringify(data));
291+
});
292+
293+
const response = await runFunctions.finalContent();
294+
if (!response) {
295+
warn(`No response from OpenAI`);
296+
return;
297+
}
298+
console.log(response);
299+
300+
// const context = new Context(appmapDir, codeObjectId);
301+
// await context.findCodeObjects();
302+
// if (context.codeObjectMatches?.length === 0) {
303+
// return yargs.exit(1, new Error(`Code object '${context.codeObjectId}' not found`));
304+
// }
305+
// await context.buildStats();
306+
// console.log(JSON.stringify(context.stats, null, 2));
307+
};

packages/cli/src/functionStats.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class FunctionStats {
4141
const trigram = (/** @type {Trigram} */ t) =>
4242
[t.callerId, t.codeObjectId, t.calleeId].join(' ->\n');
4343
return {
44+
appmaps: this.appMapNames,
4445
returnValues: this.returnValues,
4546
httpServerRequests: this.httpServerRequests,
4647
sqlQueries: this.sqlQueries,
@@ -58,6 +59,10 @@ class FunctionStats {
5859
return [...new Set(this.eventMatches.map((e) => e.appmap))].sort();
5960
}
6061

62+
get appmaps() {
63+
return this.appMapNames;
64+
}
65+
6166
get returnValues() {
6267
return [...new Set(this.eventMatches.map((e) => e.event.returnValue).map(formatValue))].sort();
6368
}

packages/cli/src/inspect/context.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import EventEmitter from 'events';
33
import { default as FunctionStatsImpl } from '../functionStats';
44
import FindCodeObjects from '../search/findCodeObjects';
55
import FindEvents from '../search/findEvents';
6-
import { FunctionStats, Filter, CodeObjectMatch } from '../search/types';
6+
import { FunctionStats, Filter, CodeObjectMatch, EventMatch } from '../search/types';
77

88
export default class Context extends EventEmitter {
99
public filters: Filter[] = [];
@@ -38,12 +38,12 @@ export default class Context extends EventEmitter {
3838
this.emit('stop');
3939
}
4040

41-
async buildStats() {
41+
async buildStats(): Promise<FunctionStats> {
4242
assert(this.codeObjectMatches, `codeObjectMatches is not yet computed`);
4343

4444
this.emit('start', this.codeObjectMatches.length);
4545

46-
const result: any[] = [];
46+
const result: EventMatch[] = [];
4747
await Promise.all(
4848
this.codeObjectMatches.map(async (codeObjectMatch) => {
4949
const findEvents = new FindEvents(codeObjectMatch.appmap, codeObjectMatch.codeObject);
@@ -57,6 +57,7 @@ export default class Context extends EventEmitter {
5757

5858
this.emit('collate');
5959
this.stats = new FunctionStatsImpl(result);
60+
return this.stats;
6061
}
6162

6263
save() {

packages/cli/src/search/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export interface Filter {
9595
}
9696

9797
export interface FunctionStats {
98+
appmaps: string[];
9899
eventMatches: EventMatch[];
99100
returnValues: string[];
100101
httpServerRequests: string[];

0 commit comments

Comments
 (0)