logdetective 1.9.0__py3-none-any.whl → 2.0.1__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/extractors.py +4 -2
- logdetective/prompts.yml +2 -2
- logdetective/server/gitlab.py +1 -1
- logdetective/server/koji.py +2 -10
- logdetective/server/llm.py +183 -74
- logdetective/server/models.py +24 -1
- logdetective/server/server.py +8 -53
- logdetective/server/templates/gitlab_full_comment.md.j2 +4 -2
- logdetective/server/templates/gitlab_short_comment.md.j2 +3 -1
- logdetective/server/utils.py +122 -0
- logdetective/utils.py +2 -2
- {logdetective-1.9.0.dist-info → logdetective-2.0.1.dist-info}/METADATA +28 -3
- {logdetective-1.9.0.dist-info → logdetective-2.0.1.dist-info}/RECORD +16 -15
- {logdetective-1.9.0.dist-info → logdetective-2.0.1.dist-info}/LICENSE +0 -0
- {logdetective-1.9.0.dist-info → logdetective-2.0.1.dist-info}/WHEEL +0 -0
- {logdetective-1.9.0.dist-info → logdetective-2.0.1.dist-info}/entry_points.txt +0 -0
logdetective/extractors.py
CHANGED
|
@@ -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)
|
logdetective/prompts.yml
CHANGED
|
@@ -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:
|
logdetective/server/gitlab.py
CHANGED
|
@@ -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,
|
logdetective/server/koji.py
CHANGED
|
@@ -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
|
"""
|
logdetective/server/llm.py
CHANGED
|
@@ -1,90 +1,48 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import asyncio
|
|
3
3
|
import random
|
|
4
|
-
from typing import List, Tuple,
|
|
4
|
+
from typing import List, Tuple, Dict
|
|
5
5
|
|
|
6
6
|
import backoff
|
|
7
7
|
from fastapi import HTTPException
|
|
8
|
+
from pydantic import ValidationError
|
|
8
9
|
|
|
9
10
|
import aiohttp
|
|
10
11
|
from openai import AsyncStream
|
|
11
12
|
from openai.types.chat import ChatCompletionChunk
|
|
12
13
|
|
|
13
|
-
from logdetective.constants import SNIPPET_DELIMITER
|
|
14
|
-
from logdetective.extractors import DrainExtractor
|
|
15
14
|
from logdetective.utils import (
|
|
16
15
|
compute_certainty,
|
|
17
16
|
prompt_to_messages,
|
|
17
|
+
format_snippets,
|
|
18
18
|
)
|
|
19
19
|
from logdetective.server.config import (
|
|
20
20
|
LOG,
|
|
21
21
|
SERVER_CONFIG,
|
|
22
22
|
PROMPT_CONFIG,
|
|
23
23
|
CLIENT,
|
|
24
|
-
SKIP_SNIPPETS_CONFIG,
|
|
25
24
|
)
|
|
26
25
|
from logdetective.server.models import (
|
|
27
26
|
AnalyzedSnippet,
|
|
28
27
|
InferenceConfig,
|
|
29
28
|
Explanation,
|
|
30
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,
|
|
31
40
|
)
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
|
|
35
44
|
|
|
36
45
|
|
|
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
46
|
@backoff.on_exception(
|
|
89
47
|
lambda: backoff.constant([10, 30, 120]),
|
|
90
48
|
aiohttp.ClientResponseError,
|
|
@@ -94,53 +52,108 @@ def we_give_up(details: backoff._typing.Details):
|
|
|
94
52
|
raise_on_giveup=False,
|
|
95
53
|
on_giveup=we_give_up,
|
|
96
54
|
)
|
|
97
|
-
async def
|
|
55
|
+
async def call_llm(
|
|
98
56
|
messages: List[Dict[str, str]],
|
|
99
57
|
inference_cfg: InferenceConfig,
|
|
100
58
|
stream: bool = False,
|
|
101
|
-
|
|
59
|
+
structured_output: dict | None = None,
|
|
60
|
+
) -> Explanation:
|
|
102
61
|
"""Submit prompt to LLM.
|
|
103
62
|
inference_cfg: The configuration section from the config.json representing
|
|
104
63
|
the relevant inference server for this request.
|
|
105
|
-
log_probs: number of token choices to produce log probs for
|
|
106
64
|
"""
|
|
107
65
|
LOG.info("Analyzing the text")
|
|
108
66
|
|
|
109
67
|
LOG.info("Submitting to /v1/chat/completions endpoint")
|
|
110
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
|
+
|
|
111
88
|
async with inference_cfg.get_limiter():
|
|
112
89
|
response = await CLIENT.chat.completions.create(
|
|
113
90
|
messages=messages,
|
|
114
91
|
max_tokens=inference_cfg.max_tokens,
|
|
115
|
-
logprobs=inference_cfg.log_probs,
|
|
116
92
|
stream=stream,
|
|
117
93
|
model=inference_cfg.model,
|
|
118
94
|
temperature=inference_cfg.temperature,
|
|
95
|
+
**kwargs,
|
|
119
96
|
)
|
|
120
97
|
|
|
121
|
-
if isinstance(response, AsyncStream):
|
|
122
|
-
return response
|
|
123
98
|
if not response.choices[0].message.content:
|
|
124
99
|
LOG.error("No response content recieved from %s", inference_cfg.url)
|
|
125
100
|
raise RuntimeError()
|
|
101
|
+
|
|
102
|
+
message_content = response.choices[0].message.content
|
|
103
|
+
|
|
126
104
|
if response.choices[0].logprobs and response.choices[0].logprobs.content:
|
|
127
105
|
logprobs = [e.to_dict() for e in response.choices[0].logprobs.content]
|
|
128
106
|
else:
|
|
129
107
|
logprobs = None
|
|
130
108
|
|
|
131
109
|
return Explanation(
|
|
132
|
-
text=
|
|
110
|
+
text=message_content,
|
|
133
111
|
logprobs=logprobs,
|
|
134
112
|
)
|
|
135
113
|
|
|
136
114
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
140
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"""
|
|
141
154
|
# Process snippets asynchronously
|
|
142
155
|
awaitables = [
|
|
143
|
-
|
|
156
|
+
call_llm(
|
|
144
157
|
prompt_to_messages(
|
|
145
158
|
PROMPT_CONFIG.snippet_prompt_template.format(s),
|
|
146
159
|
PROMPT_CONFIG.snippet_system_prompt,
|
|
@@ -148,17 +161,113 @@ async def perform_staged_analysis(log_text: str) -> StagedResponse:
|
|
|
148
161
|
SERVER_CONFIG.inference.user_role,
|
|
149
162
|
),
|
|
150
163
|
inference_cfg=SERVER_CONFIG.snippet_inference,
|
|
164
|
+
structured_output=structured_output,
|
|
151
165
|
)
|
|
152
166
|
for s in log_summary
|
|
153
167
|
]
|
|
154
|
-
|
|
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
|
+
]
|
|
155
268
|
|
|
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
269
|
final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
|
|
161
|
-
format_analyzed_snippets(
|
|
270
|
+
format_analyzed_snippets(processed_snippets)
|
|
162
271
|
)
|
|
163
272
|
messages = prompt_to_messages(
|
|
164
273
|
final_prompt,
|
|
@@ -166,7 +275,7 @@ async def perform_staged_analysis(log_text: str) -> StagedResponse:
|
|
|
166
275
|
SERVER_CONFIG.inference.system_role,
|
|
167
276
|
SERVER_CONFIG.inference.user_role,
|
|
168
277
|
)
|
|
169
|
-
final_analysis = await
|
|
278
|
+
final_analysis = await call_llm(
|
|
170
279
|
messages,
|
|
171
280
|
inference_cfg=SERVER_CONFIG.inference,
|
|
172
281
|
)
|
|
@@ -186,6 +295,6 @@ async def perform_staged_analysis(log_text: str) -> StagedResponse:
|
|
|
186
295
|
|
|
187
296
|
return StagedResponse(
|
|
188
297
|
explanation=final_analysis,
|
|
189
|
-
snippets=
|
|
298
|
+
snippets=processed_snippets,
|
|
190
299
|
response_certainty=certainty,
|
|
191
300
|
)
|
logdetective/server/models.py
CHANGED
|
@@ -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):
|
logdetective/server/server.py
CHANGED
|
@@ -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),
|
|
@@ -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>
|
|
@@ -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
|
logdetective/utils.py
CHANGED
|
@@ -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,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
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
logdetective/__init__.py,sha256=VqRngDcuFT7JWms8Qc_MsOvajoXVOKPr-S1kqY3Pqhc,59
|
|
2
2
|
logdetective/constants.py,sha256=aCwrkBrDdS_kbNESK-Z-ewg--DSzodV2OMgwEq3UE38,2456
|
|
3
3
|
logdetective/drain3.ini,sha256=ni91eCT1TwTznZwcqWoOVMQcGEnWhEDNCoTPF7cfGfY,1360
|
|
4
|
-
logdetective/extractors.py,sha256=
|
|
4
|
+
logdetective/extractors.py,sha256=gRiEcS1M_0Otu9zzT-tGm2ez3DoCKHYrQwJXZAZS0lk,1995
|
|
5
5
|
logdetective/logdetective.py,sha256=DECG4qnmYHlCcQ5Waj3Esr4wSb6LtM4m7qqtmZqYDX0,6151
|
|
6
6
|
logdetective/models.py,sha256=h01nagxgb8sR9Js_00DMoZv6GvwHjcOk0MeKttftDHk,2460
|
|
7
7
|
logdetective/prompts-summary-first.yml,sha256=3Zfp4NNOfaFYq5xBlBjeQa5PdjYfS4v17OtJqQ-DRpU,821
|
|
8
8
|
logdetective/prompts-summary-only.yml,sha256=8U9AMJV8ePW-0CoXOXlQoO92DAJDeutIT8ntSkkm6W0,470
|
|
9
|
-
logdetective/prompts.yml,sha256=
|
|
9
|
+
logdetective/prompts.yml,sha256=Mq8RdWgJxxhrQYgammojJkXULJNpzSLU0N_BryOxKgc,3906
|
|
10
10
|
logdetective/remote_log.py,sha256=28QvdQiy7RBnd86EKCq_A75P21gSNlCbgxJe5XAe9MA,2258
|
|
11
11
|
logdetective/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
12
|
logdetective/server/compressors.py,sha256=qzrT-BPSksXY6F2L6ger04GGrgdBsGOfK2YuCFRs0Q4,5427
|
|
@@ -20,19 +20,20 @@ logdetective/server/database/models/merge_request_jobs.py,sha256=q4reSC7YnEfWBPp
|
|
|
20
20
|
logdetective/server/database/models/metrics.py,sha256=_UCaizcl9w4iX54EWvk5VvXeLcg2UfnQgXg4br3OLko,14214
|
|
21
21
|
logdetective/server/emoji.py,sha256=hV4O0yfL0l1a3kWLImvBsY4AJQauKs7okYOGBEtYVz0,4795
|
|
22
22
|
logdetective/server/exceptions.py,sha256=piV7wVKc-rw_pHrThbZbUjtmjuO5qUbjVNFwjdfcP3Q,864
|
|
23
|
-
logdetective/server/gitlab.py,sha256=
|
|
24
|
-
logdetective/server/koji.py,sha256=
|
|
25
|
-
logdetective/server/llm.py,sha256=
|
|
23
|
+
logdetective/server/gitlab.py,sha256=MrAprXLTN6Q15qBC_Y2y42iKdtmIfed_pfjEt0gABvc,16422
|
|
24
|
+
logdetective/server/koji.py,sha256=LG1pRiKUFvYFRKzgQoUG3pUHfcEwMoaMNjUSMKw_pBA,5640
|
|
25
|
+
logdetective/server/llm.py,sha256=_JRbEpNHzXWy0BuwjFhhuwLdPzFi4yKikZHwoibWiek,9736
|
|
26
26
|
logdetective/server/metric.py,sha256=QrrX1FmMa7sc57av0P9UFOiCIFYVLs1opOWV3ObYo0s,4086
|
|
27
|
-
logdetective/server/models.py,sha256=
|
|
27
|
+
logdetective/server/models.py,sha256=CosUjUp-AbJC01TrBwrix-y5ZCuTIDpEo1kpLzDzrWk,19341
|
|
28
28
|
logdetective/server/plot.py,sha256=C98U9prGoPkp8_t4v2dovdZuwOhSbxXSeB_K9Q2r3NE,14607
|
|
29
|
-
logdetective/server/server.py,sha256=
|
|
30
|
-
logdetective/server/templates/gitlab_full_comment.md.j2,sha256=
|
|
31
|
-
logdetective/server/templates/gitlab_short_comment.md.j2,sha256=
|
|
29
|
+
logdetective/server/server.py,sha256=rpl5WtnesxPz_Zahy2bKN5wMIjbvdE1sM1ulvxvDiGw,24416
|
|
30
|
+
logdetective/server/templates/gitlab_full_comment.md.j2,sha256=H4NPjm3l8X5d0TNtfyZZZj_gHY1Y7hWEqY6RaVA8qt0,1947
|
|
31
|
+
logdetective/server/templates/gitlab_short_comment.md.j2,sha256=vPisU1c98LPKEwlKtMrtlqnEOlbykPZK96MpHAf-o88,1758
|
|
32
|
+
logdetective/server/utils.py,sha256=ixEXSc93Zdwy94YkExdNJ1vRCe90dNP7DQpy8XlFYRU,4288
|
|
32
33
|
logdetective/skip_snippets.yml,sha256=reGlhPPCo06nNUJWiC2LY-OJOoPdcyOB7QBTSMeh0eg,487
|
|
33
|
-
logdetective/utils.py,sha256=
|
|
34
|
-
logdetective-
|
|
35
|
-
logdetective-
|
|
36
|
-
logdetective-
|
|
37
|
-
logdetective-
|
|
38
|
-
logdetective-
|
|
34
|
+
logdetective/utils.py,sha256=Ur4EPCAeuvymFcUO10SpbCBI2tdcZ33s-fjCn9pfgUY,7822
|
|
35
|
+
logdetective-2.0.1.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
36
|
+
logdetective-2.0.1.dist-info/METADATA,sha256=yxSjPsb8Zm-ptGo0YShBwuBvMR_R-eO6cuGIJjIqIDA,20104
|
|
37
|
+
logdetective-2.0.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
38
|
+
logdetective-2.0.1.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
|
|
39
|
+
logdetective-2.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|