logdetective 1.0.1__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logdetective/prompts-summary-first.yml +22 -0
- logdetective/prompts-summary-only.yml +13 -0
- logdetective/server/emoji.py +33 -5
- logdetective/server/gitlab.py +45 -39
- logdetective/server/llm.py +39 -93
- logdetective/server/models.py +109 -12
- logdetective/server/server.py +37 -30
- {logdetective-1.0.1.dist-info → logdetective-1.1.0.dist-info}/METADATA +5 -1
- {logdetective-1.0.1.dist-info → logdetective-1.1.0.dist-info}/RECORD +12 -10
- {logdetective-1.0.1.dist-info → logdetective-1.1.0.dist-info}/LICENSE +0 -0
- {logdetective-1.0.1.dist-info → logdetective-1.1.0.dist-info}/WHEEL +0 -0
- {logdetective-1.0.1.dist-info → logdetective-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# This file is intended for customization of prompts
|
|
2
|
+
# It is used only in server mode.
|
|
3
|
+
# On command line you have to load it using --prompts
|
|
4
|
+
# The defaults are stored in constants.py
|
|
5
|
+
|
|
6
|
+
prompt_template: |
|
|
7
|
+
Given following log snippets, and nothing else, explain what failure, if any, occured during build of this package.
|
|
8
|
+
|
|
9
|
+
Please start with concise, one sentence long, summary describing the problem and recommend solution to fix it. And then follow with analysis.
|
|
10
|
+
|
|
11
|
+
Analysis of the snippets must be in a format of [X] : [Y], where [X] is a log snippet, and [Y] is the explanation.
|
|
12
|
+
Snippets themselves must not be altered in any way whatsoever.
|
|
13
|
+
|
|
14
|
+
Snippets are delimited with '================'.
|
|
15
|
+
|
|
16
|
+
Explanation of the issue, and recommended solution, should take handful of sentences.
|
|
17
|
+
|
|
18
|
+
Snippets:
|
|
19
|
+
|
|
20
|
+
{}
|
|
21
|
+
|
|
22
|
+
Analysis:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# This file is intended for customization of prompts
|
|
2
|
+
# It is used only in server mode.
|
|
3
|
+
# On command line you have to load it using --prompts
|
|
4
|
+
# The defaults are stored in constants.py
|
|
5
|
+
|
|
6
|
+
prompt_template: |
|
|
7
|
+
Given following log snippets, and nothing else, explain what failure, if any, occured during build of this package.
|
|
8
|
+
|
|
9
|
+
Provide concise, one paragraph long, summary describing the problem of most probable culprit and recommend solution to fix it.
|
|
10
|
+
|
|
11
|
+
Snippets:
|
|
12
|
+
|
|
13
|
+
{}
|
logdetective/server/emoji.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
-
from typing import List
|
|
3
|
+
from typing import List, Callable
|
|
4
4
|
from collections import Counter
|
|
5
5
|
|
|
6
6
|
import gitlab
|
|
@@ -11,6 +11,7 @@ from logdetective.server.database.models import (
|
|
|
11
11
|
Reactions,
|
|
12
12
|
GitlabMergeRequestJobs,
|
|
13
13
|
)
|
|
14
|
+
from logdetective.server.config import LOG
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
async def collect_emojis(gitlab_conn: gitlab.Gitlab, period: TimePeriod):
|
|
@@ -36,6 +37,23 @@ async def collect_emojis_for_mr(
|
|
|
36
37
|
await collect_emojis_in_comments(comments, gitlab_conn)
|
|
37
38
|
|
|
38
39
|
|
|
40
|
+
async def _handle_gitlab_operation(func: Callable, *args):
|
|
41
|
+
"""
|
|
42
|
+
It handles errors for the specified GitLab operation.
|
|
43
|
+
After executing it in a separate thread.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
return await asyncio.to_thread(func, *args)
|
|
47
|
+
except gitlab.GitlabError as e:
|
|
48
|
+
log_msg = f"Error during GitLab operation {func}{args}: {e}"
|
|
49
|
+
if "Not Found" in str(e):
|
|
50
|
+
LOG.error(log_msg)
|
|
51
|
+
else:
|
|
52
|
+
LOG.exception(log_msg)
|
|
53
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
54
|
+
LOG.exception("Unexpected error during GitLab operation %s(%s): %s", func, args, e)
|
|
55
|
+
|
|
56
|
+
|
|
39
57
|
async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
40
58
|
comments: List[Comments], gitlab_conn: gitlab.Gitlab
|
|
41
59
|
):
|
|
@@ -47,24 +65,34 @@ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
|
|
|
47
65
|
for comment in comments:
|
|
48
66
|
mr_job_db = GitlabMergeRequestJobs.get_by_id(comment.merge_request_job_id)
|
|
49
67
|
if mr_job_db.id not in projects:
|
|
50
|
-
projects[mr_job_db.id] = project = await
|
|
68
|
+
projects[mr_job_db.id] = project = await _handle_gitlab_operation(
|
|
51
69
|
gitlab_conn.projects.get, mr_job_db.project_id
|
|
52
70
|
)
|
|
71
|
+
if not project:
|
|
72
|
+
continue
|
|
53
73
|
else:
|
|
54
74
|
project = projects[mr_job_db.id]
|
|
55
75
|
mr_iid = mr_job_db.mr_iid
|
|
56
76
|
if mr_iid not in mrs:
|
|
57
|
-
mrs[mr_iid] = mr = await
|
|
77
|
+
mrs[mr_iid] = mr = await _handle_gitlab_operation(
|
|
58
78
|
project.mergerequests.get, mr_iid
|
|
59
79
|
)
|
|
80
|
+
if not mr:
|
|
81
|
+
continue
|
|
60
82
|
else:
|
|
61
83
|
mr = mrs[mr_iid]
|
|
62
84
|
|
|
63
|
-
discussion =
|
|
85
|
+
discussion = await _handle_gitlab_operation(
|
|
86
|
+
mr.discussions.get, comment.comment_id
|
|
87
|
+
)
|
|
88
|
+
if not discussion:
|
|
89
|
+
continue
|
|
64
90
|
|
|
65
91
|
# Get the ID of the first note
|
|
66
92
|
note_id = discussion.attributes["notes"][0]["id"]
|
|
67
|
-
note = mr.notes.get
|
|
93
|
+
note = await _handle_gitlab_operation(mr.notes.get, note_id)
|
|
94
|
+
if not note:
|
|
95
|
+
continue
|
|
68
96
|
|
|
69
97
|
emoji_counts = Counter(emoji.name for emoji in note.awardemojis.list())
|
|
70
98
|
|
logdetective/server/gitlab.py
CHANGED
|
@@ -11,7 +11,6 @@ import gitlab.v4
|
|
|
11
11
|
import gitlab.v4.objects
|
|
12
12
|
import jinja2
|
|
13
13
|
import aiohttp
|
|
14
|
-
import sqlalchemy
|
|
15
14
|
|
|
16
15
|
from logdetective.server.config import SERVER_CONFIG, LOG
|
|
17
16
|
from logdetective.server.llm import perform_staged_analysis
|
|
@@ -22,10 +21,11 @@ from logdetective.server.models import (
|
|
|
22
21
|
StagedResponse,
|
|
23
22
|
)
|
|
24
23
|
from logdetective.server.database.models import (
|
|
24
|
+
AnalyzeRequestMetrics,
|
|
25
25
|
Comments,
|
|
26
26
|
EndpointType,
|
|
27
27
|
Forge,
|
|
28
|
-
|
|
28
|
+
GitlabMergeRequestJobs,
|
|
29
29
|
)
|
|
30
30
|
from logdetective.server.compressors import RemoteLogCompressor
|
|
31
31
|
|
|
@@ -34,7 +34,6 @@ FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
async def process_gitlab_job_event(
|
|
37
|
-
http: aiohttp.ClientSession,
|
|
38
37
|
gitlab_cfg: GitLabInstanceConfig,
|
|
39
38
|
forge: Forge,
|
|
40
39
|
job_hook: JobHook,
|
|
@@ -63,19 +62,34 @@ async def process_gitlab_job_event(
|
|
|
63
62
|
LOG.info("Not a merge request pipeline. Ignoring.")
|
|
64
63
|
return
|
|
65
64
|
|
|
66
|
-
# Extract the merge-request
|
|
65
|
+
# Extract the merge-request IID from the job
|
|
67
66
|
match = MR_REGEX.search(pipeline.ref)
|
|
68
67
|
if not match:
|
|
69
68
|
LOG.error(
|
|
70
|
-
"Pipeline source is merge_request_event but no merge request
|
|
69
|
+
"Pipeline source is merge_request_event but no merge request IID was provided."
|
|
71
70
|
)
|
|
72
71
|
return
|
|
73
72
|
merge_request_iid = int(match.group(1))
|
|
74
73
|
|
|
74
|
+
# Check if this is a resubmission of an existing, completed job.
|
|
75
|
+
# If it is, we'll exit out here and not waste time retrieving the logs,
|
|
76
|
+
# running a new analysis or trying to submit a new comment.
|
|
77
|
+
mr_job_db = GitlabMergeRequestJobs.get_by_details(
|
|
78
|
+
forge=forge,
|
|
79
|
+
project_id=project.id,
|
|
80
|
+
mr_iid=merge_request_iid,
|
|
81
|
+
job_id=job_hook.build_id,
|
|
82
|
+
)
|
|
83
|
+
if mr_job_db:
|
|
84
|
+
LOG.info("Resubmission of an existing build. Skipping.")
|
|
85
|
+
return
|
|
86
|
+
|
|
75
87
|
LOG.debug("Retrieving log artifacts")
|
|
76
88
|
# Retrieve the build logs from the merge request artifacts and preprocess them
|
|
77
89
|
try:
|
|
78
|
-
log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(
|
|
90
|
+
log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(
|
|
91
|
+
gitlab_cfg, job
|
|
92
|
+
)
|
|
79
93
|
except LogsTooLargeError:
|
|
80
94
|
LOG.error("Could not retrieve logs. Too large.")
|
|
81
95
|
raise
|
|
@@ -85,10 +99,10 @@ async def process_gitlab_job_event(
|
|
|
85
99
|
metrics_id = await add_new_metrics(
|
|
86
100
|
api_name=EndpointType.ANALYZE_GITLAB_JOB,
|
|
87
101
|
url=log_url,
|
|
88
|
-
http_session=
|
|
102
|
+
http_session=gitlab_cfg.get_http_session(),
|
|
89
103
|
compressed_log_content=RemoteLogCompressor.zip_text(log_text),
|
|
90
104
|
)
|
|
91
|
-
staged_response = await perform_staged_analysis(
|
|
105
|
+
staged_response = await perform_staged_analysis(log_text=log_text)
|
|
92
106
|
update_metrics(metrics_id, staged_response)
|
|
93
107
|
preprocessed_log.close()
|
|
94
108
|
|
|
@@ -142,8 +156,7 @@ class LogsTooLargeError(RuntimeError):
|
|
|
142
156
|
|
|
143
157
|
async def retrieve_and_preprocess_koji_logs(
|
|
144
158
|
gitlab_cfg: GitLabInstanceConfig,
|
|
145
|
-
|
|
146
|
-
job: gitlab.v4.objects.ProjectJob
|
|
159
|
+
job: gitlab.v4.objects.ProjectJob,
|
|
147
160
|
): # pylint: disable=too-many-branches,too-many-locals
|
|
148
161
|
"""Download logs from the merge request artifacts
|
|
149
162
|
|
|
@@ -155,7 +168,7 @@ async def retrieve_and_preprocess_koji_logs(
|
|
|
155
168
|
Detective. The calling function is responsible for closing this object."""
|
|
156
169
|
|
|
157
170
|
# Make sure the file isn't too large to process.
|
|
158
|
-
if not await check_artifacts_file_size(gitlab_cfg,
|
|
171
|
+
if not await check_artifacts_file_size(gitlab_cfg, job):
|
|
159
172
|
raise LogsTooLargeError(
|
|
160
173
|
f"Oversized logs for job {job.id} in project {job.project_id}"
|
|
161
174
|
)
|
|
@@ -201,7 +214,9 @@ async def retrieve_and_preprocess_koji_logs(
|
|
|
201
214
|
match = FAILURE_LOG_REGEX.search(contents)
|
|
202
215
|
if match:
|
|
203
216
|
failure_log_name = match.group(1)
|
|
204
|
-
failed_arches[architecture] = PurePath(
|
|
217
|
+
failed_arches[architecture] = PurePath(
|
|
218
|
+
path.parent, failure_log_name
|
|
219
|
+
)
|
|
205
220
|
else:
|
|
206
221
|
LOG.info(
|
|
207
222
|
"task_failed.log does not indicate which log contains the failure."
|
|
@@ -243,8 +258,8 @@ async def retrieve_and_preprocess_koji_logs(
|
|
|
243
258
|
|
|
244
259
|
log_path = failed_arches[failed_arch].as_posix()
|
|
245
260
|
|
|
246
|
-
log_url = f"{gitlab_cfg.
|
|
247
|
-
LOG.debug("Returning contents of %s", log_url)
|
|
261
|
+
log_url = f"{gitlab_cfg.api_path}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
|
|
262
|
+
LOG.debug("Returning contents of %s%s", gitlab_cfg.url, log_url)
|
|
248
263
|
|
|
249
264
|
# Return the log as a file-like object with .read() function
|
|
250
265
|
return log_url, artifacts_zip.open(log_path)
|
|
@@ -252,7 +267,6 @@ async def retrieve_and_preprocess_koji_logs(
|
|
|
252
267
|
|
|
253
268
|
async def check_artifacts_file_size(
|
|
254
269
|
gitlab_cfg: GitLabInstanceConfig,
|
|
255
|
-
http: aiohttp.ClientSession,
|
|
256
270
|
job: gitlab.v4.objects.ProjectJob,
|
|
257
271
|
):
|
|
258
272
|
"""Method to determine if the artifacts are too large to process"""
|
|
@@ -260,14 +274,14 @@ async def check_artifacts_file_size(
|
|
|
260
274
|
# zipped artifact collection will be stored in memory below. The
|
|
261
275
|
# python-gitlab library doesn't expose a way to check this value directly,
|
|
262
276
|
# so we need to interact with directly with the headers.
|
|
263
|
-
|
|
264
|
-
|
|
277
|
+
artifacts_path = (
|
|
278
|
+
f"{gitlab_cfg.api_path}/projects/{job.project_id}/jobs/{job.id}/artifacts"
|
|
279
|
+
)
|
|
280
|
+
LOG.debug("checking artifact URL %s%s", gitlab_cfg.url, artifacts_path)
|
|
265
281
|
try:
|
|
266
|
-
head_response = await
|
|
267
|
-
|
|
282
|
+
head_response = await gitlab_cfg.get_http_session().head(
|
|
283
|
+
artifacts_path,
|
|
268
284
|
allow_redirects=True,
|
|
269
|
-
headers={"Authorization": f"Bearer {gitlab_cfg.api_token}"},
|
|
270
|
-
timeout=5,
|
|
271
285
|
raise_for_status=True,
|
|
272
286
|
)
|
|
273
287
|
except aiohttp.ClientResponseError as ex:
|
|
@@ -278,7 +292,7 @@ async def check_artifacts_file_size(
|
|
|
278
292
|
content_length = int(head_response.headers.get("content-length"))
|
|
279
293
|
LOG.debug(
|
|
280
294
|
"URL: %s, content-length: %d, max length: %d",
|
|
281
|
-
|
|
295
|
+
artifacts_path,
|
|
282
296
|
content_length,
|
|
283
297
|
gitlab_cfg.max_artifact_size,
|
|
284
298
|
)
|
|
@@ -337,23 +351,15 @@ async def comment_on_mr( # pylint: disable=too-many-arguments disable=too-many-
|
|
|
337
351
|
await asyncio.to_thread(note.save)
|
|
338
352
|
|
|
339
353
|
# Save the new comment to the database
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
350
|
-
except sqlalchemy.exc.IntegrityError:
|
|
351
|
-
# We most likely attempted to save a new comment for the same
|
|
352
|
-
# build job. This is somewhat common during development when we're
|
|
353
|
-
# submitting requests manually. It shouldn't really happen in
|
|
354
|
-
# production.
|
|
355
|
-
if not SERVER_CONFIG.general.devmode:
|
|
356
|
-
raise
|
|
354
|
+
metrics = AnalyzeRequestMetrics.get_metric_by_id(metrics_id)
|
|
355
|
+
Comments.create(
|
|
356
|
+
forge,
|
|
357
|
+
project.id,
|
|
358
|
+
merge_request_iid,
|
|
359
|
+
job.id,
|
|
360
|
+
discussion.id,
|
|
361
|
+
metrics,
|
|
362
|
+
)
|
|
357
363
|
|
|
358
364
|
|
|
359
365
|
async def suppress_latest_comment(
|
logdetective/server/llm.py
CHANGED
|
@@ -17,9 +17,10 @@ from logdetective.utils import (
|
|
|
17
17
|
)
|
|
18
18
|
from logdetective.server.config import LOG, SERVER_CONFIG, PROMPT_CONFIG
|
|
19
19
|
from logdetective.server.models import (
|
|
20
|
-
StagedResponse,
|
|
21
|
-
Explanation,
|
|
22
20
|
AnalyzedSnippet,
|
|
21
|
+
InferenceConfig,
|
|
22
|
+
Explanation,
|
|
23
|
+
StagedResponse,
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
@@ -54,24 +55,33 @@ def mine_logs(log: str) -> List[Tuple[int, str]]:
|
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
async def submit_to_llm_endpoint(
|
|
57
|
-
|
|
58
|
-
url: str,
|
|
58
|
+
url_path: str,
|
|
59
59
|
data: Dict[str, Any],
|
|
60
60
|
headers: Dict[str, str],
|
|
61
61
|
stream: bool,
|
|
62
|
+
inference_cfg: InferenceConfig = SERVER_CONFIG.inference,
|
|
62
63
|
) -> Any:
|
|
63
|
-
"""Send request to
|
|
64
|
+
"""Send request to an API endpoint. Verifying successful request unless
|
|
64
65
|
the using the stream response.
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
url_path: The endpoint path to query. (e.g. "/v1/chat/completions"). It should
|
|
68
|
+
not include the scheme and netloc of the URL, which is stored in the
|
|
69
|
+
InferenceConfig.
|
|
67
70
|
data:
|
|
68
71
|
headers:
|
|
69
72
|
stream:
|
|
73
|
+
inference_cfg: An InferenceConfig object containing the URL, max_tokens
|
|
74
|
+
and other relevant configuration for talking to an inference server.
|
|
70
75
|
"""
|
|
71
|
-
async with
|
|
72
|
-
LOG.debug("async request %s headers=%s data=%s",
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
async with inference_cfg.get_limiter():
|
|
77
|
+
LOG.debug("async request %s headers=%s data=%s", url_path, headers, data)
|
|
78
|
+
session = inference_cfg.get_http_session()
|
|
79
|
+
|
|
80
|
+
if inference_cfg.api_token:
|
|
81
|
+
headers["Authorization"] = f"Bearer {inference_cfg.api_token}"
|
|
82
|
+
|
|
83
|
+
response = await session.post(
|
|
84
|
+
url_path,
|
|
75
85
|
headers=headers,
|
|
76
86
|
# we need to use the `json=` parameter here and let aiohttp
|
|
77
87
|
# handle the json-encoding
|
|
@@ -88,7 +98,9 @@ async def submit_to_llm_endpoint(
|
|
|
88
98
|
try:
|
|
89
99
|
return json.loads(await response.text())
|
|
90
100
|
except UnicodeDecodeError as ex:
|
|
91
|
-
LOG.error(
|
|
101
|
+
LOG.error(
|
|
102
|
+
"Error encountered while parsing llama server response: %s", ex
|
|
103
|
+
)
|
|
92
104
|
raise HTTPException(
|
|
93
105
|
status_code=400,
|
|
94
106
|
detail=f"Couldn't parse the response.\nError: {ex}\nData: {response.text}",
|
|
@@ -125,16 +137,14 @@ def we_give_up(details: backoff._typing.Details):
|
|
|
125
137
|
raise_on_giveup=False,
|
|
126
138
|
on_giveup=we_give_up,
|
|
127
139
|
)
|
|
128
|
-
async def submit_text(
|
|
129
|
-
http: aiohttp.ClientSession,
|
|
140
|
+
async def submit_text(
|
|
130
141
|
text: str,
|
|
131
|
-
|
|
132
|
-
log_probs: int = 1,
|
|
142
|
+
inference_cfg: InferenceConfig,
|
|
133
143
|
stream: bool = False,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
) -> Union[Explanation, StreamReader]:
|
|
145
|
+
"""Submit prompt to LLM.
|
|
146
|
+
inference_cfg: The configuration section from the config.json representing
|
|
147
|
+
the relevant inference server for this request.
|
|
138
148
|
log_probs: number of token choices to produce log probs for
|
|
139
149
|
"""
|
|
140
150
|
LOG.info("Analyzing the text")
|
|
@@ -144,64 +154,6 @@ async def submit_text( # pylint: disable=R0913,R0917
|
|
|
144
154
|
if SERVER_CONFIG.inference.api_token:
|
|
145
155
|
headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
|
|
146
156
|
|
|
147
|
-
if SERVER_CONFIG.inference.api_endpoint == "/chat/completions":
|
|
148
|
-
return await submit_text_chat_completions(
|
|
149
|
-
http, text, headers, max_tokens, log_probs > 0, stream, model
|
|
150
|
-
)
|
|
151
|
-
return await submit_text_completions(
|
|
152
|
-
http, text, headers, max_tokens, log_probs, stream, model
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
async def submit_text_completions( # pylint: disable=R0913,R0917
|
|
157
|
-
http: aiohttp.ClientSession,
|
|
158
|
-
text: str,
|
|
159
|
-
headers: dict,
|
|
160
|
-
max_tokens: int = -1,
|
|
161
|
-
log_probs: int = 1,
|
|
162
|
-
stream: bool = False,
|
|
163
|
-
model: str = "default-model",
|
|
164
|
-
) -> Explanation:
|
|
165
|
-
"""Submit prompt to OpenAI API completions endpoint.
|
|
166
|
-
max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
|
|
167
|
-
log_probs: number of token choices to produce log probs for
|
|
168
|
-
"""
|
|
169
|
-
LOG.info("Submitting to /v1/completions endpoint")
|
|
170
|
-
data = {
|
|
171
|
-
"prompt": text,
|
|
172
|
-
"max_tokens": max_tokens,
|
|
173
|
-
"logprobs": log_probs,
|
|
174
|
-
"stream": stream,
|
|
175
|
-
"model": model,
|
|
176
|
-
"temperature": SERVER_CONFIG.inference.temperature,
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
response = await submit_to_llm_endpoint(
|
|
180
|
-
http,
|
|
181
|
-
f"{SERVER_CONFIG.inference.url}/v1/completions",
|
|
182
|
-
data,
|
|
183
|
-
headers,
|
|
184
|
-
stream,
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
return Explanation(
|
|
188
|
-
text=response["choices"][0]["text"], logprobs=response["choices"][0]["logprobs"]
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
async def submit_text_chat_completions( # pylint: disable=R0913,R0917
|
|
193
|
-
http: aiohttp.ClientSession,
|
|
194
|
-
text: str,
|
|
195
|
-
headers: dict,
|
|
196
|
-
max_tokens: int = -1,
|
|
197
|
-
log_probs: int = 1,
|
|
198
|
-
stream: bool = False,
|
|
199
|
-
model: str = "default-model",
|
|
200
|
-
) -> Union[Explanation, StreamReader]:
|
|
201
|
-
"""Submit prompt to OpenAI API /chat/completions endpoint.
|
|
202
|
-
max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
|
|
203
|
-
log_probs: number of token choices to produce log probs for
|
|
204
|
-
"""
|
|
205
157
|
LOG.info("Submitting to /v1/chat/completions endpoint")
|
|
206
158
|
|
|
207
159
|
data = {
|
|
@@ -211,19 +163,19 @@ async def submit_text_chat_completions( # pylint: disable=R0913,R0917
|
|
|
211
163
|
"content": text,
|
|
212
164
|
}
|
|
213
165
|
],
|
|
214
|
-
"max_tokens": max_tokens,
|
|
215
|
-
"logprobs": log_probs,
|
|
166
|
+
"max_tokens": inference_cfg.max_tokens,
|
|
167
|
+
"logprobs": inference_cfg.log_probs,
|
|
216
168
|
"stream": stream,
|
|
217
|
-
"model": model,
|
|
218
|
-
"temperature":
|
|
169
|
+
"model": inference_cfg.model,
|
|
170
|
+
"temperature": inference_cfg.temperature,
|
|
219
171
|
}
|
|
220
172
|
|
|
221
173
|
response = await submit_to_llm_endpoint(
|
|
222
|
-
|
|
223
|
-
f"{SERVER_CONFIG.inference.url}/v1/chat/completions",
|
|
174
|
+
"/v1/chat/completions",
|
|
224
175
|
data,
|
|
225
176
|
headers,
|
|
226
|
-
|
|
177
|
+
inference_cfg=inference_cfg,
|
|
178
|
+
stream=stream,
|
|
227
179
|
)
|
|
228
180
|
|
|
229
181
|
if stream:
|
|
@@ -234,19 +186,15 @@ async def submit_text_chat_completions( # pylint: disable=R0913,R0917
|
|
|
234
186
|
)
|
|
235
187
|
|
|
236
188
|
|
|
237
|
-
async def perform_staged_analysis(
|
|
238
|
-
http: aiohttp.ClientSession, log_text: str
|
|
239
|
-
) -> StagedResponse:
|
|
189
|
+
async def perform_staged_analysis(log_text: str) -> StagedResponse:
|
|
240
190
|
"""Submit the log file snippets to the LLM and retrieve their results"""
|
|
241
191
|
log_summary = mine_logs(log_text)
|
|
242
192
|
|
|
243
193
|
# Process snippets asynchronously
|
|
244
194
|
awaitables = [
|
|
245
195
|
submit_text(
|
|
246
|
-
http,
|
|
247
196
|
PROMPT_CONFIG.snippet_prompt_template.format(s),
|
|
248
|
-
|
|
249
|
-
max_tokens=SERVER_CONFIG.inference.max_tokens,
|
|
197
|
+
inference_cfg=SERVER_CONFIG.snippet_inference,
|
|
250
198
|
)
|
|
251
199
|
for s in log_summary
|
|
252
200
|
]
|
|
@@ -261,10 +209,8 @@ async def perform_staged_analysis(
|
|
|
261
209
|
)
|
|
262
210
|
|
|
263
211
|
final_analysis = await submit_text(
|
|
264
|
-
http,
|
|
265
212
|
final_prompt,
|
|
266
|
-
|
|
267
|
-
max_tokens=SERVER_CONFIG.inference.max_tokens,
|
|
213
|
+
inference_cfg=SERVER_CONFIG.inference,
|
|
268
214
|
)
|
|
269
215
|
|
|
270
216
|
certainty = 0
|
logdetective/server/models.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import datetime
|
|
2
3
|
from logging import BASIC_FORMAT
|
|
3
|
-
from typing import List, Dict, Optional
|
|
4
|
+
from typing import List, Dict, Optional
|
|
4
5
|
from pydantic import (
|
|
5
6
|
BaseModel,
|
|
6
7
|
Field,
|
|
@@ -10,6 +11,8 @@ from pydantic import (
|
|
|
10
11
|
HttpUrl,
|
|
11
12
|
)
|
|
12
13
|
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
13
16
|
from aiolimiter import AsyncLimiter
|
|
14
17
|
from gitlab import Gitlab
|
|
15
18
|
|
|
@@ -131,16 +134,14 @@ class InferenceConfig(BaseModel): # pylint: disable=too-many-instance-attribute
|
|
|
131
134
|
"""Model for inference configuration of logdetective server."""
|
|
132
135
|
|
|
133
136
|
max_tokens: int = -1
|
|
134
|
-
log_probs:
|
|
135
|
-
api_endpoint: Optional[Literal["/chat/completions", "/completions"]] = (
|
|
136
|
-
"/chat/completions"
|
|
137
|
-
)
|
|
137
|
+
log_probs: bool = True
|
|
138
138
|
url: str = ""
|
|
139
139
|
api_token: str = ""
|
|
140
140
|
model: str = ""
|
|
141
141
|
temperature: NonNegativeFloat = DEFAULT_TEMPERATURE
|
|
142
142
|
max_queue_size: int = LLM_DEFAULT_MAX_QUEUE_SIZE
|
|
143
|
-
|
|
143
|
+
http_timeout: float = 5.0
|
|
144
|
+
_http_session: aiohttp.ClientSession = None
|
|
144
145
|
_limiter: AsyncLimiter = AsyncLimiter(LLM_DEFAULT_REQUESTS_PER_MINUTE)
|
|
145
146
|
|
|
146
147
|
def __init__(self, data: Optional[dict] = None):
|
|
@@ -149,9 +150,9 @@ class InferenceConfig(BaseModel): # pylint: disable=too-many-instance-attribute
|
|
|
149
150
|
return
|
|
150
151
|
|
|
151
152
|
self.max_tokens = data.get("max_tokens", -1)
|
|
152
|
-
self.log_probs = data.get("log_probs",
|
|
153
|
-
self.api_endpoint = data.get("api_endpoint", "/chat/completions")
|
|
153
|
+
self.log_probs = data.get("log_probs", True)
|
|
154
154
|
self.url = data.get("url", "")
|
|
155
|
+
self.http_timeout = data.get("http_timeout", 5.0)
|
|
155
156
|
self.api_token = data.get("api_token", "")
|
|
156
157
|
self.model = data.get("model", "default-model")
|
|
157
158
|
self.temperature = data.get("temperature", DEFAULT_TEMPERATURE)
|
|
@@ -162,6 +163,40 @@ class InferenceConfig(BaseModel): # pylint: disable=too-many-instance-attribute
|
|
|
162
163
|
)
|
|
163
164
|
self._limiter = AsyncLimiter(self._requests_per_minute)
|
|
164
165
|
|
|
166
|
+
def __del__(self):
|
|
167
|
+
# Close connection when this object is destroyed
|
|
168
|
+
if self._http_session:
|
|
169
|
+
try:
|
|
170
|
+
loop = asyncio.get_running_loop()
|
|
171
|
+
loop.create_task(self._http_session.close())
|
|
172
|
+
except RuntimeError:
|
|
173
|
+
# No loop running, so create one to close the session
|
|
174
|
+
loop = asyncio.new_event_loop()
|
|
175
|
+
loop.run_until_complete(self._http_session.close())
|
|
176
|
+
loop.close()
|
|
177
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
178
|
+
# We should only get here if we're shutting down, so we don't
|
|
179
|
+
# really care if the close() completes cleanly.
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def get_http_session(self):
|
|
183
|
+
"""Return the internal HTTP session so it can be used to contect the
|
|
184
|
+
LLM server. May be used as a context manager."""
|
|
185
|
+
|
|
186
|
+
# Create the session on the first attempt. We need to do this "lazily"
|
|
187
|
+
# because it needs to happen once the event loop is running, even
|
|
188
|
+
# though the initialization itself is synchronous.
|
|
189
|
+
if not self._http_session:
|
|
190
|
+
self._http_session = aiohttp.ClientSession(
|
|
191
|
+
base_url=self.url,
|
|
192
|
+
timeout=aiohttp.ClientTimeout(
|
|
193
|
+
total=self.http_timeout,
|
|
194
|
+
connect=3.07,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return self._http_session
|
|
199
|
+
|
|
165
200
|
def get_limiter(self):
|
|
166
201
|
"""Return the limiter object so it can be used as a context manager"""
|
|
167
202
|
return self._limiter
|
|
@@ -184,14 +219,25 @@ class ExtractorConfig(BaseModel):
|
|
|
184
219
|
self.verbose = data.get("verbose", False)
|
|
185
220
|
|
|
186
221
|
|
|
187
|
-
class GitLabInstanceConfig(BaseModel):
|
|
222
|
+
class GitLabInstanceConfig(BaseModel): # pylint: disable=too-many-instance-attributes
|
|
188
223
|
"""Model for GitLab configuration of logdetective server."""
|
|
189
224
|
|
|
190
225
|
name: str = None
|
|
191
226
|
url: str = None
|
|
192
|
-
|
|
227
|
+
api_path: str = None
|
|
193
228
|
api_token: str = None
|
|
229
|
+
|
|
230
|
+
# This is a list to support key rotation.
|
|
231
|
+
# When the key is being changed, we will add the new key as a new entry in
|
|
232
|
+
# the configuration and then remove the old key once all of the client
|
|
233
|
+
# webhook configurations have been updated.
|
|
234
|
+
# If this option is left empty or unspecified, all requests will be
|
|
235
|
+
# considered authorized.
|
|
236
|
+
webhook_secrets: Optional[List[str]] = None
|
|
237
|
+
|
|
238
|
+
timeout: float = 5.0
|
|
194
239
|
_conn: Gitlab = None
|
|
240
|
+
_http_session: aiohttp.ClientSession = None
|
|
195
241
|
|
|
196
242
|
# Maximum size of artifacts.zip in MiB. (default: 300 MiB)
|
|
197
243
|
max_artifact_size: int = 300
|
|
@@ -203,16 +249,57 @@ class GitLabInstanceConfig(BaseModel):
|
|
|
203
249
|
|
|
204
250
|
self.name = name
|
|
205
251
|
self.url = data.get("url", "https://gitlab.com")
|
|
206
|
-
self.
|
|
252
|
+
self.api_path = data.get("api_path", "/api/v4")
|
|
207
253
|
self.api_token = data.get("api_token", None)
|
|
254
|
+
self.webhook_secrets = data.get("webhook_secrets", None)
|
|
208
255
|
self.max_artifact_size = int(data.get("max_artifact_size")) * 1024 * 1024
|
|
209
256
|
|
|
210
|
-
self.
|
|
257
|
+
self.timeout = data.get("timeout", 5.0)
|
|
258
|
+
self._conn = Gitlab(
|
|
259
|
+
url=self.url,
|
|
260
|
+
private_token=self.api_token,
|
|
261
|
+
timeout=self.timeout,
|
|
262
|
+
)
|
|
211
263
|
|
|
212
264
|
def get_connection(self):
|
|
213
265
|
"""Get the Gitlab connection object"""
|
|
214
266
|
return self._conn
|
|
215
267
|
|
|
268
|
+
def get_http_session(self):
|
|
269
|
+
"""Return the internal HTTP session so it can be used to contect the
|
|
270
|
+
Gitlab server. May be used as a context manager."""
|
|
271
|
+
|
|
272
|
+
# Create the session on the first attempt. We need to do this "lazily"
|
|
273
|
+
# because it needs to happen once the event loop is running, even
|
|
274
|
+
# though the initialization itself is synchronous.
|
|
275
|
+
if not self._http_session:
|
|
276
|
+
self._http_session = aiohttp.ClientSession(
|
|
277
|
+
base_url=self.url,
|
|
278
|
+
headers={"Authorization": f"Bearer {self.api_token}"},
|
|
279
|
+
timeout=aiohttp.ClientTimeout(
|
|
280
|
+
total=self.timeout,
|
|
281
|
+
connect=3.07,
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return self._http_session
|
|
286
|
+
|
|
287
|
+
def __del__(self):
|
|
288
|
+
# Close connection when this object is destroyed
|
|
289
|
+
if self._http_session:
|
|
290
|
+
try:
|
|
291
|
+
loop = asyncio.get_running_loop()
|
|
292
|
+
loop.create_task(self._http_session.close())
|
|
293
|
+
except RuntimeError:
|
|
294
|
+
# No loop running, so create one to close the session
|
|
295
|
+
loop = asyncio.new_event_loop()
|
|
296
|
+
loop.run_until_complete(self._http_session.close())
|
|
297
|
+
loop.close()
|
|
298
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
299
|
+
# We should only get here if we're shutting down, so we don't
|
|
300
|
+
# really care if the close() completes cleanly.
|
|
301
|
+
pass
|
|
302
|
+
|
|
216
303
|
|
|
217
304
|
class GitLabConfig(BaseModel):
|
|
218
305
|
"""Model for GitLab configuration of logdetective server."""
|
|
@@ -257,6 +344,7 @@ class GeneralConfig(BaseModel):
|
|
|
257
344
|
excluded_packages: List[str] = None
|
|
258
345
|
devmode: bool = False
|
|
259
346
|
sentry_dsn: HttpUrl | None = None
|
|
347
|
+
collect_emojis_interval: int = 60 * 60 # seconds
|
|
260
348
|
|
|
261
349
|
def __init__(self, data: Optional[dict] = None):
|
|
262
350
|
super().__init__()
|
|
@@ -267,6 +355,9 @@ class GeneralConfig(BaseModel):
|
|
|
267
355
|
self.excluded_packages = data.get("excluded_packages", [])
|
|
268
356
|
self.devmode = data.get("devmode", False)
|
|
269
357
|
self.sentry_dsn = data.get("sentry_dsn")
|
|
358
|
+
self.collect_emojis_interval = data.get(
|
|
359
|
+
"collect_emojis_interval", 60 * 60
|
|
360
|
+
) # seconds
|
|
270
361
|
|
|
271
362
|
|
|
272
363
|
class Config(BaseModel):
|
|
@@ -274,6 +365,7 @@ class Config(BaseModel):
|
|
|
274
365
|
|
|
275
366
|
log: LogConfig = LogConfig()
|
|
276
367
|
inference: InferenceConfig = InferenceConfig()
|
|
368
|
+
snippet_inference: InferenceConfig = InferenceConfig()
|
|
277
369
|
extractor: ExtractorConfig = ExtractorConfig()
|
|
278
370
|
gitlab: GitLabConfig = GitLabConfig()
|
|
279
371
|
general: GeneralConfig = GeneralConfig()
|
|
@@ -290,6 +382,11 @@ class Config(BaseModel):
|
|
|
290
382
|
self.gitlab = GitLabConfig(data.get("gitlab"))
|
|
291
383
|
self.general = GeneralConfig(data.get("general"))
|
|
292
384
|
|
|
385
|
+
if snippet_inference := data.get("snippet_inference", None):
|
|
386
|
+
self.snippet_inference = InferenceConfig(snippet_inference)
|
|
387
|
+
else:
|
|
388
|
+
self.snippet_inference = self.inference
|
|
389
|
+
|
|
293
390
|
|
|
294
391
|
class TimePeriod(BaseModel):
|
|
295
392
|
"""Specification for a period of time.
|
logdetective/server/server.py
CHANGED
|
@@ -28,7 +28,6 @@ from logdetective.server.llm import (
|
|
|
28
28
|
mine_logs,
|
|
29
29
|
perform_staged_analysis,
|
|
30
30
|
submit_text,
|
|
31
|
-
submit_text_chat_completions,
|
|
32
31
|
)
|
|
33
32
|
from logdetective.server.gitlab import process_gitlab_job_event
|
|
34
33
|
from logdetective.server.metric import track_request
|
|
@@ -138,10 +137,8 @@ async def analyze_log(
|
|
|
138
137
|
log_summary = format_snippets(log_summary)
|
|
139
138
|
|
|
140
139
|
response = await submit_text(
|
|
141
|
-
http_session,
|
|
142
140
|
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
143
|
-
|
|
144
|
-
max_tokens=SERVER_CONFIG.inference.max_tokens,
|
|
141
|
+
inference_cfg=SERVER_CONFIG.inference,
|
|
145
142
|
)
|
|
146
143
|
certainty = 0
|
|
147
144
|
|
|
@@ -172,10 +169,7 @@ async def analyze_log_staged(
|
|
|
172
169
|
remote_log = RemoteLog(build_log.url, http_session)
|
|
173
170
|
log_text = await remote_log.process_url()
|
|
174
171
|
|
|
175
|
-
return await perform_staged_analysis(
|
|
176
|
-
http_session,
|
|
177
|
-
log_text=log_text,
|
|
178
|
-
)
|
|
172
|
+
return await perform_staged_analysis(log_text)
|
|
179
173
|
|
|
180
174
|
|
|
181
175
|
@app.get("/queue/print")
|
|
@@ -210,19 +204,12 @@ async def analyze_log_stream(
|
|
|
210
204
|
log_text = await remote_log.process_url()
|
|
211
205
|
log_summary = mine_logs(log_text)
|
|
212
206
|
log_summary = format_snippets(log_summary)
|
|
213
|
-
headers = {"Content-Type": "application/json"}
|
|
214
|
-
|
|
215
|
-
if SERVER_CONFIG.inference.api_token:
|
|
216
|
-
headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
|
|
217
207
|
|
|
218
208
|
try:
|
|
219
|
-
stream =
|
|
220
|
-
http_session,
|
|
209
|
+
stream = submit_text(
|
|
221
210
|
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
211
|
+
inference_cfg=SERVER_CONFIG.inference,
|
|
222
212
|
stream=True,
|
|
223
|
-
headers=headers,
|
|
224
|
-
model=SERVER_CONFIG.inference.model,
|
|
225
|
-
max_tokens=SERVER_CONFIG.inference.max_tokens,
|
|
226
213
|
)
|
|
227
214
|
except aiohttp.ClientResponseError as ex:
|
|
228
215
|
raise HTTPException(
|
|
@@ -237,12 +224,29 @@ async def analyze_log_stream(
|
|
|
237
224
|
return StreamingResponse(stream)
|
|
238
225
|
|
|
239
226
|
|
|
227
|
+
def is_valid_webhook_secret(forge, x_gitlab_token):
|
|
228
|
+
"""Check whether the provided x_gitlab_token matches the webhook secret
|
|
229
|
+
specified in the configuration"""
|
|
230
|
+
|
|
231
|
+
gitlab_cfg = SERVER_CONFIG.gitlab.instances[forge.value]
|
|
232
|
+
|
|
233
|
+
if not gitlab_cfg.webhook_secrets:
|
|
234
|
+
# No secrets specified, so don't bother validating.
|
|
235
|
+
# This is mostly to be used for development.
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
if x_gitlab_token in gitlab_cfg.webhook_secrets:
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
240
244
|
@app.post("/webhook/gitlab/job_events")
|
|
241
245
|
async def receive_gitlab_job_event_webhook(
|
|
242
|
-
x_gitlab_instance: Annotated[str | None, Header()],
|
|
243
246
|
job_hook: JobHook,
|
|
244
247
|
background_tasks: BackgroundTasks,
|
|
245
|
-
|
|
248
|
+
x_gitlab_instance: Annotated[str | None, Header()],
|
|
249
|
+
x_gitlab_token: Annotated[str | None, Header()] = None,
|
|
246
250
|
):
|
|
247
251
|
"""Webhook endpoint for receiving job_events notifications from GitLab
|
|
248
252
|
https://docs.gitlab.com/user/project/integrations/webhook_events/#job-events
|
|
@@ -254,11 +258,15 @@ async def receive_gitlab_job_event_webhook(
|
|
|
254
258
|
LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
|
|
255
259
|
return BasicResponse(status_code=400)
|
|
256
260
|
|
|
261
|
+
if not is_valid_webhook_secret(forge, x_gitlab_token):
|
|
262
|
+
# This request could not be validated, so return a 401
|
|
263
|
+
# (Unauthorized) error.
|
|
264
|
+
return BasicResponse(status_code=401)
|
|
265
|
+
|
|
257
266
|
# Handle the message in the background so we can return 204 immediately
|
|
258
267
|
gitlab_cfg = SERVER_CONFIG.gitlab.instances[forge.value]
|
|
259
268
|
background_tasks.add_task(
|
|
260
269
|
process_gitlab_job_event,
|
|
261
|
-
http,
|
|
262
270
|
gitlab_cfg,
|
|
263
271
|
forge,
|
|
264
272
|
job_hook,
|
|
@@ -280,6 +288,7 @@ emoji_lookup = {}
|
|
|
280
288
|
@app.post("/webhook/gitlab/emoji_events")
|
|
281
289
|
async def receive_gitlab_emoji_event_webhook(
|
|
282
290
|
x_gitlab_instance: Annotated[str | None, Header()],
|
|
291
|
+
x_gitlab_token: Annotated[str | None, Header()],
|
|
283
292
|
emoji_hook: EmojiHook,
|
|
284
293
|
background_tasks: BackgroundTasks,
|
|
285
294
|
):
|
|
@@ -293,6 +302,11 @@ async def receive_gitlab_emoji_event_webhook(
|
|
|
293
302
|
LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
|
|
294
303
|
return BasicResponse(status_code=400)
|
|
295
304
|
|
|
305
|
+
if not is_valid_webhook_secret(forge, x_gitlab_token):
|
|
306
|
+
# This request could not be validated, so return a 401
|
|
307
|
+
# (Unauthorized) error.
|
|
308
|
+
return BasicResponse(status_code=401)
|
|
309
|
+
|
|
296
310
|
if not emoji_hook.merge_request:
|
|
297
311
|
# This is not a merge request event. It is probably an emoji applied
|
|
298
312
|
# to some other "awardable" entity. Just ignore it and return.
|
|
@@ -504,20 +518,13 @@ async def collect_emoji_task():
|
|
|
504
518
|
|
|
505
519
|
|
|
506
520
|
async def schedule_collect_emojis_task():
|
|
507
|
-
"""Schedule the collect_emojis_task to run
|
|
521
|
+
"""Schedule the collect_emojis_task to run on a configured interval"""
|
|
508
522
|
while True:
|
|
509
|
-
|
|
510
|
-
midnight = datetime.datetime.combine(
|
|
511
|
-
now.date() + datetime.timedelta(days=1),
|
|
512
|
-
datetime.time(0, 0),
|
|
513
|
-
datetime.timezone.utc,
|
|
514
|
-
)
|
|
515
|
-
seconds_until_run = (midnight - now).total_seconds()
|
|
516
|
-
|
|
523
|
+
seconds_until_run = SERVER_CONFIG.general.collect_emojis_interval
|
|
517
524
|
LOG.info("Collect emojis in %d seconds", seconds_until_run)
|
|
518
525
|
await asyncio.sleep(seconds_until_run)
|
|
519
526
|
|
|
520
527
|
try:
|
|
521
528
|
await collect_emoji_task()
|
|
522
529
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
523
|
-
LOG.
|
|
530
|
+
LOG.exception("Error in collect_emoji_task: %s", e)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: logdetective
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Log using LLM AI to search for build/test failures and provide ideas for fixing these.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: Jiri Podivin
|
|
@@ -218,6 +218,10 @@ or
|
|
|
218
218
|
|
|
219
219
|
tox run -e lint # to run pylint
|
|
220
220
|
|
|
221
|
+
To run full test suite you will need postgresql client utilities.
|
|
222
|
+
|
|
223
|
+
dnf install postgresql
|
|
224
|
+
|
|
221
225
|
Visual Studio Code testing with podman/docker-compose
|
|
222
226
|
-----------------------------------------------------
|
|
223
227
|
|
|
@@ -4,6 +4,8 @@ logdetective/drain3.ini,sha256=ni91eCT1TwTznZwcqWoOVMQcGEnWhEDNCoTPF7cfGfY,1360
|
|
|
4
4
|
logdetective/extractors.py,sha256=7ahzWbTtU9MveG1Q7wU9LO8OJgs85X-cHmWltUhCe9M,3491
|
|
5
5
|
logdetective/logdetective.py,sha256=cC2oL4yPNo94AB2nS4v1jpZi-Qo1g0_FEchL_yQL1UU,5832
|
|
6
6
|
logdetective/models.py,sha256=nrGBmMRu8i6UhFflQKAp81Y3Sd_Aaoor0i_yqSJoLT0,1115
|
|
7
|
+
logdetective/prompts-summary-first.yml,sha256=3Zfp4NNOfaFYq5xBlBjeQa5PdjYfS4v17OtJqQ-DRpU,821
|
|
8
|
+
logdetective/prompts-summary-only.yml,sha256=8U9AMJV8ePW-0CoXOXlQoO92DAJDeutIT8ntSkkm6W0,470
|
|
7
9
|
logdetective/prompts.yml,sha256=urPKG068TYxi58EicFVUH6FavZq_q36oM1LvfI4ddjg,1729
|
|
8
10
|
logdetective/remote_log.py,sha256=Zbv8g29jko8uQnzFUznnr8Nd9RSJCRs1PmPV7viqX9M,2267
|
|
9
11
|
logdetective/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -14,18 +16,18 @@ logdetective/server/database/base.py,sha256=1mcjEbhwLl4RalvT3oy6XVctjJoWIW3H9aI_
|
|
|
14
16
|
logdetective/server/database/models/__init__.py,sha256=xy2hkygyw6_87zPKkG20i7g7_LXTGR__PUeojhbvv94,496
|
|
15
17
|
logdetective/server/database/models/merge_request_jobs.py,sha256=hw88wV1-3x7i53sX7ZotKClc6OsH1njPpbRSZofnqr4,18670
|
|
16
18
|
logdetective/server/database/models/metrics.py,sha256=yl9fS4IPVFWDeFvPAxO6zOVu6oLF319ApvVLAgnD5yU,13928
|
|
17
|
-
logdetective/server/emoji.py,sha256=
|
|
18
|
-
logdetective/server/gitlab.py,sha256=
|
|
19
|
-
logdetective/server/llm.py,sha256=
|
|
19
|
+
logdetective/server/emoji.py,sha256=Jh8RM36K8XDLefS8STm-nvhSLuoaFwlKeZJ5HLmRmdY,4298
|
|
20
|
+
logdetective/server/gitlab.py,sha256=HxepI9I7j6VaIn2gu0FLz34e_1qodHQNR8xdrRpEMLI,16277
|
|
21
|
+
logdetective/server/llm.py,sha256=8OnyCErDxKZA7FJSUzNjgPHu0eAjagCz7Yx7LzW98EE,7207
|
|
20
22
|
logdetective/server/metric.py,sha256=B3ew_qSmtEMj6xl-FoOtS4F_bkplp-shhtfHF1cG_Io,4010
|
|
21
|
-
logdetective/server/models.py,sha256=
|
|
23
|
+
logdetective/server/models.py,sha256=V8haEsnIYap1lRj2NlOCBtM7bJxWDdZehw4Whf_hnIE,15336
|
|
22
24
|
logdetective/server/plot.py,sha256=eZs4r9gua-nW3yymSMIz1leL9mb4QKlh6FJZSeOfZ5M,14872
|
|
23
|
-
logdetective/server/server.py,sha256
|
|
25
|
+
logdetective/server/server.py,sha256=-JJnHj8fPzx8aCJD3q2wRwidxoHPCmwOP8FTWwc1C14,18386
|
|
24
26
|
logdetective/server/templates/gitlab_full_comment.md.j2,sha256=DQZ2WVFedpuXI6znbHIW4wpF9BmFS8FaUkowh8AnGhE,1627
|
|
25
27
|
logdetective/server/templates/gitlab_short_comment.md.j2,sha256=fzScpayv2vpRLczP_0O0YxtA8rsKvR6gSv4ntNdWb98,1443
|
|
26
28
|
logdetective/utils.py,sha256=hdExAC8FtDIxvdgIq-Ro6LVM-JZ-k_UofaMzaDAHvzM,6088
|
|
27
|
-
logdetective-1.0.
|
|
28
|
-
logdetective-1.0.
|
|
29
|
-
logdetective-1.0.
|
|
30
|
-
logdetective-1.0.
|
|
31
|
-
logdetective-1.0.
|
|
29
|
+
logdetective-1.1.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
30
|
+
logdetective-1.1.0.dist-info/METADATA,sha256=5oZU-qB24EoQ05ezESAuiDo0MH3o-y3_ZdLYbFiRldo,17231
|
|
31
|
+
logdetective-1.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
32
|
+
logdetective-1.1.0.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
|
|
33
|
+
logdetective-1.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|