logdetective 0.4.0__py3-none-any.whl → 2.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. logdetective/constants.py +33 -12
  2. logdetective/extractors.py +137 -68
  3. logdetective/logdetective.py +102 -33
  4. logdetective/models.py +99 -0
  5. logdetective/prompts-summary-first.yml +20 -0
  6. logdetective/prompts-summary-only.yml +13 -0
  7. logdetective/prompts.yml +90 -0
  8. logdetective/remote_log.py +67 -0
  9. logdetective/server/compressors.py +186 -0
  10. logdetective/server/config.py +78 -0
  11. logdetective/server/database/base.py +34 -26
  12. logdetective/server/database/models/__init__.py +33 -0
  13. logdetective/server/database/models/exceptions.py +17 -0
  14. logdetective/server/database/models/koji.py +143 -0
  15. logdetective/server/database/models/merge_request_jobs.py +623 -0
  16. logdetective/server/database/models/metrics.py +427 -0
  17. logdetective/server/emoji.py +148 -0
  18. logdetective/server/exceptions.py +37 -0
  19. logdetective/server/gitlab.py +451 -0
  20. logdetective/server/koji.py +159 -0
  21. logdetective/server/llm.py +309 -0
  22. logdetective/server/metric.py +75 -30
  23. logdetective/server/models.py +426 -23
  24. logdetective/server/plot.py +432 -0
  25. logdetective/server/server.py +580 -468
  26. logdetective/server/templates/base_response.html.j2 +59 -0
  27. logdetective/server/templates/gitlab_full_comment.md.j2 +73 -0
  28. logdetective/server/templates/gitlab_short_comment.md.j2 +62 -0
  29. logdetective/server/utils.py +98 -32
  30. logdetective/skip_snippets.yml +12 -0
  31. logdetective/utils.py +187 -73
  32. logdetective-2.11.0.dist-info/METADATA +568 -0
  33. logdetective-2.11.0.dist-info/RECORD +40 -0
  34. {logdetective-0.4.0.dist-info → logdetective-2.11.0.dist-info}/WHEEL +1 -1
  35. logdetective/server/database/models.py +0 -88
  36. logdetective-0.4.0.dist-info/METADATA +0 -333
  37. logdetective-0.4.0.dist-info/RECORD +0 -19
  38. {logdetective-0.4.0.dist-info → logdetective-2.11.0.dist-info}/entry_points.txt +0 -0
  39. {logdetective-0.4.0.dist-info → logdetective-2.11.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,309 @@
1
+ import os
2
+ import asyncio
3
+ import random
4
+ import time
5
+ from typing import List, Tuple, Dict
6
+
7
+ import backoff
8
+ from fastapi import HTTPException
9
+ from pydantic import ValidationError
10
+
11
+ import aiohttp
12
+ from openai import AsyncStream
13
+ from openai.types.chat import ChatCompletionChunk
14
+
15
+ from logdetective.utils import (
16
+ compute_certainty,
17
+ prompt_to_messages,
18
+ format_snippets,
19
+ mine_logs,
20
+ )
21
+ from logdetective.server.config import (
22
+ LOG,
23
+ SERVER_CONFIG,
24
+ PROMPT_CONFIG,
25
+ CLIENT,
26
+ )
27
+ from logdetective.server.models import (
28
+ AnalyzedSnippet,
29
+ InferenceConfig,
30
+ Explanation,
31
+ StagedResponse,
32
+ SnippetAnalysis,
33
+ RatedSnippetAnalysis,
34
+ Response,
35
+ )
36
+ from logdetective.server.utils import (
37
+ format_analyzed_snippets,
38
+ should_we_giveup,
39
+ we_give_up,
40
+ filter_snippets,
41
+ construct_final_prompt,
42
+ )
43
+
44
+
45
+ LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
46
+
47
+
48
+ @backoff.on_exception(
49
+ lambda: backoff.constant([10, 30, 120]),
50
+ aiohttp.ClientResponseError,
51
+ max_tries=4, # 4 tries and 3 retries
52
+ jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
53
+ giveup=should_we_giveup,
54
+ raise_on_giveup=False,
55
+ on_giveup=we_give_up,
56
+ )
57
+ async def call_llm(
58
+ messages: List[Dict[str, str]],
59
+ inference_cfg: InferenceConfig,
60
+ stream: bool = False,
61
+ structured_output: dict | None = None,
62
+ ) -> Explanation:
63
+ """Submit prompt to LLM.
64
+ inference_cfg: The configuration section from the config.json representing
65
+ the relevant inference server for this request.
66
+ """
67
+ LOG.info("Analyzing the text")
68
+
69
+ LOG.info("Submitting to /v1/chat/completions endpoint")
70
+
71
+ kwargs = {}
72
+
73
+ # OpenAI API does not guarantee that the behavior for parameter set to `None`
74
+ # and parameter not given at all is the same.
75
+ # We build a dictionary of parameters based on the configuration.
76
+ if inference_cfg.log_probs:
77
+ LOG.info("Requesting log probabilities from LLM")
78
+ kwargs["logprobs"] = inference_cfg.log_probs
79
+ if structured_output:
80
+ LOG.info("Requesting structured output from LLM")
81
+ response_format = {
82
+ "type": "json_schema",
83
+ "json_schema": {
84
+ "name": "rated-snippet-analysis",
85
+ "schema": structured_output,
86
+ },
87
+ }
88
+ kwargs["response_format"] = response_format
89
+
90
+ async with inference_cfg.get_limiter():
91
+ response = await CLIENT.chat.completions.create(
92
+ messages=messages,
93
+ max_tokens=inference_cfg.max_tokens,
94
+ stream=stream,
95
+ model=inference_cfg.model,
96
+ temperature=inference_cfg.temperature,
97
+ **kwargs,
98
+ )
99
+
100
+ if not response.choices[0].message.content:
101
+ LOG.error("No response content recieved from %s", inference_cfg.url)
102
+ raise RuntimeError()
103
+
104
+ message_content = response.choices[0].message.content
105
+
106
+ if response.choices[0].logprobs and response.choices[0].logprobs.content:
107
+ logprobs = [e.to_dict() for e in response.choices[0].logprobs.content]
108
+ else:
109
+ logprobs = None
110
+
111
+ return Explanation(
112
+ text=message_content,
113
+ logprobs=logprobs,
114
+ )
115
+
116
+
117
+ @backoff.on_exception(
118
+ lambda: backoff.constant([10, 30, 120]),
119
+ aiohttp.ClientResponseError,
120
+ max_tries=4, # 4 tries and 3 retries
121
+ jitter=lambda wait_gen_value: random.uniform(wait_gen_value, wait_gen_value + 30),
122
+ giveup=should_we_giveup,
123
+ raise_on_giveup=False,
124
+ on_giveup=we_give_up,
125
+ )
126
+ async def call_llm_stream(
127
+ messages: List[Dict[str, str]],
128
+ inference_cfg: InferenceConfig,
129
+ stream: bool = False,
130
+ ) -> AsyncStream[ChatCompletionChunk]:
131
+ """Submit prompt to LLM and recieve stream of tokens as a result.
132
+ inference_cfg: The configuration section from the config.json representing
133
+ the relevant inference server for this request.
134
+ """
135
+ LOG.info("Analyzing the text")
136
+
137
+ LOG.info("Submitting to /v1/chat/completions endpoint")
138
+
139
+ async with inference_cfg.get_limiter():
140
+ response = await CLIENT.chat.completions.create(
141
+ messages=messages,
142
+ max_tokens=inference_cfg.max_tokens,
143
+ logprobs=inference_cfg.log_probs,
144
+ stream=stream,
145
+ model=inference_cfg.model,
146
+ temperature=inference_cfg.temperature,
147
+ )
148
+
149
+ return response
150
+
151
+
152
+ async def analyze_snippets(
153
+ log_summary: List[Tuple[int, str]], structured_output: dict | None = None
154
+ ) -> List[SnippetAnalysis | RatedSnippetAnalysis]:
155
+ """Submit log file snippets to the LLM and gather results"""
156
+ # Process snippets asynchronously
157
+ awaitables = [
158
+ call_llm(
159
+ prompt_to_messages(
160
+ PROMPT_CONFIG.snippet_prompt_template.format(s),
161
+ PROMPT_CONFIG.snippet_system_prompt,
162
+ SERVER_CONFIG.inference.system_role,
163
+ SERVER_CONFIG.inference.user_role,
164
+ ),
165
+ inference_cfg=SERVER_CONFIG.snippet_inference,
166
+ structured_output=structured_output,
167
+ )
168
+ for s in log_summary
169
+ ]
170
+ gathered_responses = await asyncio.gather(*awaitables)
171
+ analyzed_snippets = []
172
+
173
+ for response in gathered_responses:
174
+ if structured_output:
175
+ try:
176
+ snippet = RatedSnippetAnalysis.model_validate_json(response.text)
177
+ except ValidationError as ex:
178
+ LOG.error("Invalid data structure returned `%s`", response.text)
179
+ raise ex
180
+ else:
181
+ snippet = SnippetAnalysis(text=response.text)
182
+ analyzed_snippets.append(snippet)
183
+
184
+ return analyzed_snippets
185
+
186
+
187
+ async def perfrom_analysis(log_text: str) -> Response:
188
+ """Sumbit log file snippets in aggregate to LLM and retrieve results"""
189
+ log_summary = mine_logs(log_text, SERVER_CONFIG.extractor.get_extractors())
190
+ log_summary = format_snippets(log_summary)
191
+
192
+ final_prompt = construct_final_prompt(log_summary, PROMPT_CONFIG.prompt_template)
193
+
194
+ messages = prompt_to_messages(
195
+ final_prompt,
196
+ PROMPT_CONFIG.default_system_prompt,
197
+ SERVER_CONFIG.inference.system_role,
198
+ SERVER_CONFIG.inference.user_role,
199
+ )
200
+ response = await call_llm(
201
+ messages,
202
+ inference_cfg=SERVER_CONFIG.inference,
203
+ )
204
+ certainty = 0
205
+
206
+ if response.logprobs is not None:
207
+ try:
208
+ certainty = compute_certainty(response.logprobs)
209
+ except ValueError as ex:
210
+ LOG.error("Error encountered while computing certainty: %s", ex)
211
+ raise HTTPException(
212
+ status_code=400,
213
+ detail=f"Couldn't compute certainty with data:\n{response.logprobs}",
214
+ ) from ex
215
+
216
+ return Response(explanation=response, response_certainty=certainty)
217
+
218
+
219
+ async def perform_analyis_stream(log_text: str) -> AsyncStream:
220
+ """Submit log file snippets in aggregate and return a stream of tokens"""
221
+ log_summary = mine_logs(log_text, SERVER_CONFIG.extractor.get_extractors())
222
+ log_summary = format_snippets(log_summary)
223
+
224
+ final_prompt = construct_final_prompt(log_summary, PROMPT_CONFIG.prompt_template)
225
+
226
+ messages = prompt_to_messages(
227
+ final_prompt,
228
+ PROMPT_CONFIG.default_system_prompt,
229
+ SERVER_CONFIG.inference.system_role,
230
+ SERVER_CONFIG.inference.user_role,
231
+ )
232
+
233
+ stream = call_llm_stream(
234
+ messages,
235
+ inference_cfg=SERVER_CONFIG.inference,
236
+ )
237
+
238
+ # we need to figure out a better response here, this is how it looks rn:
239
+ # b'data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],
240
+ # "created":1744818071,"id":"chatcmpl-c9geTxNcQO7M9wR...
241
+ return stream
242
+
243
+
244
+ async def perform_staged_analysis(log_text: str) -> StagedResponse:
245
+ """Submit the log file snippets to the LLM and retrieve their results"""
246
+ log_summary = mine_logs(log_text, SERVER_CONFIG.extractor.get_extractors())
247
+ start = time.time()
248
+ if SERVER_CONFIG.general.top_k_snippets:
249
+ rated_snippets = await analyze_snippets(
250
+ log_summary=log_summary,
251
+ structured_output=RatedSnippetAnalysis.model_json_schema(),
252
+ )
253
+
254
+ # Extract original text and line number from `log_summary`
255
+ processed_snippets = [
256
+ AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
257
+ for e in zip(log_summary, rated_snippets)
258
+ ]
259
+ processed_snippets = filter_snippets(
260
+ processed_snippets=processed_snippets,
261
+ top_k=SERVER_CONFIG.general.top_k_snippets,
262
+ )
263
+ LOG.info(
264
+ "Keeping %d of original %d snippets",
265
+ len(processed_snippets),
266
+ len(rated_snippets),
267
+ )
268
+ else:
269
+ processed_snippets = await analyze_snippets(log_summary=log_summary)
270
+
271
+ # Extract original text and line number from `log_summary`
272
+ processed_snippets = [
273
+ AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
274
+ for e in zip(log_summary, processed_snippets)
275
+ ]
276
+ delta = time.time() - start
277
+ LOG.info("Snippet analysis performed in %f s", delta)
278
+ log_summary = format_analyzed_snippets(processed_snippets)
279
+ final_prompt = construct_final_prompt(log_summary, PROMPT_CONFIG.prompt_template_staged)
280
+
281
+ messages = prompt_to_messages(
282
+ final_prompt,
283
+ PROMPT_CONFIG.staged_system_prompt,
284
+ SERVER_CONFIG.inference.system_role,
285
+ SERVER_CONFIG.inference.user_role,
286
+ )
287
+ final_analysis = await call_llm(
288
+ messages,
289
+ inference_cfg=SERVER_CONFIG.inference,
290
+ )
291
+
292
+ certainty = 0
293
+
294
+ if final_analysis.logprobs:
295
+ try:
296
+ certainty = compute_certainty(final_analysis.logprobs)
297
+ except ValueError as ex:
298
+ LOG.error("Error encountered while computing certainty: %s", ex)
299
+ raise HTTPException(
300
+ status_code=400,
301
+ detail=f"Couldn't compute certainty with data:\n"
302
+ f"{final_analysis.logprobs}",
303
+ ) from ex
304
+
305
+ return StagedResponse(
306
+ explanation=final_analysis,
307
+ snippets=processed_snippets,
308
+ response_certainty=certainty,
309
+ )
@@ -1,35 +1,54 @@
1
- import datetime
2
1
  import inspect
3
- from typing import Union
2
+ import datetime
3
+
4
+ from typing import Optional, Union
4
5
  from functools import wraps
5
6
 
7
+ import aiohttp
8
+
6
9
  from starlette.responses import StreamingResponse
7
- from logdetective.server.database.models import EndpointType, AnalyzeRequestMetrics
8
10
  from logdetective.server import models
11
+ from logdetective.remote_log import RemoteLog
12
+ from logdetective.server.config import LOG
13
+ from logdetective.server.compressors import LLMResponseCompressor, RemoteLogCompressor
14
+ from logdetective.server.database.models import EndpointType, AnalyzeRequestMetrics
15
+ from logdetective.server.exceptions import LogDetectiveMetricsError
9
16
 
10
17
 
11
- def add_new_metrics(
12
- api_name: str, build_log: models.BuildLog, received_at: datetime.datetime = None
18
+ async def add_new_metrics(
19
+ api_name: EndpointType,
20
+ url: Optional[str] = None,
21
+ http_session: Optional[aiohttp.ClientSession] = None,
22
+ received_at: Optional[datetime.datetime] = None,
23
+ compressed_log_content: Optional[bytes] = None,
13
24
  ) -> int:
14
25
  """Add a new database entry for a received request.
15
26
 
16
27
  This will store the time when this function is called,
17
28
  the endpoint from where the request was received,
18
- and the log for which analysis is requested.
29
+ and the log (in a zip format) for which analysis is requested.
19
30
  """
20
- return AnalyzeRequestMetrics.create(
31
+ if not compressed_log_content:
32
+ if not (url and http_session):
33
+ raise LogDetectiveMetricsError(
34
+ f"""Remote log can not be retrieved without URL and http session.
35
+ URL: {url}, http session:{http_session}""")
36
+ remote_log = RemoteLog(url, http_session)
37
+ compressed_log_content = await RemoteLogCompressor(remote_log).zip_content()
38
+
39
+ return await AnalyzeRequestMetrics.create(
21
40
  endpoint=EndpointType(api_name),
22
- log_url=build_log.url,
41
+ compressed_log=compressed_log_content,
23
42
  request_received_at=received_at
24
43
  if received_at
25
44
  else datetime.datetime.now(datetime.timezone.utc),
26
45
  )
27
46
 
28
47
 
29
- def update_metrics(
48
+ async def update_metrics(
30
49
  metrics_id: int,
31
50
  response: Union[models.Response, models.StagedResponse, StreamingResponse],
32
- sent_at: datetime.datetime = None,
51
+ sent_at: Optional[datetime.datetime] = None,
33
52
  ) -> None:
34
53
  """Update a database metric entry for a received request,
35
54
  filling data for the given response.
@@ -37,46 +56,72 @@ def update_metrics(
37
56
  This will add to the database entry the time when the response was sent,
38
57
  the length of the created response and the certainty for it.
39
58
  """
59
+ try:
60
+ compressed_response = LLMResponseCompressor(response).zip_response()
61
+ except AttributeError as e:
62
+ compressed_response = None
63
+ LOG.warning(
64
+ "Given response can not be serialized "
65
+ "and saved in db (probably a StreamingResponse): %s.",
66
+ e,
67
+ )
68
+
40
69
  response_sent_at = (
41
70
  sent_at if sent_at else datetime.datetime.now(datetime.timezone.utc)
42
71
  )
43
72
  response_length = None
44
- if hasattr(response, "explanation") and "choices" in response.explanation:
45
- response_length = sum(
46
- len(choice["text"])
47
- for choice in response.explanation["choices"]
48
- if "text" in choice
49
- )
73
+ if hasattr(response, "explanation") and isinstance(
74
+ response.explanation, models.Explanation
75
+ ):
76
+ response_length = len(response.explanation.text)
50
77
  response_certainty = (
51
78
  response.response_certainty if hasattr(response, "response_certainty") else None
52
79
  )
53
- AnalyzeRequestMetrics.update(
54
- metrics_id, response_sent_at, response_length, response_certainty
80
+ await AnalyzeRequestMetrics.update(
81
+ id_=metrics_id,
82
+ response_sent_at=response_sent_at,
83
+ response_length=response_length,
84
+ response_certainty=response_certainty,
85
+ compressed_response=compressed_response,
55
86
  )
56
87
 
57
88
 
58
- def track_request():
89
+ def track_request(name=None):
59
90
  """
60
- Decorator to track requests metrics
91
+ Decorator to track requests/responses metrics
92
+
93
+ On entering the decorated function, it registers the time for the request
94
+ and saves the passed log content.
95
+ On exiting the decorated function, it registers the time for the response
96
+ and saves the generated response.
97
+
98
+ Use it to decorate server endpoints that generate a llm response
99
+ as in the following example:
100
+
101
+ >>> @app.post("/analyze", response_model=Response)
102
+ >>> @track_request()
103
+ >>> async def analyze_log(build_log)
104
+ >>> pass
105
+
106
+ Warning: the decorators' order is important!
107
+ The function returned by the *track_request* decorator is the
108
+ server API function we want to be called by FastAPI.
61
109
  """
62
110
 
63
111
  def decorator(f):
64
112
  @wraps(f)
65
113
  async def async_decorated_function(*args, **kwargs):
66
- metrics_id = add_new_metrics(f.__name__, kwargs["build_log"])
114
+ log_url = kwargs["build_log"].url
115
+ metrics_id = await add_new_metrics(
116
+ api_name=EndpointType(name if name else f.__name__),
117
+ url=log_url, http_session=kwargs["http_session"]
118
+ )
67
119
  response = await f(*args, **kwargs)
68
- update_metrics(metrics_id, response)
69
- return response
70
-
71
- @wraps(f)
72
- def sync_decorated_function(*args, **kwargs):
73
- metrics_id = add_new_metrics(f.__name__, kwargs["build_log"])
74
- response = f(*args, **kwargs)
75
- update_metrics(metrics_id, response)
120
+ await update_metrics(metrics_id, response)
76
121
  return response
77
122
 
78
123
  if inspect.iscoroutinefunction(f):
79
124
  return async_decorated_function
80
- return sync_decorated_function
125
+ raise NotImplementedError("An async coroutine is needed")
81
126
 
82
127
  return decorator