|
| 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 | +}; |
0 commit comments