logdetective 0.9.1__tar.gz → 0.10.0__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.
Files changed (32) hide show
  1. {logdetective-0.9.1 → logdetective-0.10.0}/PKG-INFO +3 -2
  2. {logdetective-0.9.1 → logdetective-0.10.0}/README.md +1 -1
  3. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/constants.py +4 -0
  4. {logdetective-0.9.1/logdetective/server → logdetective-0.10.0/logdetective}/remote_log.py +3 -43
  5. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/compressors.py +49 -4
  6. logdetective-0.9.1/logdetective/server/utils.py → logdetective-0.10.0/logdetective/server/config.py +12 -13
  7. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/database/models/merge_request_jobs.py +79 -7
  8. logdetective-0.10.0/logdetective/server/emoji.py +104 -0
  9. logdetective-0.10.0/logdetective/server/gitlab.py +413 -0
  10. logdetective-0.10.0/logdetective/server/llm.py +284 -0
  11. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/metric.py +9 -8
  12. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/models.py +78 -6
  13. logdetective-0.10.0/logdetective/server/server.py +514 -0
  14. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/utils.py +1 -1
  15. {logdetective-0.9.1 → logdetective-0.10.0}/pyproject.toml +8 -2
  16. logdetective-0.9.1/logdetective/server/server.py +0 -981
  17. {logdetective-0.9.1 → logdetective-0.10.0}/LICENSE +0 -0
  18. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/__init__.py +0 -0
  19. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/drain3.ini +0 -0
  20. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/extractors.py +0 -0
  21. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/logdetective.py +0 -0
  22. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/models.py +0 -0
  23. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/prompts.yml +0 -0
  24. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/__init__.py +0 -0
  25. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/database/__init__.py +0 -0
  26. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/database/base.py +0 -0
  27. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/database/models/__init__.py +0 -0
  28. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/database/models/metrics.py +0 -0
  29. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/plot.py +0 -0
  30. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/templates/gitlab_full_comment.md.j2 +0 -0
  31. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective/server/templates/gitlab_short_comment.md.j2 +0 -0
  32. {logdetective-0.9.1 → logdetective-0.10.0}/logdetective.1.asciidoc +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: logdetective
3
- Version: 0.9.1
3
+ Version: 0.10.0
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
@@ -21,6 +21,7 @@ Classifier: Topic :: Software Development :: Debuggers
21
21
  Provides-Extra: server
22
22
  Provides-Extra: server-testing
23
23
  Requires-Dist: aiohttp (>=3.7.4)
24
+ Requires-Dist: aiolimiter (>=1.0.0,<2.0.0) ; extra == "server"
24
25
  Requires-Dist: alembic (>=1.13.3,<2.0.0) ; extra == "server" or extra == "server-testing"
25
26
  Requires-Dist: backoff (==2.2.1) ; extra == "server" or extra == "server-testing"
26
27
  Requires-Dist: drain3 (>=0.9.11,<0.10.0)
@@ -60,7 +61,7 @@ Installation
60
61
 
61
62
  **From Pypi repository**
62
63
 
