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.
@@ -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:
@@ -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=f"{response.response_certainty:.2f}",
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
  """
@@ -1,90 +1,48 @@
1
1
  import os
2
2
  import asyncio
3
3
  import random
4
- from typing import List, Tuple, Union, Dict
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 submit_text(
55
+ async def call_llm(
98
56
  messages: List[Dict[str, str]],
99
57
  inference_cfg: InferenceConfig,
100
58
  stream: bool = False,
101
- ) -> Union[Explanation, AsyncStream[ChatCompletionChunk]]:
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=response.choices[0].message.content,
110
+ text=message_content,
133
111
  logprobs=logprobs,
134
112
  )
135
113
 
136
114
 
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)
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
- submit_text(
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
- analyzed_snippets = await asyncio.gather(*awaitables)
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(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 submit_text(
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=analyzed_snippets,
298
+ snippets=processed_snippets,
190
299
  response_certainty=certainty,
191
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: 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.utils import (
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
- submit_text,
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
- if response.logprobs is not None:
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 = submit_text(
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="54"))
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
- In this case, we are {{ certainty }}% certain of the response {{ emoji_face }}.
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
- In this case, we are {{ certainty }}% certain of the response {{ emoji_face }}.
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: 1.9.0
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=BkQe7FMLDoKVWitP85Vpv1qle1Fo1FeupKm0wVlcALI,1859
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=dOqaFrtBOkFRHLWte_2tGV-pNXwXP9Km9iWno_TZyic,3863
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=xTGKDZnEZay7TMumeVFJ4M5lE6LDLBwCtz34OZRfIhk,16431
24
- logdetective/server/koji.py,sha256=_tZRaY9IRIzQsEk6XMRcsO4Bz0tJq3PgCB-ATywvWIU,5860
25
- logdetective/server/llm.py,sha256=EiLp3QV3OAvZcqrq6t21M0vzHFiPuVMamRLWPggqTEo,5829
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=VIwVfcXD7wq4aRorT_k8dTmJg5pVi4kUVbEvO1QUMKM,18531
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=texHf-3HYcdMcERMbkgR8xq9R2PN-kU4E6qtj3Kvx3U,26002
30
- logdetective/server/templates/gitlab_full_comment.md.j2,sha256=2_TGQPYZFgd5r-rY08kAnKbeePBynCYWbCojbIy44Go,1890
31
- logdetective/server/templates/gitlab_short_comment.md.j2,sha256=b0dCNmEOLEcKLKufF9g7ftrjvGVotTdMBZsw2SVnrec,1706
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=UAhPWbOGdTR7PWc1dEQk8FSxBSsO0UgfdyY8AKGfMJY,7781
34
- logdetective-1.9.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
35
- logdetective-1.9.0.dist-info/METADATA,sha256=ljBz487GxQlZKt-9xq64KD_R5wikXM--9kbPR2lVwE0,18974
36
- logdetective-1.9.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
37
- logdetective-1.9.0.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
38
- logdetective-1.9.0.dist-info/RECORD,,
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,,