logdetective 0.2.13__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: logdetective
3
- Version: 0.2.13
3
+ Version: 0.3.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
@@ -18,10 +18,15 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Topic :: Internet :: Log Analysis
19
19
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
20
  Classifier: Topic :: Software Development :: Debuggers
21
+ Provides-Extra: server
21
22
  Requires-Dist: drain3 (>=0.9.11,<0.10.0)
23
+ Requires-Dist: fastapi (>=0.115.8,<0.116.0) ; extra == "server"
22
24
  Requires-Dist: huggingface-hub (>0.23.2)
23
25
  Requires-Dist: llama-cpp-python (>0.2.56,!=0.2.86)
24
26
  Requires-Dist: numpy (>=1.26.0)
27
+ Requires-Dist: pydantic (>=2.10.6,<3.0.0) ; extra == "server"
28
+ Requires-Dist: python-gitlab (>=5.6.0,<6.0.0)
29
+ Requires-Dist: pyyaml (>=6.0.1,<7.0.0) ; extra == "server"
25
30
  Requires-Dist: requests (>0.2.31)
26
31
  Project-URL: homepage, https://github.com/fedora-copr/logdetective
27
32
  Project-URL: issues, https://github.com/fedora-copr/logdetective/issues
@@ -216,6 +221,30 @@ $ curl -L -o models/mistral-7b-instruct-v0.2.Q4_K_S.gguf https://huggingface.co/
216
221
  ```
217
222
 
218
223
 
224
+ Our production instance
225
+ -----------------------
226
+
227
+ Our FastAPI server and model inference server run through `podman-compose` on an
228
+ Amazon AWS intance. The VM is provisioned by an
229
+ [ansible playbook](https://pagure.io/fedora-infra/ansible/blob/main/f/roles/logdetective/tasks/main.yml).
230
+
231
+ You can control the server through:
232
+
233
+ ```
234
+ cd /root/logdetective
235
+ podman-compose -f docker-compose-prod.yaml ...
236
+ ```
237
+
238
+ The `/root` directory contains valuable data. If moving to a new instance,
239
+ please backup the whole directory and transfer it to the new instance.
240
+
241
+ Fore some reason, we need to manually run this command after every reboot:
242
+
243
+ ```
244
+ nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
245
+ ```
246
+
247
+
219
248
  License
220
249
  -------
221
250
 
@@ -187,6 +187,30 @@ $ curl -L -o models/mistral-7b-instruct-v0.2.Q4_K_S.gguf https://huggingface.co/
187
187
  ```
188
188
 
189
189
 