63
- The logdetective project is published on the the the the the [Pypi repository](https://pypi.org/project/logdetective/). The `pip` tool can be used for installation.
64
+ The logdetective project is published on the [Pypi repository](https://pypi.org/project/logdetective/). The `pip` tool can be used for installation.
64
65
 
65
66
  First, ensure that the necessary dependencies for the `llama-cpp-python` project are installed. For Fedora, install `gcc-c++`:
66
67
 
@@ -18,7 +18,7 @@ Installation
18
18
 
19
19
  **From Pypi repository**
20
20
 
21
- The logdetective project is published on the the the the the [Pypi repository](https://pypi.org/project/logdetective/). The `pip` tool can be used for installation.
21
+ The logdetective project is published on the [Pypi repository](https://pypi.org/project/logdetective/). The `pip` tool can be used for installation.
22
22
 
23
23
  First, ensure that the necessary dependencies for the `llama-cpp-python` project are installed. For Fedora, install `gcc-c++`:
24
24
 
@@ -72,3 +72,7 @@ Analysis:
72
72
  SNIPPET_DELIMITER = "================"
73
73
 
74
74
  DEFAULT_TEMPERATURE = 0.8
75
+
76
+ # Tuning for LLM-as-a-Service
77
+ LLM_DEFAULT_MAX_QUEUE_SIZE = 50
78
+ LLM_DEFAULT_REQUESTS_PER_MINUTE = 60
@@ -1,24 +1,17 @@
1
- import io
2
1
  import logging
3
- from typing import Union
4
2
  from urllib.parse import urlparse
5
3
 
6
4
  import aiohttp
7
-
8
- from logdetective.server.compressors import TextCompressor
9
-
5
+ from fastapi import HTTPException
10
6
 
11
7
  LOG = logging.getLogger("logdetective")
12
8
 
13
9
 
14
10
  class RemoteLog:
15
11
  """
16
- Handles retrieval and compression of remote log files.
12
+ Handles retrieval of remote log files.
17
13
  """
18
14
 
19
- LOG_FILE_NAME = "log.txt"
20
- COMPRESSOR = TextCompressor()
21
-
22
15
  def __init__(self, url: str, http_session: aiohttp.ClientSession):
23
16
  """
24
17
  Initialize with a remote log URL and HTTP session.
@@ -40,39 +33,6 @@ class RemoteLog:
40
33
  """Content of the url."""
41
34
  return await self.get_url_content()
42
35
 
43
- @classmethod
44
- def zip_text(cls, text: str) -> bytes:
45
- """
46
- Compress the given text.
47
-
48
- Returns:
49
- bytes: Compressed text
50
- """
51
- return cls.COMPRESSOR.zip({cls.LOG_FILE_NAME: text})
52
-
53
- async def zip_content(self) -> bytes:
54
- """
55
- Compress the content of the remote log.
56
-
57
- Returns:
58
- bytes: Compressed log content
59
- """
60
- content_text = await self.content
61
- return self.zip_text(content_text)
62
-
63
- @classmethod
64
- def unzip(cls, zip_data: Union[bytes, io.BytesIO]) -> str:
65
- """
66
- Uncompress the zipped content of the remote log.
67
-
68
- Args:
69
- zip_data: Compressed data as bytes or BytesIO
70
-
71
- Returns:
72
- str: The decompressed log content
73
- """
74
- return cls.COMPRESSOR.unzip(zip_data)[cls.LOG_FILE_NAME]
75
-
76
36
  def validate_url(self) -> bool:
77
37
  """Validate incoming URL to be at least somewhat sensible for log files
78
38
  Only http and https protocols permitted. No result, params or query fields allowed.
@@ -104,6 +64,6 @@ class RemoteLog:
104
64
  try:
105
65
  return await self.get_url_content()
106
66
  except RuntimeError as ex:
107
- raise aiohttp.HTTPException(
67
+ raise HTTPException(
108
68
  status_code=400, detail=f"We couldn't obtain the logs: {ex}"
109
69
  ) from ex
@@ -1,8 +1,8 @@
1
1
  import io
2
- import logging
3
2
  import zipfile
4
3
 
5
4
  from typing import Union, Dict
5
+ from logdetective.remote_log import RemoteLog
6
6
  from logdetective.server.models import (
7
7
  StagedResponse,
8
8
  Response,
@@ -11,9 +11,6 @@ from logdetective.server.models import (
11
11
  )
12
12
 
13
13
 
14
- LOG = logging.getLogger("logdetective")
15
-
16
-
17
14
  class TextCompressor:
18
15
  """
19
16
  Encapsulates one or more texts in one or more files with the specified names
@@ -63,6 +60,54 @@ class TextCompressor:
63
60
  return content
64
61
 
65
62
 
63
+ class RemoteLogCompressor:
64
+ """
65
+ Handles compression of remote log files.
66
+ """
67
+
68
+ LOG_FILE_NAME = "log.txt"
69
+ COMPRESSOR = TextCompressor()
70
+
71
+ def __init__(self, remote_log: RemoteLog):
72
+ """
73
+ Initialize with a RemoteLog object.
74
+ """
75
+ self._remote_log = remote_log
76
+
77
+ @classmethod
78
+ def zip_text(cls, text: str) -> bytes:
79
+ """
80
+ Compress the given text.
81
+
82
+ Returns:
83
+ bytes: Compressed text
84
+ """
85
+ return cls.COMPRESSOR.zip({cls.LOG_FILE_NAME: text})
86
+
87
+ async def zip_content(self) -> bytes:
88
+ """
89
+ Compress the content of the remote log.
90
+
91
+ Returns:
92
+ bytes: Compressed log content
93
+ """
94
+ content_text = await self._remote_log.content
95
+ return self.zip_text(content_text)
96
+
97
+ @classmethod
98
+ def unzip(cls, zip_data: Union[bytes, io.BytesIO]) -> str:
99
+ """
100
+ Uncompress the zipped content of the remote log.
101
+
102
+ Args:
103
+ zip_data: Compressed data as bytes or BytesIO
104
+
105
+ Returns:
106
+ str: The decompressed log content
107
+ """
108
+ return cls.COMPRESSOR.unzip(zip_data)[cls.LOG_FILE_NAME]
109
+
110
+
66
111
  class LLMResponseCompressor:
67
112
  """
68
113
  Handles compression and decompression of LLM responses.
@@ -1,18 +1,8 @@
1
+ import os
1
2
  import logging
2
3
  import yaml
3
- from logdetective.constants import SNIPPET_DELIMITER
4
- from logdetective.server.models import Config, AnalyzedSnippet
5
-
6
-
7
- def format_analyzed_snippets(snippets: list[AnalyzedSnippet]) -> str:
8
- """Format snippets for submission into staged prompt."""
9
- summary = f"\n{SNIPPET_DELIMITER}\n".join(
10
- [
11
- f"[{e.text}] at line [{e.line_number}]: [{e.explanation.text}]"
12
- for e in snippets
13
- ]
14
- )
15
- return summary
4
+ from logdetective.utils import load_prompts
5
+ from logdetective.server.models import Config
16
6
 
17
7
 
18
8
  def load_server_config(path: str | None) -> Config:
@@ -57,3 +47,12 @@ def get_log(config: Config):
57
47
 
58
48
  log.initialized = True
59
49
  return log
50
+
51
+
52
+ SERVER_CONFIG_PATH = os.environ.get("LOGDETECTIVE_SERVER_CONF", None)
53
+ SERVER_PROMPT_PATH = os.environ.get("LOGDETECTIVE_PROMPTS", None)
54
+
55
+ SERVER_CONFIG = load_server_config(SERVER_CONFIG_PATH)
56
+ PROMPT_CONFIG = load_prompts(SERVER_PROMPT_PATH)
57
+
58
+ LOG = get_log(SERVER_CONFIG)
@@ -1,6 +1,6 @@
1
1
  import enum
2
2
  import datetime
3
- from typing import Optional
3
+ from typing import Optional, List
4
4
 
5
5
  import backoff
6
6
 
@@ -131,6 +131,24 @@ class GitlabMergeRequestJobs(Base):
131
131
  )
132
132
  return mr
133
133
 
134
+ @classmethod
135
+ def get_by_mr_iid(
136
+ cls, forge: Forge, project_id: int, mr_iid
137
+ ) -> Optional["GitlabMergeRequestJobs"]:
138
+ """Get all the mr jobs saved for the specified mr iid and project id."""
139
+ with transaction(commit=False) as session:
140
+ comments = (
141
+ session.query(cls)
142
+ .filter(
143
+ GitlabMergeRequestJobs.forge == forge,
144
+ GitlabMergeRequestJobs.project_id == project_id,
145
+ GitlabMergeRequestJobs.mr_iid == mr_iid,
146
+ )
147
+ .all()
148
+ )
149
+
150
+ return comments
151
+
134
152
  @classmethod
135
153
  def get_or_create(
136
154
  cls,
@@ -170,12 +188,7 @@ class Comments(Base):
170
188
  index=True,
171
189
  comment="The associated merge request job (db) id",
172
190
  )
173
- forge = Column(
174
- Enum(Forge),
175
- nullable=False,
176
- index=True,
177
- comment="The forge name"
178
- )
191
+ forge = Column(Enum(Forge), nullable=False, index=True, comment="The forge name")
179
192
  comment_id = Column(
180
193
  String(50), # e.g. 'd5a3ff139356ce33e37e73add446f16869741b50'
181
194
  nullable=False,
@@ -362,6 +375,36 @@ class Comments(Base):
362
375
  comment = GitlabMergeRequestJobs.get_by_id(id_)
363
376
  return comment
364
377
 
378
+ @classmethod
379
+ def get_since(cls, time: datetime.datetime) -> Optional["Comments"]:
380
+ """Get all the comments created after the given time."""
381
+ with transaction(commit=False) as session:
382
+ comments = (
383
+ session.query(cls)
384
+ .filter(
385
+ Comments.created_at > time,
386
+ )
387
+ .all()
388
+ )
389
+
390
+ return comments
391
+
392
+ @classmethod
393
+ def get_by_mr_job(
394
+ cls, merge_request_job: GitlabMergeRequestJobs
395
+ ) -> Optional["Comments"]:
396
+ """Get the comment added for the specified merge request's job."""
397
+ with transaction(commit=False) as session:
398
+ comments = (
399
+ session.query(cls)
400
+ .filter(
401
+ Comments.merge_request_job == merge_request_job,
402
+ )
403
+ .first() # just one
404
+ )
405
+
406
+ return comments
407
+
365
408
 
366
409
  class Reactions(Base):
367
410
  """Store and count reactions received for
@@ -513,3 +556,32 @@ class Reactions(Base):
513
556
  )
514
557
 
515
558
  return reaction
559
+
560
+ @classmethod
561
+ @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
562
+ def delete( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
563
+ cls,
564
+ forge: Forge,
565
+ project_id: int,
566
+ mr_iid: int,
567
+ job_id: int,
568
+ comment_id: str,
569
+ reaction_types: List[str],
570
+ ) -> None:
571
+ """Remove rows with given reaction types
572
+
573
+ Args:
574
+ forge: forge name
575
+ project_id: forge project id
576
+ mr_iid: merge request forge iid
577
+ job_id: forge job id
578
+ comment_id: forge comment id
579
+ reaction_type: a str iterable, ex. ['thumbsup', 'thumbsdown']
580
+ """
581
+ for reaction_type in reaction_types:
582
+ reaction = cls.get_reaction_by_type(
583
+ forge, project_id, mr_iid, job_id, comment_id, reaction_type
584
+ )
585
+ with transaction(commit=True) as session:
586
+ session.delete(reaction)
587
+ session.flush()
@@ -0,0 +1,104 @@
1
+ import asyncio
2
+
3
+ from typing import List
4
+ from collections import Counter
5
+
6
+ import gitlab
7
+
8
+ from logdetective.server.models import TimePeriod
9
+ from logdetective.server.database.models import (
10
+ Comments,
11
+ Reactions,
12
+ GitlabMergeRequestJobs,
13
+ )
14
+
15
+
16
+ async def collect_emojis(gitlab_conn: gitlab.Gitlab, period: TimePeriod):
17
+ """
18
+ Collect emoji feedback from logdetective comments saved in database.
19
+ Check only comments created in the last given period of time.
20
+ """
21
+ comments = Comments.get_since(period.get_period_start_time())
22
+ comments_for_gitlab_connection = [
23
+ comment for comment in comments if comment.forge == gitlab_conn.url
24
+ ]
25
+ await collect_emojis_in_comments(comments_for_gitlab_connection, gitlab_conn)
26
+
27
+
28
+ async def collect_emojis_for_mr(
29
+ project_id: int, mr_iid: int, gitlab_conn: gitlab.Gitlab
30
+ ):
31
+ """
32
+ Collect emoji feedback from logdetective comments in the specified MR.
33
+ """
34
+ mr_jobs = GitlabMergeRequestJobs.get_by_mr_iid(gitlab_conn.url, project_id, mr_iid)
35
+ comments = [Comments.get_by_mr_job(mr_job) for mr_job in mr_jobs]
36
+ await collect_emojis_in_comments(comments, gitlab_conn)
37
+
38
+
39
+ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
40
+ comments: List[Comments], gitlab_conn: gitlab.Gitlab
41
+ ):
42
+ """
43
+ Collect emoji feedback from specified logdetective comments.
44
+ """
45
+ projects = {}
46
+ mrs = {}
47
+ for comment in comments:
48
+ mr_job_db = GitlabMergeRequestJobs.get_by_id(comment.merge_request_job_id)
49
+ if mr_job_db.id not in projects:
50
+ projects[mr_job_db.id] = project = await asyncio.to_thread(
51
+ gitlab_conn.projects.get, mr_job_db.project_id
52
+ )
53
+ else:
54
+ project = projects[mr_job_db.id]
55
+ mr_iid = mr_job_db.mr_iid
56
+ if mr_iid not in mrs:
57
+ mrs[mr_iid] = mr = await asyncio.to_thread(
58
+ project.mergerequests.get, mr_iid
59
+ )
60
+ else:
61
+ mr = mrs[mr_iid]
62
+
63
+ discussion = mr.discussions.get(comment.comment_id)
64
+
65
+ # Get the ID of the first note
66
+ note_id = discussion.attributes["notes"][0]["id"]
67
+ note = mr.notes.get(note_id)
68
+
69
+ emoji_counts = Counter(emoji.name for emoji in note.awardemojis.list())
70
+
71
+ # keep track of not updated reactions
72
+ # because we need to remove them
73
+ old_emojis = [
74
+ reaction.reaction_type
75
+ for reaction in Reactions.get_all_reactions(
76
+ comment.forge,
77
+ mr_job_db.project_id,
78
+ mr_job_db.mr_iid,
79
+ mr_job_db.job_id,
80
+ comment.comment_id,
81
+ )
82
+ ]
83
+ for key, value in emoji_counts.items():
84
+ Reactions.create_or_update(
85
+ comment.forge,
86
+ mr_job_db.project_id,
87
+ mr_job_db.mr_iid,
88
+ mr_job_db.job_id,
89
+ comment.comment_id,
90
+ key,
91
+ value,
92
+ )
93
+ if key in old_emojis:
94
+ old_emojis.remove(key)
95
+
96
+ # not updated reactions has been removed, drop them
97
+ Reactions.delete(
98
+ comment.forge,
99
+ mr_job_db.project_id,
100
+ mr_job_db.mr_iid,
101
+ mr_job_db.job_id,
102
+ comment.comment_id,
103
+ old_emojis,
104
+ )