|
21 | 21 | INFERENCE_MODEL = "mistralai/Mistral-Small-3.2-24B-Instruct-2506" |
22 | 22 | INFERENCE_RESPONSE_PER_MINUTE_LIMIT = 5 |
23 | 23 | INFERENCE_API_KEY = os.getenv("NEBULA_API_KEY") |
| 24 | +INFERENCE_MAX_CHARACTERS = 100000 # max characters in all files provided to the model, approximately 25k tokens |
24 | 25 |
|
25 | 26 | QUESTIONS = [ |
26 | | - "Is there a EXTENSION_DESCRIPTION variable in the CMakeLists.txt file that describes what the extension does in a few sentences that can be understood by a person knowledgeable in medical image computing?", |
27 | | - "Does the README.md file contain a short description, 1-2 sentences, which summarizes what the extension is usable for?", |
28 | | - "Does the README.md file contain at least one image that illustrates what the extension can do, preferably a screenshot? Ignore contents of CMakeLists.txt file.", |
29 | | - "Does the README.md file contain description of contained modules: at one sentence for each module?", |
30 | | - "Does the README.md file contain publication: link to publication and/or to PubMed reference or a 'How to cite' section?", |
31 | | - "Does the documentation contain step-by-step tutorial? Does the tutorial tell where to get sample data from?" |
32 | | - "Does this code download any executable code from the internet or uploads any data to the internet?", |
33 | | - "Is any code executed at the file scope when a module is imported?", |
34 | | - "Are any Python packages imported at the file scope that are not from the Python Standard Library and not from Slicer, vtk, SimpleITK, numpy, and scipy?", |
35 | | - "Does it directly use pip_install to install pytorch?", |
36 | | - "Does it store large amount of downloaded content on local disk other than installing Python packages? Does it provide a way for the user to remove that content?", |
| 27 | + ["Is there a EXTENSION_DESCRIPTION variable in the CMakeLists.txt file that describes what the extension does in a few sentences that can be understood by a person knowledgeable in medical image computing?", ["cmake"]], |
| 28 | + ["Does the README.md file contain a short description, 1-2 sentences, which summarizes what the extension is usable for?", ["doc"]], |
| 29 | + ["Does the README.md file contain at least one image that illustrates what the extension can do, preferably a screenshot?", ["doc"]], |
| 30 | + ["Does the README.md file contain description of contained modules: at one sentence for each module?", ["doc"]], |
| 31 | + ["Does the README.md file contain publication: link to publication and/or to PubMed reference or a 'How to cite' section?", ["doc"]], |
| 32 | + ["Does the documentation contain step-by-step tutorial? Does the tutorial tell where to get sample data from?", ["doc"]], |
| 33 | + ["Does this code download any executable code from the internet or uploads any data to the internet?", ["source"]], |
| 34 | + ["Is any code executed at the file scope when a module is imported?", ["source"]], |
| 35 | + ["Are any Python packages imported at the file scope that are not from the Python Standard Library and not from Slicer, vtk, SimpleITK, numpy, and scipy?", ["source"]], |
| 36 | + ["Does it directly use pip_install to install pytorch?", ["source"]], |
| 37 | + ["Does it store large amount of downloaded content on local disk other than installing Python packages? Does it provide a way for the user to remove that content?", ["source"]], |
37 | 38 | ] |
38 | 39 |
|
39 | 40 | def parse_json(extension_file_path): |
@@ -105,72 +106,126 @@ def clone_repository(metadata, cloned_repository_folder): |
105 | 106 |
|
106 | 107 |
|
107 | 108 | def collect_analyzed_files(folder): |
108 | | - """Load all .py files in a folder, recursively.""" |
109 | | - scripts = {} |
| 109 | + """Load all .py files in a folder, recursively. |
| 110 | + returns a dict of categories (doc, source, cmake), each containing a dict of filename->content""" |
| 111 | + found_files = { "doc": {}, "source": {}, "cmake": {} } |
110 | 112 | for root, dirs, files in os.walk(folder): |
111 | 113 | for filename in files: |
112 | 114 | fullpath = os.path.join(root, filename) |
113 | 115 | relative_path = os.path.relpath(fullpath, start=folder).replace("\\", "/") |
114 | | - if filename.endswith(".py") or filename.endswith(".md") or relative_path == "CMakeLists.txt": |
115 | | - with open(fullpath, "r", encoding="utf-8") as f: |
116 | | - # get relative path to folder, in linux-style |
117 | | - scripts[relative_path] = f.read() |
118 | | - return scripts |
119 | | - |
120 | | - |
121 | | -def analyze_extension(extension_name, metadata, cloned_repository_folder): |
| 116 | + category = None |
| 117 | + if filename.endswith(".py"): |
| 118 | + category = "source" |
| 119 | + elif filename.endswith(".md"): |
| 120 | + category = "doc" |
| 121 | + elif relative_path == "CMakeLists.txt": |
| 122 | + category = "cmake" |
| 123 | + if category is None: |
| 124 | + continue |
| 125 | + with open(fullpath, "r", encoding="utf-8") as f: |
| 126 | + # get relative path to folder, in linux-style |
| 127 | + found_files[category][relative_path] = f.read() |
| 128 | + return found_files |
122 | 129 |
|
| 130 | +def ask_question(system_msg, question): |
123 | 131 | headers = { |
124 | 132 | "Content-Type": "application/json", |
125 | 133 | "Authorization": f"Bearer {INFERENCE_API_KEY}" |
126 | 134 | } |
127 | 135 |
|
128 | | - scripts = collect_analyzed_files(cloned_repository_folder) |
129 | | - |
130 | | - system_msg = \ |
131 | | - "You are a quality control expert that checks community-contributed files that contain code and documentation." \ |
132 | | - " Do not talk about things in general, only strictly about the content provided." \ |
133 | | - " Relevant files of the extension repository are provided below." \ |
134 | | - " Each file is delimited by lines with '=== FILE: filename ===' and '=== END FILE: filename ==='." |
135 | | - for filename in scripts: |
136 | | - system_msg += f"\n=== FILE: {filename} ===\n" |
137 | | - system_msg += scripts[filename] |
138 | | - system_msg += f"\n=== END FILE: {filename} ===\n" |
139 | | - |
140 | | - # Send the system prompt only once, then continue the conversation |
141 | 136 | messages = [ |
142 | | - {"role": "system", "content": system_msg} |
| 137 | + {"role": "system", "content": system_msg}, |
| 138 | + {"role": "user", "content": question} |
143 | 139 | ] |
144 | 140 |
|
145 | | - for index, question in enumerate(QUESTIONS): |
146 | | - messages.append({"role": "user", "content": question}) |
147 | | - data = { |
148 | | - "messages": messages, |
149 | | - "model": INFERENCE_MODEL, |
150 | | - "max_tokens": None, |
151 | | - "temperature": 1, |
152 | | - "top_p": 0.9, |
153 | | - "stream": False |
154 | | - } |
155 | | - response = requests.post(INFERENCE_URL, headers=headers, json=data) |
| 141 | + data = { |
| 142 | + "messages": messages, |
| 143 | + "model": INFERENCE_MODEL, |
| 144 | + "max_tokens": None, |
| 145 | + "temperature": 1, |
| 146 | + "top_p": 0.9, |
| 147 | + "stream": False |
| 148 | + } |
| 149 | + |
| 150 | + response = requests.post(INFERENCE_URL, headers=headers, json=data) |
| 151 | + |
| 152 | + # wait according to response per minute limit |
| 153 | + delay = 60 / INFERENCE_RESPONSE_PER_MINUTE_LIMIT |
| 154 | + import time |
| 155 | + time.sleep(delay) |
| 156 | + |
| 157 | + try: |
| 158 | + answer = response.json()["choices"][0]["message"]["content"] |
| 159 | + except Exception as e: |
| 160 | + raise RuntimeError(f"Error or unexpected response: {response.json()["error"]["message"]}") |
| 161 | + |
| 162 | + return answer |
| 163 | + |
| 164 | + |
| 165 | +def analyze_extension(extension_name, metadata, cloned_repository_folder): |
| 166 | + |
| 167 | + files = collect_analyzed_files(cloned_repository_folder) |
| 168 | + |
| 169 | + for index, [question, categories] in enumerate(QUESTIONS): |
| 170 | + |
156 | 171 | print("\n------------------------------------------------------") |
157 | 172 | print(f"Question {index+1}: {question}") |
158 | 173 | print("------------------------------------------------------") |
159 | | - try: |
160 | | - answer = response.json()["choices"][0]["message"]["content"] |
161 | 174 |
|
162 | | - print(answer) |
163 | | - messages.append({"role": "assistant", "content": answer}) |
164 | | - except Exception as e: |
165 | | - print("Error or unexpected response:", response.json()["error"]["message"]) |
166 | | - if index == 0: |
167 | | - # if the first question fails, likely the system prompt is too long, so stop here |
168 | | - raise RuntimeError("Stopping further questions since the first question failed.") |
| 175 | + file_content_batches = [""] |
| 176 | + |
| 177 | + # Add files of the categories relevant for the question |
| 178 | + # The context of each query is limited, therefore if there are too many/too large input files in the relevant categories, |
| 179 | + # then we split them into batches, ask the question for each batch, and then generate a summary of the answers. |
| 180 | + for category in categories: |
| 181 | + files_in_category = files.get(category, {}) |
| 182 | + for filename in files_in_category: |
| 183 | + next_file = f"\n=== FILE: {filename} ===\n" + files_in_category[filename] + f"\n=== END FILE: {filename} ===\n" |
| 184 | + if len(file_content_batches[-1]) + len(next_file) < INFERENCE_MAX_CHARACTERS: |
| 185 | + # We can add this file to the current batch |
| 186 | + file_content_batches[-1] += next_file |
| 187 | + else: |
| 188 | + # Start a new batch |
| 189 | + file_content_batches.append(next_file) |
| 190 | + |
| 191 | + if not file_content_batches[0].strip(): |
| 192 | + print("No relevant files found for this question.") |
| 193 | + continue |
169 | 194 |
|
170 | | - # wait according to response per minute limit |
171 | | - delay = 60 / INFERENCE_RESPONSE_PER_MINUTE_LIMIT |
172 | | - import time |
173 | | - time.sleep(delay) |
| 195 | + role_description = \ |
| 196 | + "You are a quality control expert that checks community-contributed files that contain code and documentation." \ |
| 197 | + " Do not talk about things in general, only strictly about the content provided." |
| 198 | + |
| 199 | + answers = [] |
| 200 | + |
| 201 | + for batch_index, file_content in enumerate(file_content_batches): |
| 202 | + |
| 203 | + system_msg = role_description |
| 204 | + system_msg += " Relevant files of the extension repository are provided below." |
| 205 | + system_msg += " Each file is delimited by lines with '=== FILE: filename ===' and '=== END FILE: filename ==='.\n" |
| 206 | + system_msg += file_content |
| 207 | + |
| 208 | + try: |
| 209 | + answer = ask_question(system_msg, question) |
| 210 | + answers.append(answer) |
| 211 | + except Exception as e: |
| 212 | + answers = [f"Error or unexpected response: {e}"] |
| 213 | + break |
| 214 | + |
| 215 | + if len(answers) == 1: |
| 216 | + print(answers[0]) |
| 217 | + else: |
| 218 | + # Multiple batches of files were used to answer this question, generate a summary |
| 219 | + system_msg = role_description |
| 220 | + question = "The answer to the question is spread over multiple parts. Please summarize the answer in a concise way, combining all relevant information from the different parts. " \ |
| 221 | + "Here are the different parts of the answer:\n\n" |
| 222 | + for part_index, part in enumerate(answers): |
| 223 | + question += f"--- PART {part_index+1} ---\n{part}\n" |
| 224 | + try: |
| 225 | + answer = ask_question(system_msg, question) |
| 226 | + except Exception as e: |
| 227 | + answer = f"Error or unexpected response: {e}" |
| 228 | + print(answer) |
174 | 229 |
|
175 | 230 |
|
176 | 231 | def main(): |
@@ -211,7 +266,7 @@ def main(): |
211 | 266 | # Clean up temporary directory |
212 | 267 | success_cleanup = safe_cleanup_directory(cloned_repository_folder) |
213 | 268 |
|
214 | | - print("=====================================================") |
| 269 | + print("\n=====================================================\n") |
215 | 270 |
|
216 | 271 |
|
217 | 272 | if __name__ == "__main__": |
|
0 commit comments