190
+ Our production instance
191
+ -----------------------
192
+
193
+ Our FastAPI server and model inference server run through `podman-compose` on an
194
+ Amazon AWS intance. The VM is provisioned by an
195
+ [ansible playbook](https://pagure.io/fedora-infra/ansible/blob/main/f/roles/logdetective/tasks/main.yml).
196
+
197
+ You can control the server through:
198
+
199
+ ```
200
+ cd /root/logdetective
201
+ podman-compose -f docker-compose-prod.yaml ...
202
+ ```
203
+
204
+ The `/root` directory contains valuable data. If moving to a new instance,
205
+ please backup the whole directory and transfer it to the new instance.
206
+
207
+ Fore some reason, we need to manually run this command after every reboot:
208
+
209
+ ```
210
+ nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
211
+ ```
212
+
213
+
190
214
  License
191
215
  -------
192
216
 
@@ -1,4 +1,3 @@
1
-
2
1
  # pylint: disable=line-too-long
3
2
  DEFAULT_ADVISOR = "fedora-copr/Mistral-7B-Instruct-v0.2-GGUF"
4
3
 
@@ -32,7 +31,7 @@ Answer:
32
31
  """
33
32
 
34
33
  SNIPPET_PROMPT_TEMPLATE = """
35
- Analyse following RPM build log snippet. Decribe contents accurately, without speculation or suggestions for resolution.
34
+ Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution.
36
35
 
37
36
  Snippet:
38
37
 
@@ -59,4 +58,4 @@ Analysis:
59
58
 
60
59
  """
61
60
 
62
- SNIPPET_DELIMITER = '================'
61
+ SNIPPET_DELIMITER = "================"
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import logging
3
+ from typing import Tuple
3
4
 
4
5
  import drain3
5
6
  from drain3.template_miner_config import TemplateMinerConfig
@@ -15,13 +16,17 @@ class LLMExtractor:
15
16
  """
16
17
  A class that extracts relevant information from logs using a language model.
17
18
  """
19
+
18
20
  def __init__(self, model: Llama, n_lines: int = 2):
19
21
  self.model = model
20
22
  self.n_lines = n_lines
21
23
  self.grammar = LlamaGrammar.from_string(
22
- "root ::= (\"Yes\" | \"No\")", verbose=False)
24
+ 'root ::= ("Yes" | "No")', verbose=False
25
+ )
23
26
 
24
- def __call__(self, log: str, n_lines: int = 2, neighbors: bool = False) -> list[str]:
27
+ def __call__(
28
+ self, log: str, n_lines: int = 2, neighbors: bool = False
29
+ ) -> list[str]:
25
30
  chunks = self.rate_chunks(log)
26
31
  out = self.create_extract(chunks, neighbors)
27
32
  return out
@@ -35,7 +40,7 @@ class LLMExtractor:
35
40
  log_lines = log.split("\n")
36
41
 
37
42
  for i in range(0, len(log_lines), self.n_lines):
38
- block = '\n'.join(log_lines[i:i + self.n_lines])
43
+ block = "\n".join(log_lines[i: i + self.n_lines])
39
44
  prompt = SUMMARIZE_PROMPT_TEMPLATE.format(log)
40
45
  out = self.model(prompt, max_tokens=7, grammar=self.grammar)
41
46
  out = f"{out['choices'][0]['text']}\n"
@@ -44,8 +49,7 @@ class LLMExtractor:
44
49
  return results
45
50
 
46
51
  def create_extract(self, chunks: list[tuple], neighbors: bool = False) -> list[str]:
47
- """Extract interesting chunks from the model processing.
48
- """
52
+ """Extract interesting chunks from the model processing."""
49
53
  interesting = []
50
54
  summary = []
51
55
  # pylint: disable=consider-using-enumerate
@@ -64,8 +68,8 @@ class LLMExtractor:
64
68
 
65
69
 
66
70
  class DrainExtractor:
67
- """A class that extracts information from logs using a template miner algorithm.
68
- """
71
+ """A class that extracts information from logs using a template miner algorithm."""
72
+
69
73
  def __init__(self, verbose: bool = False, context: bool = False, max_clusters=8):
70
74
  config = TemplateMinerConfig()
71
75
  config.load(f"{os.path.dirname(__file__)}/drain3.ini")
@@ -75,15 +79,21 @@ class DrainExtractor:
75
79
  self.verbose = verbose
76
80
  self.context = context
77
81
 
78
- def __call__(self, log: str) -> list[str]:
82
+ def __call__(self, log: str) -> list[Tuple[int, str]]:
79
83
  out = []
80
- for chunk in get_chunks(log):
81
- processed_line = self.miner.add_log_message(chunk)
82
- LOG.debug(processed_line)
83
- sorted_clusters = sorted(self.miner.drain.clusters, key=lambda it: it.size, reverse=True)
84
- for chunk in get_chunks(log):
84
+ # First pass create clusters
85
+ for _, chunk in get_chunks(log):
86
+ processed_chunk = self.miner.add_log_message(chunk)
87
+ LOG.debug(processed_chunk)
88
+ # Sort found clusters by size, descending order
89
+ sorted_clusters = sorted(
90
+ self.miner.drain.clusters, key=lambda it: it.size, reverse=True
91
+ )
92
+ # Second pass, only matching lines with clusters,
93
+ # to recover original text
94
+ for chunk_start, chunk in get_chunks(log):
85
95
  cluster = self.miner.match(chunk, "always")
86
96
  if cluster in sorted_clusters:
87
- out.append(chunk)
97
+ out.append((chunk_start, chunk))
88
98
  sorted_clusters.remove(cluster)
89
99
  return out
@@ -4,40 +4,71 @@ import sys
4
4
 
5
5
  from logdetective.constants import DEFAULT_ADVISOR
6
6
  from logdetective.utils import (
7
- process_log, initialize_model, retrieve_log_content, format_snippets, compute_certainty)
7
+ process_log,
8
+ initialize_model,
9
+ retrieve_log_content,
10
+ format_snippets,
11
+ compute_certainty,
12
+ )
8
13
  from logdetective.extractors import LLMExtractor, DrainExtractor
9
14
 
10
15
  LOG = logging.getLogger("logdetective")
11
16
 
12
17
 
13
18
  def setup_args():
14
- """ Setup argument parser and return arguments. """
19
+ """Setup argument parser and return arguments."""
15
20
  parser = argparse.ArgumentParser("logdetective")
16
- parser.add_argument("file", type=str,
17
- default="", help="The URL or path to the log file to be analyzed.")
18
- parser.add_argument("-M", "--model",
19
- help="The path or Hugging Face name of the language model for analysis.",
20
- type=str, default=DEFAULT_ADVISOR)
21
- parser.add_argument("-F", "--filename_suffix",
22
- help="Suffix of the model file name to be retrieved from Hugging Face.\
21
+ parser.add_argument(
22
+ "file",
23
+ type=str,
24
+ default="",
25
+ help="The URL or path to the log file to be analyzed.",
26
+ )
27
+ parser.add_argument(
28
+ "-M",
29
+ "--model",
30
+ help="The path or Hugging Face name of the language model for analysis.",
31
+ type=str,
32
+ default=DEFAULT_ADVISOR,
33
+ )
34
+ parser.add_argument(
35
+ "-F",
36
+ "--filename_suffix",
37
+ help="Suffix of the model file name to be retrieved from Hugging Face.\
23
38
  Makes sense only if the model is specified with Hugging Face name.",
24
- default="Q4_K_S.gguf")
25
- parser.add_argument("-n", "--no-stream", action='store_true')
26
- parser.add_argument("-S", "--summarizer", type=str, default="drain",
27
- help="Choose between LLM and Drain template miner as the log summarizer.\
28
- LLM must be specified as path to a model, URL or local file.")
29
- parser.add_argument("-N", "--n_lines", type=int,
30
- default=8, help="The number of lines per chunk for LLM analysis.\
31
- This only makes sense when you are summarizing with LLM.")
32
- parser.add_argument("-C", "--n_clusters", type=int, default=8,
33
- help="Number of clusters for Drain to organize log chunks into.\
34
- This only makes sense when you are summarizing with Drain")
35
- parser.add_argument("-v", "--verbose", action='count', default=0)
36
- parser.add_argument("-q", "--quiet", action='store_true')
39
+ default="Q4_K_S.gguf",
40
+ )
41
+ parser.add_argument("-n", "--no-stream", action="store_true")
42
+ parser.add_argument(
43
+ "-S",
44
+ "--summarizer",
45
+ type=str,
46
+ default="drain",
47
+ help="Choose between LLM and Drain template miner as the log summarizer.\
48
+ LLM must be specified as path to a model, URL or local file.",
49
+ )
50
+ parser.add_argument(
51
+ "-N",
52
+ "--n_lines",
53
+ type=int,
54
+ default=8,
55
+ help="The number of lines per chunk for LLM analysis.\
56
+ This only makes sense when you are summarizing with LLM.",
57
+ )
58
+ parser.add_argument(
59
+ "-C",
60
+ "--n_clusters",
61
+ type=int,
62
+ default=8,
63
+ help="Number of clusters for Drain to organize log chunks into.\
64
+ This only makes sense when you are summarizing with Drain",
65
+ )
66
+ parser.add_argument("-v", "--verbose", action="count", default=0)
67
+ parser.add_argument("-q", "--quiet", action="store_true")
37
68
  return parser.parse_args()
38
69
 
39
70
 
40
- def main():
71
+ def main(): # pylint: disable=too-many-statements
41
72
  """Main execution function."""
42
73
  args = setup_args()
43
74
 
@@ -57,8 +88,9 @@ def main():
57
88
 
58
89
  # Primary model initialization
59
90
  try:
60
- model = initialize_model(args.model, filename_suffix=args.filename_suffix,
61
- verbose=args.verbose > 2)
91
+ model = initialize_model(
92
+ args.model, filename_suffix=args.filename_suffix, verbose=args.verbose > 2
93
+ )
62
94
  except ValueError as e:
63
95
  LOG.error(e)
64
96
  LOG.error("You likely do not have enough memory to load the AI model")
@@ -66,7 +98,9 @@ def main():
66
98
 
67
99
  # Log file summarizer selection and initialization
68
100
  if args.summarizer == "drain":
69
- extractor = DrainExtractor(args.verbose > 1, context=True, max_clusters=args.n_clusters)
101
+ extractor = DrainExtractor(
102
+ args.verbose > 1, context=True, max_clusters=args.n_clusters
103
+ )
70
104
  else:
71
105
  summarizer_model = initialize_model(args.summarizer, verbose=args.verbose > 2)
72
106
  extractor = LLMExtractor(summarizer_model, args.verbose > 1)
@@ -81,7 +115,7 @@ def main():
81
115
  sys.exit(4)
82
116
  log_summary = extractor(log)
83
117
 
84
- ratio = len(log_summary) / len(log.split('\n'))
118
+ ratio = len(log_summary) / len(log.split("\n"))
85
119
 
86
120
  LOG.info("Compression ratio: %s", ratio)
87
121
 
@@ -103,15 +137,19 @@ def main():
103
137
 
104
138
  if args.no_stream:
105
139
  print(response["choices"][0]["text"])
106
- probs = [{'logprob': e} for e in response['choices'][0]['logprobs']['token_logprobs']]
140
+ probs = [
141
+ {"logprob": e} for e in response["choices"][0]["logprobs"]["token_logprobs"]
142
+ ]
107
143
 
108
144
  else:
109
145
  # Stream the output
110
146
  for chunk in response:
111
147
  if isinstance(chunk["choices"][0]["logprobs"], dict):
112
- probs.append({'logprob': chunk["choices"][0]["logprobs"]['token_logprobs'][0]})
113
- delta = chunk['choices'][0]['text']
114
- print(delta, end='', flush=True)
148
+ probs.append(
149
+ {"logprob": chunk["choices"][0]["logprobs"]["token_logprobs"][0]}
150
+ )
151
+ delta = chunk["choices"][0]["text"]
152
+ print(delta, end="", flush=True)
115
153
  certainty = compute_certainty(probs)
116
154
 
117
155
  print(f"\nResponse certainty: {certainty:.2f}%\n")
@@ -0,0 +1,173 @@
1
+ from logging import BASIC_FORMAT
2
+ from typing import List, Dict, Optional
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class BuildLog(BaseModel):
7
+ """Model of data submitted to API."""
8
+
9
+ url: str
10
+
11
+
12
+ class JobHook(BaseModel):
13
+ """Model of Job Hook events sent from GitLab.
14
+ Full details of the specification are available at
15
+ https://docs.gitlab.com/user/project/integrations/webhook_events/#job-events
16
+ This model implements only the fields that we care about. The webhook
17
+ sends many more fields that we will ignore."""
18
+
19
+ # The unique job ID on this GitLab instance.
20
+ build_id: int
21
+
22
+ # The identifier of the job. We only care about 'build_rpm' and
23
+ # 'build_centos_stream_rpm' jobs.
24
+ build_name: str = Field(pattern=r"^build(_.*)?_rpm$")
25
+
26
+ # A string representing the job status. We only care about 'failed' jobs.
27
+ build_status: str = Field(pattern=r"^failed$")
28
+
29
+ # The kind of webhook message. We are only interested in 'build' messages
30
+ # which represents job tasks in a pipeline.
31
+ object_kind: str = Field(pattern=r"^build$")
32
+
33
+ # The unique ID of the enclosing pipeline on this GitLab instance.
34
+ pipeline_id: int
35
+
36
+ # The unique ID of the project triggering this event
37
+ project_id: int
38
+
39
+
40
+ class Response(BaseModel):
41
+ """Model of data returned by Log Detective API
42
+
43
+ explanation: CreateCompletionResponse
44
+ https://llama-cpp-python.readthedocs.io/en/latest/api-reference/#llama_cpp.llama_types.CreateCompletionResponse
45
+ response_certainty: float
46
+ """
47
+
48
+ explanation: Dict
49
+ response_certainty: float
50
+
51
+
52
+ class StagedResponse(Response):
53
+ """Model of data returned by Log Detective API when called when staged response
54
+ is requested. Contains list of reponses to prompts for individual snippets.
55
+
56
+ explanation: CreateCompletionResponse
57
+ https://llama-cpp-python.readthedocs.io/en/latest/api-reference/#llama_cpp.llama_types.CreateCompletionResponse
58
+ response_certainty: float
59
+ snippets:
60
+ list of dictionaries {
61
+ 'snippet' : '<original_text>,
62
+ 'comment': CreateCompletionResponse,
63
+ 'line_number': '<location_in_log>' }
64
+ """
65
+
66
+ snippets: List[Dict[str, str | Dict | int]]
67
+
68
+
69
+ class InferenceConfig(BaseModel):
70
+ """Model for inference configuration of logdetective server."""
71
+
72
+ max_tokens: int = -1
73
+ log_probs: int = 1
74
+
75
+ def __init__(self, data: Optional[dict] = None):
76
+ super().__init__()
77
+ if data is None:
78
+ return
79
+
80
+ self.max_tokens = data.get("max_tokens", -1)
81
+ self.log_probs = data.get("log_probs", 1)
82
+
83
+
84
+ class ExtractorConfig(BaseModel):
85
+ """Model for extractor configuration of logdetective server."""
86
+
87
+ context: bool = True
88
+ max_clusters: int = 8
89
+ verbose: bool = False
90
+
91
+ def __init__(self, data: Optional[dict] = None):
92
+ super().__init__()
93
+ if data is None:
94
+ return
95
+
96
+ self.context = data.get("context", True)
97
+ self.max_clusters = data.get("max_clusters", 8)
98
+ self.verbose = data.get("verbose", False)
99
+
100
+
101
+ class GitLabConfig(BaseModel):
102
+ """Model for GitLab configuration of logdetective server."""
103
+
104
+ url: str = None
105
+ api_url: str = None
106
+ api_token: str = None
107
+
108
+ # Maximum size of artifacts.zip in MiB. (default: 300 MiB)
109
+ max_artifact_size: int = 300
110
+
111
+ def __init__(self, data: Optional[dict] = None):
112
+ super().__init__()
113
+ if data is None:
114
+ return
115
+
116
+ self.url = data.get("url", "https://gitlab.com")
117
+ self.api_url = f"{self.url}/api/v4"
118
+ self.api_token = data.get("api_token", None)
119
+ self.max_artifact_size = int(data.get("max_artifact_size")) * 1024 * 1024
120
+
121
+
122
+ class LogConfig(BaseModel):
123
+ """Logging configuration"""
124
+
125
+ name: str = "logdetective"
126
+ level: str | int = "INFO"
127
+ path: str | None = None
128
+ format: str = BASIC_FORMAT
129
+
130
+ def __init__(self, data: Optional[dict] = None):
131
+ super().__init__()
132
+ if data is None:
133
+ return
134
+
135
+ self.name = data.get("name", "logdetective")
136
+ self.level = data.get("level", "INFO").upper()
137
+ self.path = data.get("path")
138
+ self.format = data.get("format", BASIC_FORMAT)
139
+
140
+
141
+ class GeneralConfig(BaseModel):
142
+ """General config options for Log Detective"""
143
+
144
+ packages: List[str] = None
145
+
146
+ def __init__(self, data: Optional[dict] = None):
147
+ super().__init__()
148
+ if data is None:
149
+ return
150
+
151
+ self.packages = data.get("packages", [])
152
+
153
+
154
+ class Config(BaseModel):
155
+ """Model for configuration of logdetective server."""
156
+
157
+ log: LogConfig = LogConfig()
158
+ inference: InferenceConfig = InferenceConfig()
159
+ extractor: ExtractorConfig = ExtractorConfig()
160
+ gitlab: GitLabConfig = GitLabConfig()
161
+ general: GeneralConfig = GeneralConfig()
162
+
163
+ def __init__(self, data: Optional[dict] = None):
164
+ super().__init__()
165
+
166
+ if data is None:
167
+ return
168
+
169
+ self.log = LogConfig(data.get("log"))
170
+ self.inference = InferenceConfig(data.get("inference"))
171
+ self.extractor = ExtractorConfig(data.get("extractor"))
172
+ self.gitlab = GitLabConfig(data.get("gitlab"))
173
+ self.general = GeneralConfig(data.get("general"))