logdetective 1.9.0__tar.gz → 2.0.1__tar.gz
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-1.9.0 → logdetective-2.0.1}/PKG-INFO +28 -3
- {logdetective-1.9.0 → logdetective-2.0.1}/README.md +27 -2
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/extractors.py +4 -2
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/prompts.yml +2 -2
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/gitlab.py +1 -1
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/koji.py +2 -10
- logdetective-2.0.1/logdetective/server/llm.py +300 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/models.py +24 -1
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/server.py +8 -53
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/templates/gitlab_full_comment.md.j2 +4 -2
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/templates/gitlab_short_comment.md.j2 +3 -1
- logdetective-2.0.1/logdetective/server/utils.py +122 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/utils.py +2 -2
- {logdetective-1.9.0 → logdetective-2.0.1}/pyproject.toml +1 -1
- logdetective-1.9.0/logdetective/server/llm.py +0 -191
- {logdetective-1.9.0 → logdetective-2.0.1}/LICENSE +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/__init__.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/constants.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/drain3.ini +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/logdetective.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/models.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/prompts-summary-first.yml +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/prompts-summary-only.yml +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/remote_log.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/__init__.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/compressors.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/config.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/__init__.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/base.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/__init__.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/exceptions.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/koji.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/merge_request_jobs.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/metrics.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/emoji.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/exceptions.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/metric.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/plot.py +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective/skip_snippets.yml +0 -0
- {logdetective-1.9.0 → logdetective-2.0.1}/logdetective.1.asciidoc +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: logdetective
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
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
|
|
@@ -124,6 +124,7 @@ Note that streaming with some models (notably Meta-Llama-3 is broken) is broken
|
|
|
124
124
|
|
|
125
125
|
Real Example
|
|
126
126
|
------------
|
|
127
|
+
|
|
127
128
|
Let's have a look at a real world example. Log Detective can work with any logs though we optimize it for RPM build logs.
|
|
128
129
|
|
|
129
130
|
We're going to analyze a failed build of a python-based library that happened in Fedora Koji buildsystem:
|
|
@@ -187,7 +188,7 @@ It looks like a wall of text. Similar to any log. The main difference is that he
|
|
|
187
188
|
|
|
188
189
|
|
|
189
190
|
Contributing
|
|
190
|
-
|
|
191
|
+
============
|
|
191
192
|
|
|
192
193
|
Contributions are welcome! Please submit a pull request if you have any improvements or new features to add. Make sure your changes pass all existing tests before submitting.
|
|
193
194
|
For bigger code changes, please consult us first by creating an issue.
|
|
@@ -304,7 +305,7 @@ podman-compose up server
|
|
|
304
305
|
- Run Visual Stdio Code debug configuration named *Python Debug: Remote Attach*
|
|
305
306
|
|
|
306
307
|
Server
|
|
307
|
-
|
|
308
|
+
======
|
|
308
309
|
|
|
309
310
|
FastApi based server is implemented in `logdetective/server.py`. In order to run it in a development mode,
|
|
310
311
|
simply start llama-cpp-python server with your chosen model as described in llama-cpp-python [docs](https://llama-cpp-python.readthedocs.io/en/latest/server/#running-the-server).
|
|
@@ -335,6 +336,30 @@ Model can be downloaded from [our Hugging Space](https://huggingface.co/fedora-c
|
|
|
335
336
|
$ curl -L -o models/mistral-7b-instruct-v0.3.Q4_K.gguf https://huggingface.co/fedora-copr/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/ggml-model-Q4_K.gguf
|
|
336
337
|
```
|
|
337
338
|
|
|
339
|
+
Filtering snippet analysis by relevance
|
|
340
|
+
---------------------------------------
|
|
341
|
+
|
|
342
|
+
When using `/analyze/staged` API, it is possible to enable filtering analyzed snippets by their estimated relavance, submitting only those with highest meansure of relevance for final analysis.
|
|
343
|
+
|
|
344
|
+
**Note**: This feautre requires LLM provider with support for JSON structured output. Smaller models, even though techically capable of providing structured output, may not be able to appropriatelly estimate snippet relevance.
|
|
345
|
+
|
|
346
|
+
Filtering is disabled by default and must be enabled by setting value of `top_k_snippets` field in `general` section of server configuration. Value indicates number of snippets with highest estimated relavance that will be submitted for final analysis.
|
|
347
|
+
|
|
348
|
+
Example:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
general:
|
|
352
|
+
devmode: False
|
|
353
|
+
packages:
|
|
354
|
+
- .*
|
|
355
|
+
excluded_packages:
|
|
356
|
+
- ^redhat-internal-.*
|
|
357
|
+
top_k_snippets: 10
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
If all snippets are rated the same, the filtering is skipped and warning raised in logs.
|
|
361
|
+
Values higher than total number of snippets, as set by `max_clusters` in the `extrator` section of config, also result in filtering being skipped.
|
|
362
|
+
|
|
338
363
|
Generate a new database revision with alembic
|
|
339
364
|
---------------------------------------------
|
|
340
365
|
|
|
@@ -79,6 +79,7 @@ Note that streaming with some models (notably Meta-Llama-3 is broken) is broken
|
|
|
79
79
|
|
|
80
80
|
Real Example
|
|
81
81
|
------------
|
|
82
|
+
|
|
82
83
|
Let's have a look at a real world example. Log Detective can work with any logs though we optimize it for RPM build logs.
|
|
83
84
|
|
|
84
85
|
We're going to analyze a failed build of a python-based library that happened in Fedora Koji buildsystem:
|
|
@@ -142,7 +143,7 @@ It looks like a wall of text. Similar to any log. The main difference is that he
|
|
|
142
143
|
|
|
143
144
|
|
|
144
145
|
Contributing
|
|
145
|
-
|
|
146
|
+
============
|
|
146
147
|
|
|
147
148
|
Contributions are welcome! Please submit a pull request if you have any improvements or new features to add. Make sure your changes pass all existing tests before submitting.
|
|
148
149
|
For bigger code changes, please consult us first by creating an issue.
|
|
@@ -259,7 +260,7 @@ podman-compose up server
|
|
|
259
260
|
- Run Visual Stdio Code debug configuration named *Python Debug: Remote Attach*
|
|
260
261
|
|
|
261
262
|
Server
|
|
262
|
-
|
|
263
|
+
======
|
|
263
264
|
|
|
264
265
|
FastApi based server is implemented in `logdetective/server.py`. In order to run it in a development mode,
|
|
265
266
|
simply start llama-cpp-python server with your chosen model as described in llama-cpp-python [docs](https://llama-cpp-python.readthedocs.io/en/latest/server/#running-the-server).
|
|
@@ -290,6 +291,30 @@ Model can be downloaded from [our Hugging Space](https://huggingface.co/fedora-c
|
|
|
290
291
|
$ curl -L -o models/mistral-7b-instruct-v0.3.Q4_K.gguf https://huggingface.co/fedora-copr/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/ggml-model-Q4_K.gguf
|
|
291
292
|
```
|
|
292
293
|
|
|
294
|
+
Filtering snippet analysis by relevance
|
|
295
|
+
---------------------------------------
|
|
296
|
+
|
|
297
|
+
When using `/analyze/staged` API, it is possible to enable filtering analyzed snippets by their estimated relavance, submitting only those with highest meansure of relevance for final analysis.
|
|
298
|
+
|
|
299
|
+
**Note**: This feautre requires LLM provider with support for JSON structured output. Smaller models, even though techically capable of providing structured output, may not be able to appropriatelly estimate snippet relevance.
|
|
300
|
+
|
|
301
|
+
Filtering is disabled by default and must be enabled by setting value of `top_k_snippets` field in `general` section of server configuration. Value indicates number of snippets with highest estimated relavance that will be submitted for final analysis.
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
general:
|
|
307
|
+
devmode: False
|
|
308
|
+
packages:
|
|
309
|
+
- .*
|
|
310
|
+
excluded_packages:
|
|
311
|
+
- ^redhat-internal-.*
|
|
312
|
+
top_k_snippets: 10
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
If all snippets are rated the same, the filtering is skipped and warning raised in logs.
|
|
316
|
+
Values higher than total number of snippets, as set by `max_clusters` in the `extrator` section of config, also result in filtering being skipped.
|
|
317
|
+
|
|
293
318
|
Generate a new database revision with alembic
|
|
294
319
|
---------------------------------------------
|
|
295
320
|
|
|
@@ -20,7 +20,8 @@ class DrainExtractor:
|
|
|
20
20
|
context: bool = False,
|
|
21
21
|
max_clusters=8,
|
|
22
22
|
skip_snippets: SkipSnippets = SkipSnippets({}),
|
|
23
|
-
|
|
23
|
+
max_snippet_len: int = 2000
|
|
24
|
+
): # pylint: disable=R0913,R0917
|
|
24
25
|
config = TemplateMinerConfig()
|
|
25
26
|
config.load(f"{os.path.dirname(__file__)}/drain3.ini")
|
|
26
27
|
config.profiling_enabled = verbose
|
|
@@ -29,11 +30,12 @@ class DrainExtractor:
|
|
|
29
30
|
self.verbose = verbose
|
|
30
31
|
self.context = context
|
|
31
32
|
self.skip_snippets = skip_snippets
|
|
33
|
+
self.max_snippet_len = max_snippet_len
|
|
32
34
|
|
|
33
35
|
def __call__(self, log: str) -> list[Tuple[int, str]]:
|
|
34
36
|
out = []
|
|
35
37
|
# Create chunks
|
|
36
|
-
chunks = list(get_chunks(log))
|
|
38
|
+
chunks = list(get_chunks(log, self.max_snippet_len))
|
|
37
39
|
# Keep only chunks that don't match any of the excluded patterns
|
|
38
40
|
chunks = [
|
|
39
41
|
(_, chunk)
|
|
@@ -22,8 +22,8 @@ prompt_template: |
|
|
|
22
22
|
Analysis:
|
|
23
23
|
|
|
24
24
|
snippet_prompt_template: |
|
|
25
|
-
Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution
|
|
26
|
-
|
|
25
|
+
Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution
|
|
26
|
+
and provide estimate of snippet relevance.
|
|
27
27
|
Your analysis must be as concise as possible, while keeping relevant information intact.
|
|
28
28
|
|
|
29
29
|
Snippet:
|
|
@@ -434,7 +434,7 @@ async def generate_mr_comment(
|
|
|
434
434
|
content = tpl.render(
|
|
435
435
|
package=job.project_name,
|
|
436
436
|
explanation=response.explanation.text,
|
|
437
|
-
certainty=
|
|
437
|
+
certainty=response.response_certainty,
|
|
438
438
|
emoji_face=emoji_face,
|
|
439
439
|
snippets=response.snippets,
|
|
440
440
|
log_url=log_url,
|
|
@@ -4,7 +4,6 @@ from typing import Any, Callable, Optional
|
|
|
4
4
|
|
|
5
5
|
import backoff
|
|
6
6
|
import koji
|
|
7
|
-
from logdetective.server.config import LOG
|
|
8
7
|
from logdetective.server.exceptions import (
|
|
9
8
|
KojiInvalidTaskID,
|
|
10
9
|
LogDetectiveConnectionError,
|
|
@@ -12,23 +11,16 @@ from logdetective.server.exceptions import (
|
|
|
12
11
|
LogsTooLargeError,
|
|
13
12
|
UnknownTaskType,
|
|
14
13
|
)
|
|
15
|
-
|
|
14
|
+
from logdetective.server.utils import connection_error_giveup
|
|
16
15
|
|
|
17
16
|
FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
|
|
18
17
|
|
|
19
18
|
|
|
20
|
-
def connection_error_giveup(details: backoff._typing.Details) -> None:
|
|
21
|
-
"""
|
|
22
|
-
Too many connection errors, give up.
|
|
23
|
-
"""
|
|
24
|
-
LOG.error("Too many connection errors, giving up. %s", details["exception"])
|
|
25
|
-
raise LogDetectiveConnectionError() from details["exception"]
|
|
26
|
-
|
|
27
|
-
|
|
28
19
|
@backoff.on_exception(
|
|
29
20
|
backoff.expo,
|
|
30
21
|
koji.GenericError,
|
|
31
22
|
max_time=60,
|
|
23
|
+
on_giveup=connection_error_giveup,
|
|
32
24
|
)
|
|
33
25
|
async def call_koji(func: Callable, *args, **kwargs) -> Any:
|
|
34
26
|
"""
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import asyncio
|
|
3
|
+
import random
|
|
4
|
+
from typing import List, Tuple, Dict
|
|
5
|
+
|
|
6
|
+
import backoff
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
from openai import AsyncStream
|
|
12
|
+
from openai.types.chat import ChatCompletionChunk
|
|
13
|
+
|
|
14
|
+
from logdetective.utils import (
|
|
15
|
+
compute_certainty,
|
|
16
|
+
prompt_to_messages,
|
|
17
|
+
format_snippets,
|
|
18
|
+
)
|
|
19
|
+
from logdetective.server.config import (
|
|
20
|
+
LOG,
|
|
21
|
+
SERVER_CONFIG,
|
|
22
|
+
PROMPT_CONFIG,
|
|
23
|
+
CLIENT,
|
|
24
|
+
)
|
|
25
|
+
from logdetective.server.models import (
|
|
26
|
+
AnalyzedSnippet,
|
|
27
|
+
InferenceConfig,
|
|
28
|
+
Explanation,
|
|
29
|
+
StagedResponse,
|
|
30
|
+
SnippetAnalysis,
|
|
31
|
+
RatedSnippetAnalysis,
|
|
32
|
+
Response,
|
|
33
|
+
)
|
|
34
|
+
from logdetective.server.utils import (
|
|
35
|
+
format_analyzed_snippets,
|
|
36
|
+
mine_logs,
|
|
37
|
+
should_we_giveup,
|
|
38
|
+
we_give_up,
|
|
39
|
+
filter_snippets,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@backoff.on_exception(
|
|
47
|
+
lambda: backoff.constant([10, 30, 120]),
|
|
48
|
+
aiohttp.ClientResponseError,
|
|
49
|
+
max_tries=4, # 4 tries and 3 retries
|
|
50
|
+
jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
|
|
51
|
+
giveup=should_we_giveup,
|
|
52
|
+
raise_on_giveup=False,
|
|
53
|
+
on_giveup=we_give_up,
|
|
54
|
+
)
|
|
55
|
+
async def call_llm(
|
|
56
|
+
messages: List[Dict[str, str]],
|
|
57
|
+
inference_cfg: InferenceConfig,
|
|
58
|
+
stream: bool = False,
|
|
59
|
+
structured_output: dict | None = None,
|
|
60
|
+
) -> Explanation:
|
|
61
|
+
"""Submit prompt to LLM.
|
|
62
|
+
inference_cfg: The configuration section from the config.json representing
|
|
63
|
+
the relevant inference server for this request.
|
|
64
|
+
"""
|
|
65
|
+
LOG.info("Analyzing the text")
|
|
66
|
+
|
|
67
|
+
LOG.info("Submitting to /v1/chat/completions endpoint")
|
|
68
|
+
|
|
69
|
+
kwargs = {}
|
|
70
|
+
|
|
71
|
+
# OpenAI API does not guarantee that the behavior for parameter set to `None`
|
|
72
|
+
# and parameter not given at all is the same.
|
|
73
|
+
# We build a dictionary of parameters based on the configuration.
|
|
74
|
+
if inference_cfg.log_probs:
|
|
75
|
+
LOG.info("Requesting log probabilities from LLM")
|
|
76
|
+
kwargs["logprobs"] = inference_cfg.log_probs
|
|
77
|
+
if structured_output:
|
|
78
|
+
LOG.info("Requesting structured output from LLM")
|
|
79
|
+
response_format = {
|
|
80
|
+
"type": "json_schema",
|
|
81
|
+
"json_schema": {
|
|
82
|
+
"name": "rated-snippet-analysis",
|
|
83
|
+
"schema": structured_output,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
kwargs["response_format"] = response_format
|
|
87
|
+
|
|
88
|
+
async with inference_cfg.get_limiter():
|
|
89
|
+
response = await CLIENT.chat.completions.create(
|
|
90
|
+
messages=messages,
|
|
91
|
+
max_tokens=inference_cfg.max_tokens,
|
|
92
|
+
stream=stream,
|
|
93
|
+
model=inference_cfg.model,
|
|
94
|
+
temperature=inference_cfg.temperature,
|
|
95
|
+
**kwargs,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if not response.choices[0].message.content:
|
|
99
|
+
LOG.error("No response content recieved from %s", inference_cfg.url)
|
|
100
|
+
raise RuntimeError()
|
|
101
|
+
|
|
102
|
+
message_content = response.choices[0].message.content
|
|
103
|
+
|
|
104
|
+
if response.choices[0].logprobs and response.choices[0].logprobs.content:
|
|
105
|
+
logprobs = [e.to_dict() for e in response.choices[0].logprobs.content]
|
|
106
|
+
else:
|
|
107
|
+
logprobs = None
|
|
108
|
+
|
|
109
|
+
return Explanation(
|
|
110
|
+
text=message_content,
|
|
111
|
+
logprobs=logprobs,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@backoff.on_exception(
|
|
116
|
+
lambda: backoff.constant([10, 30, 120]),
|
|
117
|
+
aiohttp.ClientResponseError,
|
|
118
|
+
max_tries=4, # 4 tries and 3 retries
|
|
119
|
+
jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
|
|
120
|
+
giveup=should_we_giveup,
|
|
121
|
+
raise_on_giveup=False,
|
|
122
|
+
on_giveup=we_give_up,
|
|
123
|
+
)
|
|
124
|
+
async def call_llm_stream(
|
|
125
|
+
messages: List[Dict[str, str]],
|
|
126
|
+
inference_cfg: InferenceConfig,
|
|
127
|
+
stream: bool = False,
|
|
128
|
+
) -> AsyncStream[ChatCompletionChunk]:
|
|
129
|
+
"""Submit prompt to LLM and recieve stream of tokens as a result.
|
|
130
|
+
inference_cfg: The configuration section from the config.json representing
|
|
131
|
+
the relevant inference server for this request.
|
|
132
|
+
"""
|
|
133
|
+
LOG.info("Analyzing the text")
|
|
134
|
+
|
|
135
|
+
LOG.info("Submitting to /v1/chat/completions endpoint")
|
|
136
|
+
|
|
137
|
+
async with inference_cfg.get_limiter():
|
|
138
|
+
response = await CLIENT.chat.completions.create(
|
|
139
|
+
messages=messages,
|
|
140
|
+
max_tokens=inference_cfg.max_tokens,
|
|
141
|
+
logprobs=inference_cfg.log_probs,
|
|
142
|
+
stream=stream,
|
|
143
|
+
model=inference_cfg.model,
|
|
144
|
+
temperature=inference_cfg.temperature,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def analyze_snippets(
|
|
151
|
+
log_summary: List[Tuple[int, str]], structured_output: dict | None = None
|
|
152
|
+
) -> List[SnippetAnalysis | RatedSnippetAnalysis]:
|
|
153
|
+
"""Submit log file snippets to the LLM and gather results"""
|
|
154
|
+
# Process snippets asynchronously
|
|
155
|
+
awaitables = [
|
|
156
|
+
call_llm(
|
|
157
|
+
prompt_to_messages(
|
|
158
|
+
PROMPT_CONFIG.snippet_prompt_template.format(s),
|
|
159
|
+
PROMPT_CONFIG.snippet_system_prompt,
|
|
160
|
+
SERVER_CONFIG.inference.system_role,
|
|
161
|
+
SERVER_CONFIG.inference.user_role,
|
|
162
|
+
),
|
|
163
|
+
inference_cfg=SERVER_CONFIG.snippet_inference,
|
|
164
|
+
structured_output=structured_output,
|
|
165
|
+
)
|
|
166
|
+
for s in log_summary
|
|
167
|
+
]
|
|
168
|
+
gathered_responses = await asyncio.gather(*awaitables)
|
|
169
|
+
analyzed_snippets = []
|
|
170
|
+
|
|
171
|
+
for response in gathered_responses:
|
|
172
|
+
if structured_output:
|
|
173
|
+
try:
|
|
174
|
+
snippet = RatedSnippetAnalysis.model_validate_json(response.text)
|
|
175
|
+
except ValidationError as ex:
|
|
176
|
+
LOG.error("Invalid data structure returned `%s`", response.text)
|
|
177
|
+
raise ex
|
|
178
|
+
else:
|
|
179
|
+
snippet = SnippetAnalysis(text=response.text)
|
|
180
|
+
analyzed_snippets.append(snippet)
|
|
181
|
+
|
|
182
|
+
return analyzed_snippets
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def perfrom_analysis(log_text: str) -> Response:
|
|
186
|
+
"""Sumbit log file snippets in aggregate to LLM and retrieve results"""
|
|
187
|
+
log_summary = mine_logs(log_text)
|
|
188
|
+
log_summary = format_snippets(log_summary)
|
|
189
|
+
messages = prompt_to_messages(
|
|
190
|
+
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
191
|
+
PROMPT_CONFIG.default_system_prompt,
|
|
192
|
+
SERVER_CONFIG.inference.system_role,
|
|
193
|
+
SERVER_CONFIG.inference.user_role,
|
|
194
|
+
)
|
|
195
|
+
response = await call_llm(
|
|
196
|
+
messages,
|
|
197
|
+
inference_cfg=SERVER_CONFIG.inference,
|
|
198
|
+
)
|
|
199
|
+
certainty = 0
|
|
200
|
+
|
|
201
|
+
if response.logprobs is not None:
|
|
202
|
+
try:
|
|
203
|
+
certainty = compute_certainty(response.logprobs)
|
|
204
|
+
except ValueError as ex:
|
|
205
|
+
LOG.error("Error encountered while computing certainty: %s", ex)
|
|
206
|
+
raise HTTPException(
|
|
207
|
+
status_code=400,
|
|
208
|
+
detail=f"Couldn't compute certainty with data:\n{response.logprobs}",
|
|
209
|
+
) from ex
|
|
210
|
+
|
|
211
|
+
return Response(explanation=response, response_certainty=certainty)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def perform_analyis_stream(log_text: str) -> AsyncStream:
|
|
215
|
+
"""Submit log file snippets in aggregate and return a stream of tokens"""
|
|
216
|
+
log_summary = mine_logs(log_text)
|
|
217
|
+
log_summary = format_snippets(log_summary)
|
|
218
|
+
messages = prompt_to_messages(
|
|
219
|
+
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
220
|
+
PROMPT_CONFIG.default_system_prompt,
|
|
221
|
+
SERVER_CONFIG.inference.system_role,
|
|
222
|
+
SERVER_CONFIG.inference.user_role,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
stream = call_llm_stream(
|
|
226
|
+
messages,
|
|
227
|
+
inference_cfg=SERVER_CONFIG.inference,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# we need to figure out a better response here, this is how it looks rn:
|
|
231
|
+
# b'data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],
|
|
232
|
+
# "created":1744818071,"id":"chatcmpl-c9geTxNcQO7M9wR...
|
|
233
|
+
return stream
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def perform_staged_analysis(log_text: str) -> StagedResponse:
|
|
237
|
+
"""Submit the log file snippets to the LLM and retrieve their results"""
|
|
238
|
+
log_summary = mine_logs(log_text)
|
|
239
|
+
|
|
240
|
+
if SERVER_CONFIG.general.top_k_snippets:
|
|
241
|
+
rated_snippets = await analyze_snippets(
|
|
242
|
+
log_summary=log_summary,
|
|
243
|
+
structured_output=RatedSnippetAnalysis.model_json_schema(),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Extract original text and line number from `log_summary`
|
|
247
|
+
processed_snippets = [
|
|
248
|
+
AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
|
|
249
|
+
for e in zip(log_summary, rated_snippets)
|
|
250
|
+
]
|
|
251
|
+
processed_snippets = filter_snippets(
|
|
252
|
+
processed_snippets=processed_snippets,
|
|
253
|
+
top_k=SERVER_CONFIG.general.top_k_snippets,
|
|
254
|
+
)
|
|
255
|
+
LOG.info(
|
|
256
|
+
"Keeping %d of original %d snippets",
|
|
257
|
+
len(processed_snippets),
|
|
258
|
+
len(rated_snippets),
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
processed_snippets = await analyze_snippets(log_summary=log_summary)
|
|
262
|
+
|
|
263
|
+
# Extract original text and line number from `log_summary`
|
|
264
|
+
processed_snippets = [
|
|
265
|
+
AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
|
|
266
|
+
for e in zip(log_summary, processed_snippets)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
|
|
270
|
+
format_analyzed_snippets(processed_snippets)
|
|
271
|
+
)
|
|
272
|
+
messages = prompt_to_messages(
|
|
273
|
+
final_prompt,
|
|
274
|
+
PROMPT_CONFIG.staged_system_prompt,
|
|
275
|
+
SERVER_CONFIG.inference.system_role,
|
|
276
|
+
SERVER_CONFIG.inference.user_role,
|
|
277
|
+
)
|
|
278
|
+
final_analysis = await call_llm(
|
|
279
|
+
messages,
|
|
280
|
+
inference_cfg=SERVER_CONFIG.inference,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
certainty = 0
|
|
284
|
+
|
|
285
|
+
if final_analysis.logprobs:
|
|
286
|
+
try:
|
|
287
|
+
certainty = compute_certainty(final_analysis.logprobs)
|
|
288
|
+
except ValueError as ex:
|
|
289
|
+
LOG.error("Error encountered while computing certainty: %s", ex)
|
|
290
|
+
raise HTTPException(
|
|
291
|
+
status_code=400,
|
|
292
|
+
detail=f"Couldn't compute certainty with data:\n"
|
|
293
|
+
f"{final_analysis.logprobs}",
|
|
294
|
+
) from ex
|
|
295
|
+
|
|
296
|
+
return StagedResponse(
|
|
297
|
+
explanation=final_analysis,
|
|
298
|
+
snippets=processed_snippets,
|
|
299
|
+
response_certainty=certainty,
|
|
300
|
+
)
|
|
@@ -88,6 +88,24 @@ class EmojiHook(BaseModel):
|
|
|
88
88
|
merge_request: EmojiMergeRequest = Field(default=None)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
class SnippetAnalysis(BaseModel):
|
|
92
|
+
"""Model of snippet analysis from LLM."""
|
|
93
|
+
|
|
94
|
+
text: str = Field(description="Analysis of log snippet contents.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RatedSnippetAnalysis(SnippetAnalysis):
|
|
98
|
+
"""Model for rated snippet analysis. This model is used to generate
|
|
99
|
+
json schema for inference with structured output."""
|
|
100
|
+
|
|
101
|
+
relevance: int = Field(
|
|
102
|
+
ge=0,
|
|
103
|
+
le=100,
|
|
104
|
+
description="Estimate of likelyhood that snippet contains an error, "
|
|
105
|
+
"with 0 standing for completely unlikely, 100 for absolutely certain.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
91
109
|
class Explanation(BaseModel):
|
|
92
110
|
"""Model of snippet or general log explanation from Log Detective"""
|
|
93
111
|
|
|
@@ -95,6 +113,7 @@ class Explanation(BaseModel):
|
|
|
95
113
|
logprobs: Optional[List[Dict]] = None
|
|
96
114
|
|
|
97
115
|
def __str__(self):
|
|
116
|
+
"""Return text of the Explanation"""
|
|
98
117
|
return self.text
|
|
99
118
|
|
|
100
119
|
|
|
@@ -106,7 +125,7 @@ class AnalyzedSnippet(BaseModel):
|
|
|
106
125
|
line_number: location of snippet in original log
|
|
107
126
|
"""
|
|
108
127
|
|
|
109
|
-
explanation:
|
|
128
|
+
explanation: SnippetAnalysis | RatedSnippetAnalysis
|
|
110
129
|
text: str
|
|
111
130
|
line_number: int
|
|
112
131
|
|
|
@@ -228,6 +247,7 @@ class ExtractorConfig(BaseModel):
|
|
|
228
247
|
context: bool = True
|
|
229
248
|
max_clusters: int = 8
|
|
230
249
|
verbose: bool = False
|
|
250
|
+
max_snippet_len: int = 2000
|
|
231
251
|
|
|
232
252
|
def __init__(self, data: Optional[dict] = None):
|
|
233
253
|
super().__init__()
|
|
@@ -237,6 +257,7 @@ class ExtractorConfig(BaseModel):
|
|
|
237
257
|
self.context = data.get("context", True)
|
|
238
258
|
self.max_clusters = data.get("max_clusters", 8)
|
|
239
259
|
self.verbose = data.get("verbose", False)
|
|
260
|
+
self.max_snippet_len = data.get("max_snippet_len", 2000)
|
|
240
261
|
|
|
241
262
|
|
|
242
263
|
class GitLabInstanceConfig(BaseModel): # pylint: disable=too-many-instance-attributes
|
|
@@ -439,6 +460,7 @@ class GeneralConfig(BaseModel):
|
|
|
439
460
|
devmode: bool = False
|
|
440
461
|
sentry_dsn: HttpUrl | None = None
|
|
441
462
|
collect_emojis_interval: int = 60 * 60 # seconds
|
|
463
|
+
top_k_snippets: int = 0
|
|
442
464
|
|
|
443
465
|
def __init__(self, data: Optional[dict] = None):
|
|
444
466
|
super().__init__()
|
|
@@ -452,6 +474,7 @@ class GeneralConfig(BaseModel):
|
|
|
452
474
|
self.collect_emojis_interval = data.get(
|
|
453
475
|
"collect_emojis_interval", 60 * 60
|
|
454
476
|
) # seconds
|
|
477
|
+
self.top_k_snippets = data.get("top_k_snippets", 0)
|
|
455
478
|
|
|
456
479
|
|
|
457
480
|
class Config(BaseModel):
|
|
@@ -7,6 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
from io import BytesIO
|
|
8
8
|
|
|
9
9
|
import matplotlib
|
|
10
|
+
import matplotlib.figure
|
|
10
11
|
import matplotlib.pyplot
|
|
11
12
|
from fastapi import (
|
|
12
13
|
FastAPI,
|
|
@@ -34,21 +35,15 @@ from logdetective.server.database.models.exceptions import (
|
|
|
34
35
|
|
|
35
36
|
import logdetective.server.database.base
|
|
36
37
|
|
|
37
|
-
from logdetective.
|
|
38
|
-
compute_certainty,
|
|
39
|
-
format_snippets,
|
|
40
|
-
prompt_to_messages,
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
from logdetective.server.config import SERVER_CONFIG, PROMPT_CONFIG, LOG
|
|
38
|
+
from logdetective.server.config import SERVER_CONFIG, LOG
|
|
44
39
|
from logdetective.server.koji import (
|
|
45
40
|
get_failed_log_from_task as get_failed_log_from_koji_task,
|
|
46
41
|
)
|
|
47
42
|
from logdetective.remote_log import RemoteLog
|
|
48
43
|
from logdetective.server.llm import (
|
|
49
|
-
mine_logs,
|
|
50
44
|
perform_staged_analysis,
|
|
51
|
-
|
|
45
|
+
perfrom_analysis,
|
|
46
|
+
perform_analyis_stream,
|
|
52
47
|
)
|
|
53
48
|
from logdetective.server.gitlab import process_gitlab_job_event
|
|
54
49
|
from logdetective.server.metric import track_request, add_new_metrics, update_metrics
|
|
@@ -157,31 +152,8 @@ async def analyze_log(
|
|
|
157
152
|
"""
|
|
158
153
|
remote_log = RemoteLog(build_log.url, http_session)
|
|
159
154
|
log_text = await remote_log.process_url()
|
|
160
|
-
log_summary = mine_logs(log_text)
|
|
161
|
-
log_summary = format_snippets(log_summary)
|
|
162
|
-
messages = prompt_to_messages(
|
|
163
|
-
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
164
|
-
PROMPT_CONFIG.default_system_prompt,
|
|
165
|
-
SERVER_CONFIG.inference.system_role,
|
|
166
|
-
SERVER_CONFIG.inference.user_role,
|
|
167
|
-
)
|
|
168
|
-
response = await submit_text(
|
|
169
|
-
messages,
|
|
170
|
-
inference_cfg=SERVER_CONFIG.inference,
|
|
171
|
-
)
|
|
172
|
-
certainty = 0
|
|
173
155
|
|
|
174
|
-
|
|
175
|
-
try:
|
|
176
|
-
certainty = compute_certainty(response.logprobs)
|
|
177
|
-
except ValueError as ex:
|
|
178
|
-
LOG.error("Error encountered while computing certainty: %s", ex)
|
|
179
|
-
raise HTTPException(
|
|
180
|
-
status_code=400,
|
|
181
|
-
detail=f"Couldn't compute certainty with data:\n{response.logprobs}",
|
|
182
|
-
) from ex
|
|
183
|
-
|
|
184
|
-
return Response(explanation=response, response_certainty=certainty)
|
|
156
|
+
return await perfrom_analysis(log_text)
|
|
185
157
|
|
|
186
158
|
|
|
187
159
|
@app.post("/analyze/staged", response_model=StagedResponse)
|
|
@@ -351,9 +323,7 @@ async def analyze_koji_task(task_id: int, koji_instance_config: KojiInstanceConf
|
|
|
351
323
|
# Notify any callbacks that the analysis is complete.
|
|
352
324
|
for callback in koji_instance_config.get_callbacks(task_id):
|
|
353
325
|
LOG.info("Notifying callback %s of task %d completion", callback, task_id)
|
|
354
|
-
asyncio.create_task(
|
|
355
|
-
send_koji_callback(callback, task_id)
|
|
356
|
-
)
|
|
326
|
+
asyncio.create_task(send_koji_callback(callback, task_id))
|
|
357
327
|
|
|
358
328
|
# Now that it's sent, we can clear the callbacks for this task.
|
|
359
329
|
koji_instance_config.clear_callbacks(task_id)
|
|
@@ -398,20 +368,8 @@ async def analyze_log_stream(
|
|
|
398
368
|
"""
|
|
399
369
|
remote_log = RemoteLog(build_log.url, http_session)
|
|
400
370
|
log_text = await remote_log.process_url()
|
|
401
|
-
log_summary = mine_logs(log_text)
|
|
402
|
-
log_summary = format_snippets(log_summary)
|
|
403
|
-
messages = prompt_to_messages(
|
|
404
|
-
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
405
|
-
PROMPT_CONFIG.default_system_prompt,
|
|
406
|
-
SERVER_CONFIG.inference.system_role,
|
|
407
|
-
SERVER_CONFIG.inference.user_role,
|
|
408
|
-
)
|
|
409
371
|
try:
|
|
410
|
-
stream =
|
|
411
|
-
messages,
|
|
412
|
-
inference_cfg=SERVER_CONFIG.inference,
|
|
413
|
-
stream=True,
|
|
414
|
-
)
|
|
372
|
+
stream = perform_analyis_stream(log_text)
|
|
415
373
|
except aiohttp.ClientResponseError as ex:
|
|
416
374
|
raise HTTPException(
|
|
417
375
|
status_code=400,
|
|
@@ -419,9 +377,6 @@ async def analyze_log_stream(
|
|
|
419
377
|
f"[{ex.status}] {ex.message}",
|
|
420
378
|
) from ex
|
|
421
379
|
|
|
422
|
-
# we need to figure out a better response here, this is how it looks rn:
|
|
423
|
-
# b'data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],
|
|
424
|
-
# "created":1744818071,"id":"chatcmpl-c9geTxNcQO7M9wR...
|
|
425
380
|
return StreamingResponse(stream)
|
|
426
381
|
|
|
427
382
|
|
|
@@ -711,7 +666,7 @@ async def collect_emoji_task():
|
|
|
711
666
|
instance.url,
|
|
712
667
|
datetime.datetime.now(datetime.timezone.utc),
|
|
713
668
|
)
|
|
714
|
-
await collect_emojis(instance.get_connection(), TimePeriod(weeks=
|
|
669
|
+
await collect_emojis(instance.get_connection(), TimePeriod(weeks=54))
|
|
715
670
|
LOG.info(
|
|
716
671
|
"Collect emoji feedback finished at %s",
|
|
717
672
|
datetime.datetime.now(datetime.timezone.utc),
|
{logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/templates/gitlab_full_comment.md.j2
RENAMED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
The package {{ package }} failed to build, here is a possible explanation why.
|
|
2
2
|
|
|
3
3
|
Please know that the explanation was provided by AI and may be incorrect.
|
|
4
|
-
|
|
4
|
+
{% if certainty > 0 %}
|
|
5
|
+
In this case, we are {{ "%.2f" | format(certainty) }}% certain of the response {{ emoji_face }}.
|
|
6
|
+
{% endif %}
|
|
5
7
|
|
|
6
8
|
{{ explanation }}
|
|
7
9
|
|
|
@@ -10,7 +12,7 @@ In this case, we are {{ certainty }}% certain of the response {{ emoji_face }}.
|
|
|
10
12
|
{% for snippet in snippets %}
|
|
11
13
|
<li>
|
|
12
14
|
<b>Line {{ snippet.line_number }}:</b> <code>{{ snippet.text }}</code>
|
|
13
|
-
{{ snippet.explanation }}
|
|
15
|
+
{{ snippet.explanation.text }}
|
|
14
16
|
</li>
|
|
15
17
|
{% endfor %}
|
|
16
18
|
</ul>
|
{logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/templates/gitlab_short_comment.md.j2
RENAMED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
The package {{ package }} failed to build, here is a possible explanation why.
|
|
2
2
|
|
|
3
3
|
Please know that the explanation was provided by AI and may be incorrect.
|
|
4
|
-
|
|
4
|
+
{% if certainty > 0 %}
|
|
5
|
+
In this case, we are {{ "%.2f" | format(certainty) }}% certain of the response {{ emoji_face }}.
|
|
6
|
+
{% endif %}
|
|
5
7
|
|
|
6
8
|
{{ explanation }}
|
|
7
9
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from typing import List, Tuple
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
from fastapi import HTTPException
|
|
5
|
+
|
|
6
|
+
from logdetective.constants import SNIPPET_DELIMITER
|
|
7
|
+
from logdetective.extractors import DrainExtractor
|
|
8
|
+
from logdetective.server.config import (
|
|
9
|
+
LOG,
|
|
10
|
+
SERVER_CONFIG,
|
|
11
|
+
SKIP_SNIPPETS_CONFIG,
|
|
12
|
+
)
|
|
13
|
+
from logdetective.server.exceptions import LogDetectiveConnectionError
|
|
14
|
+
from logdetective.server.models import AnalyzedSnippet, RatedSnippetAnalysis
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_analyzed_snippets(snippets: list[AnalyzedSnippet]) -> str:
|
|
18
|
+
"""Format snippets for submission into staged prompt."""
|
|
19
|
+
summary = f"\n{SNIPPET_DELIMITER}\n".join(
|
|
20
|
+
[f"[{e.text}] at line [{e.line_number}]: [{e.explanation}]" for e in snippets]
|
|
21
|
+
)
|
|
22
|
+
return summary
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mine_logs(log: str) -> List[Tuple[int, str]]:
|
|
26
|
+
"""Extract snippets from log text"""
|
|
27
|
+
extractor = DrainExtractor(
|
|
28
|
+
verbose=True,
|
|
29
|
+
context=True,
|
|
30
|
+
max_clusters=SERVER_CONFIG.extractor.max_clusters,
|
|
31
|
+
skip_snippets=SKIP_SNIPPETS_CONFIG,
|
|
32
|
+
max_snippet_len=SERVER_CONFIG.extractor.max_snippet_len
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
LOG.info("Getting summary")
|
|
36
|
+
log_summary = extractor(log)
|
|
37
|
+
|
|
38
|
+
ratio = len(log_summary) / len(log.split("\n"))
|
|
39
|
+
LOG.debug("Log summary: \n %s", log_summary)
|
|
40
|
+
LOG.info("Compression ratio: %s", ratio)
|
|
41
|
+
|
|
42
|
+
return log_summary
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def connection_error_giveup(details: dict) -> None:
|
|
46
|
+
"""Too many connection errors, give up.
|
|
47
|
+
"""
|
|
48
|
+
LOG.error("Too many connection errors, giving up. %s", details["exception"])
|
|
49
|
+
raise LogDetectiveConnectionError() from details["exception"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def should_we_giveup(exc: aiohttp.ClientResponseError) -> bool:
|
|
53
|
+
"""From backoff's docs:
|
|
54
|
+
|
|
55
|
+
> a function which accepts the exception and returns
|
|
56
|
+
> a truthy value if the exception should not be retried
|
|
57
|
+
"""
|
|
58
|
+
LOG.info("Should we give up on retrying error %s", exc)
|
|
59
|
+
return exc.status < 400
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def we_give_up(details: dict):
|
|
63
|
+
"""Retries didn't work (or we got a different exc)
|
|
64
|
+
we give up and raise proper 500 for our API endpoint
|
|
65
|
+
"""
|
|
66
|
+
LOG.error("Last exception: %s", details["exception"])
|
|
67
|
+
LOG.error("Inference error: %s", details["args"])
|
|
68
|
+
raise HTTPException(500, "Request to the inference API failed")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def select_relevance(snippet: AnalyzedSnippet) -> float:
|
|
72
|
+
"""Retrieve relevance value from structure, if there is one."""
|
|
73
|
+
if not isinstance(snippet.explanation, RatedSnippetAnalysis):
|
|
74
|
+
LOG.exception("Only rated snippets can be ordered by relevance.")
|
|
75
|
+
raise ValueError
|
|
76
|
+
return snippet.explanation.relevance
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def select_line_number(explanation: AnalyzedSnippet) -> int:
|
|
80
|
+
"""Returns line number of original snippet."""
|
|
81
|
+
return explanation.line_number
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def filter_snippets(
|
|
85
|
+
processed_snippets: List[AnalyzedSnippet], top_k: int
|
|
86
|
+
) -> List[AnalyzedSnippet]:
|
|
87
|
+
"""Filter snippets according to criteria in config while keeping them ordered by line number.
|
|
88
|
+
If all snippets recieved the same score, return them all.
|
|
89
|
+
AnalyzedSnippet objects must have `explanation` attribute set to `RatedSnippetAnalysis`,
|
|
90
|
+
otherwise raise `ValueError`."""
|
|
91
|
+
|
|
92
|
+
if top_k >= len(processed_snippets):
|
|
93
|
+
LOG.warning(
|
|
94
|
+
"The `top-k` parameter >= number of original snippets, skipping filtering."
|
|
95
|
+
)
|
|
96
|
+
return processed_snippets
|
|
97
|
+
|
|
98
|
+
# Sorting invokes `select_relevance` which also tests if objects actually
|
|
99
|
+
# have the score assigned. Otherwise it raises exception.
|
|
100
|
+
processed_snippets = sorted(processed_snippets, key=select_relevance, reverse=True)
|
|
101
|
+
|
|
102
|
+
# Check for failure mode when all snippets have
|
|
103
|
+
# the same relevance. In such cases there is no point in filtering
|
|
104
|
+
# and all snippets are returned.
|
|
105
|
+
max_relevance = processed_snippets[0].explanation.relevance
|
|
106
|
+
min_relevance = processed_snippets[-1].explanation.relevance
|
|
107
|
+
|
|
108
|
+
LOG.info(
|
|
109
|
+
"Analyzed snippets sorted. Max relevance: %d Min relevance: %e",
|
|
110
|
+
max_relevance,
|
|
111
|
+
min_relevance,
|
|
112
|
+
)
|
|
113
|
+
if max_relevance == min_relevance:
|
|
114
|
+
LOG.warning("All snippets recieved the same rating. Filtering disabled.")
|
|
115
|
+
return processed_snippets
|
|
116
|
+
|
|
117
|
+
processed_snippets = processed_snippets[:top_k]
|
|
118
|
+
|
|
119
|
+
# Re-sorting snippets by line number
|
|
120
|
+
processed_snippets = sorted(processed_snippets, key=select_line_number)
|
|
121
|
+
|
|
122
|
+
return processed_snippets
|
|
@@ -39,7 +39,7 @@ def chunk_continues(text: str, index: int) -> bool:
|
|
|
39
39
|
return False
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def get_chunks(text: str) -> Generator[Tuple[int, str], None, None]:
|
|
42
|
+
def get_chunks(text: str, max_len: int = 2000) -> Generator[Tuple[int, str], None, None]:
|
|
43
43
|
"""Split log into chunks according to heuristic
|
|
44
44
|
based on whitespace and backslash presence.
|
|
45
45
|
"""
|
|
@@ -54,7 +54,7 @@ def get_chunks(text: str) -> Generator[Tuple[int, str], None, None]:
|
|
|
54
54
|
chunk += text[i]
|
|
55
55
|
if text[i] == "\n":
|
|
56
56
|
next_line_number += 1
|
|
57
|
-
if i + 1 < text_len and chunk_continues(text, i):
|
|
57
|
+
if i + 1 < text_len and chunk_continues(text, i) and i + 1 < max_len:
|
|
58
58
|
i += 1
|
|
59
59
|
continue
|
|
60
60
|
yield (original_line_number, chunk)
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import asyncio
|
|
3
|
-
import random
|
|
4
|
-
from typing import List, Tuple, Union, Dict
|
|
5
|
-
|
|
6
|
-
import backoff
|
|
7
|
-
from fastapi import HTTPException
|
|
8
|
-
|
|
9
|
-
import aiohttp
|
|
10
|
-
from openai import AsyncStream
|
|
11
|
-
from openai.types.chat import ChatCompletionChunk
|
|
12
|
-
|
|
13
|
-
from logdetective.constants import SNIPPET_DELIMITER
|
|
14
|
-
from logdetective.extractors import DrainExtractor
|
|
15
|
-
from logdetective.utils import (
|
|
16
|
-
compute_certainty,
|
|
17
|
-
prompt_to_messages,
|
|
18
|
-
)
|
|
19
|
-
from logdetective.server.config import (
|
|
20
|
-
LOG,
|
|
21
|
-
SERVER_CONFIG,
|
|
22
|
-
PROMPT_CONFIG,
|
|
23
|
-
CLIENT,
|
|
24
|
-
SKIP_SNIPPETS_CONFIG,
|
|
25
|
-
)
|
|
26
|
-
from logdetective.server.models import (
|
|
27
|
-
AnalyzedSnippet,
|
|
28
|
-
InferenceConfig,
|
|
29
|
-
Explanation,
|
|
30
|
-
StagedResponse,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def format_analyzed_snippets(snippets: list[AnalyzedSnippet]) -> str:
|
|
38
|
-
"""Format snippets for submission into staged prompt."""
|
|
39
|
-
summary = f"\n{SNIPPET_DELIMITER}\n".join(
|
|
40
|
-
[
|
|
41
|
-
f"[{e.text}] at line [{e.line_number}]: [{e.explanation.text}]"
|
|
42
|
-
for e in snippets
|
|
43
|
-
]
|
|
44
|
-
)
|
|
45
|
-
return summary
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def mine_logs(log: str) -> List[Tuple[int, str]]:
|
|
49
|
-
"""Extract snippets from log text"""
|
|
50
|
-
extractor = DrainExtractor(
|
|
51
|
-
verbose=True,
|
|
52
|
-
context=True,
|
|
53
|
-
max_clusters=SERVER_CONFIG.extractor.max_clusters,
|
|
54
|
-
skip_snippets=SKIP_SNIPPETS_CONFIG,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
LOG.info("Getting summary")
|
|
58
|
-
log_summary = extractor(log)
|
|
59
|
-
|
|
60
|
-
ratio = len(log_summary) / len(log.split("\n"))
|
|
61
|
-
LOG.debug("Log summary: \n %s", log_summary)
|
|
62
|
-
LOG.info("Compression ratio: %s", ratio)
|
|
63
|
-
|
|
64
|
-
return log_summary
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def should_we_giveup(exc: aiohttp.ClientResponseError) -> bool:
|
|
68
|
-
"""
|
|
69
|
-
From backoff's docs:
|
|
70
|
-
|
|
71
|
-
> a function which accepts the exception and returns
|
|
72
|
-
> a truthy value if the exception should not be retried
|
|
73
|
-
"""
|
|
74
|
-
LOG.info("Should we give up on retrying error %s", exc)
|
|
75
|
-
return exc.status < 400
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def we_give_up(details: backoff._typing.Details):
|
|
79
|
-
"""
|
|
80
|
-
retries didn't work (or we got a different exc)
|
|
81
|
-
we give up and raise proper 500 for our API endpoint
|
|
82
|
-
"""
|
|
83
|
-
LOG.error("Last exception: %s", details["exception"])
|
|
84
|
-
LOG.error("Inference error: %s", details["args"])
|
|
85
|
-
raise HTTPException(500, "Request to the inference API failed")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@backoff.on_exception(
|
|
89
|
-
lambda: backoff.constant([10, 30, 120]),
|
|
90
|
-
aiohttp.ClientResponseError,
|
|
91
|
-
max_tries=4, # 4 tries and 3 retries
|
|
92
|
-
jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
|
|
93
|
-
giveup=should_we_giveup,
|
|
94
|
-
raise_on_giveup=False,
|
|
95
|
-
on_giveup=we_give_up,
|
|
96
|
-
)
|
|
97
|
-
async def submit_text(
|
|
98
|
-
messages: List[Dict[str, str]],
|
|
99
|
-
inference_cfg: InferenceConfig,
|
|
100
|
-
stream: bool = False,
|
|
101
|
-
) -> Union[Explanation, AsyncStream[ChatCompletionChunk]]:
|
|
102
|
-
"""Submit prompt to LLM.
|
|
103
|
-
inference_cfg: The configuration section from the config.json representing
|
|
104
|
-
the relevant inference server for this request.
|
|
105
|
-
log_probs: number of token choices to produce log probs for
|
|
106
|
-
"""
|
|
107
|
-
LOG.info("Analyzing the text")
|
|
108
|
-
|
|
109
|
-
LOG.info("Submitting to /v1/chat/completions endpoint")
|
|
110
|
-
|
|
111
|
-
async with inference_cfg.get_limiter():
|
|
112
|
-
response = await CLIENT.chat.completions.create(
|
|
113
|
-
messages=messages,
|
|
114
|
-
max_tokens=inference_cfg.max_tokens,
|
|
115
|
-
logprobs=inference_cfg.log_probs,
|
|
116
|
-
stream=stream,
|
|
117
|
-
model=inference_cfg.model,
|
|
118
|
-
temperature=inference_cfg.temperature,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
if isinstance(response, AsyncStream):
|
|
122
|
-
return response
|
|
123
|
-
if not response.choices[0].message.content:
|
|
124
|
-
LOG.error("No response content recieved from %s", inference_cfg.url)
|
|
125
|
-
raise RuntimeError()
|
|
126
|
-
if response.choices[0].logprobs and response.choices[0].logprobs.content:
|
|
127
|
-
logprobs = [e.to_dict() for e in response.choices[0].logprobs.content]
|
|
128
|
-
else:
|
|
129
|
-
logprobs = None
|
|
130
|
-
|
|
131
|
-
return Explanation(
|
|
132
|
-
text=response.choices[0].message.content,
|
|
133
|
-
logprobs=logprobs,
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
async def perform_staged_analysis(log_text: str) -> StagedResponse:
|
|
138
|
-
"""Submit the log file snippets to the LLM and retrieve their results"""
|
|
139
|
-
log_summary = mine_logs(log_text)
|
|
140
|
-
|
|
141
|
-
# Process snippets asynchronously
|
|
142
|
-
awaitables = [
|
|
143
|
-
submit_text(
|
|
144
|
-
prompt_to_messages(
|
|
145
|
-
PROMPT_CONFIG.snippet_prompt_template.format(s),
|
|
146
|
-
PROMPT_CONFIG.snippet_system_prompt,
|
|
147
|
-
SERVER_CONFIG.inference.system_role,
|
|
148
|
-
SERVER_CONFIG.inference.user_role,
|
|
149
|
-
),
|
|
150
|
-
inference_cfg=SERVER_CONFIG.snippet_inference,
|
|
151
|
-
)
|
|
152
|
-
for s in log_summary
|
|
153
|
-
]
|
|
154
|
-
analyzed_snippets = await asyncio.gather(*awaitables)
|
|
155
|
-
|
|
156
|
-
analyzed_snippets = [
|
|
157
|
-
AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
|
|
158
|
-
for e in zip(log_summary, analyzed_snippets)
|
|
159
|
-
]
|
|
160
|
-
final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
|
|
161
|
-
format_analyzed_snippets(analyzed_snippets)
|
|
162
|
-
)
|
|
163
|
-
messages = prompt_to_messages(
|
|
164
|
-
final_prompt,
|
|
165
|
-
PROMPT_CONFIG.staged_system_prompt,
|
|
166
|
-
SERVER_CONFIG.inference.system_role,
|
|
167
|
-
SERVER_CONFIG.inference.user_role,
|
|
168
|
-
)
|
|
169
|
-
final_analysis = await submit_text(
|
|
170
|
-
messages,
|
|
171
|
-
inference_cfg=SERVER_CONFIG.inference,
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
certainty = 0
|
|
175
|
-
|
|
176
|
-
if final_analysis.logprobs:
|
|
177
|
-
try:
|
|
178
|
-
certainty = compute_certainty(final_analysis.logprobs)
|
|
179
|
-
except ValueError as ex:
|
|
180
|
-
LOG.error("Error encountered while computing certainty: %s", ex)
|
|
181
|
-
raise HTTPException(
|
|
182
|
-
status_code=400,
|
|
183
|
-
detail=f"Couldn't compute certainty with data:\n"
|
|
184
|
-
f"{final_analysis.logprobs}",
|
|
185
|
-
) from ex
|
|
186
|
-
|
|
187
|
-
return StagedResponse(
|
|
188
|
-
explanation=final_analysis,
|
|
189
|
-
snippets=analyzed_snippets,
|
|
190
|
-
response_certainty=certainty,
|
|
191
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{logdetective-1.9.0 → logdetective-2.0.1}/logdetective/server/database/models/merge_request_jobs.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|