logdetective 2.4.1__tar.gz → 2.6.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 (41) hide show
  1. {logdetective-2.4.1 → logdetective-2.6.0}/PKG-INFO +18 -3
  2. {logdetective-2.4.1 → logdetective-2.6.0}/README.md +14 -0
  3. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/prompts-summary-first.yml +0 -2
  4. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/prompts.yml +0 -3
  5. logdetective-2.6.0/logdetective/server/database/base.py +71 -0
  6. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/database/models/__init__.py +2 -2
  7. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/database/models/koji.py +43 -30
  8. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/database/models/merge_request_jobs.py +205 -186
  9. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/database/models/metrics.py +86 -59
  10. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/emoji.py +7 -7
  11. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/gitlab.py +6 -6
  12. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/metric.py +9 -9
  13. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/plot.py +12 -10
  14. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/server.py +19 -11
  15. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/templates/gitlab_full_comment.md.j2 +7 -7
  16. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/templates/gitlab_short_comment.md.j2 +7 -7
  17. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/utils.py +7 -0
  18. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/utils.py +36 -29
  19. {logdetective-2.4.1 → logdetective-2.6.0}/pyproject.toml +25 -6
  20. logdetective-2.4.1/logdetective/server/database/base.py +0 -66
  21. {logdetective-2.4.1 → logdetective-2.6.0}/LICENSE +0 -0
  22. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/__init__.py +0 -0
  23. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/constants.py +0 -0
  24. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/drain3.ini +0 -0
  25. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/extractors.py +0 -0
  26. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/logdetective.py +0 -0
  27. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/models.py +0 -0
  28. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/prompts-summary-only.yml +0 -0
  29. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/remote_log.py +0 -0
  30. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/__init__.py +0 -0
  31. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/compressors.py +0 -0
  32. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/config.py +0 -0
  33. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/database/__init__.py +0 -0
  34. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/database/models/exceptions.py +0 -0
  35. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/exceptions.py +0 -0
  36. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/koji.py +0 -0
  37. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/llm.py +0 -0
  38. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/models.py +0 -0
  39. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/server/templates/base_response.html.j2 +0 -0
  40. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective/skip_snippets.yml +0 -0
  41. {logdetective-2.4.1 → logdetective-2.6.0}/logdetective.1.asciidoc +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logdetective
3
- Version: 2.4.1
3
+ Version: 2.6.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
  License-File: LICENSE
@@ -27,6 +27,8 @@ Requires-Dist: aiohttp (>=3.7.4,<4.0.0)
27
27
  Requires-Dist: aiolimiter (>=1.0.0,<2.0.0) ; extra == "server"
28
28
  Requires-Dist: aioresponses (>=0.7.8,<0.8.0) ; extra == "testing"
29
29
  Requires-Dist: alembic (>=1.13.3,<2.0.0) ; extra == "server" or extra == "server-testing"
30
+ Requires-Dist: asciidoc[testing] (>=10.2.1,<11.0.0) ; extra == "testing"
31
+ Requires-Dist: asyncpg (>=0.30.0,<0.31.0) ; extra == "server" or extra == "server-testing"
30
32
  Requires-Dist: backoff (==2.2.1) ; extra == "server" or extra == "server-testing"
31
33
  Requires-Dist: drain3 (>=0.9.11,<0.10.0)
32
34
  Requires-Dist: fastapi (>=0.111.1,<1.0.0) ; extra == "server" or extra == "server-testing"
@@ -37,11 +39,10 @@ Requires-Dist: llama-cpp-python (>0.2.56,!=0.2.86,<1.0.0)
37
39
  Requires-Dist: matplotlib (>=3.8.4,<4.0.0) ; extra == "server" or extra == "server-testing"
38
40
  Requires-Dist: numpy (>=1.26.0)
39
41
  Requires-Dist: openai (>=1.82.1,<2.0.0) ; extra == "server" or extra == "server-testing"
40
- Requires-Dist: psycopg2 (>=2.9.9,<3.0.0) ; extra == "server"
41
- Requires-Dist: psycopg2-binary (>=2.9.9,<3.0.0) ; extra == "server-testing"
42
42
  Requires-Dist: pydantic (>=2.8.2,<3.0.0)
43
43
  Requires-Dist: pytest (>=8.4.1,<9.0.0) ; extra == "testing"
44
44
  Requires-Dist: pytest-asyncio (>=1.1.0,<2.0.0) ; extra == "testing"
45
+ Requires-Dist: pytest-cov[testing] (>=7.0.0,<8.0.0) ; extra == "testing"
45
46
  Requires-Dist: pytest-mock (>=3.14.1,<4.0.0) ; extra == "server-testing"
46
47
  Requires-Dist: python-gitlab (>=4.4.0)
47
48
  Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
@@ -127,6 +128,20 @@ Note that streaming with some models (notably Meta-Llama-3) is broken and can be
127
128
 
128
129
  logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename_suffix Q5_K_M.gguf --no-stream
129
130
 
131
+ Choice of LLM
132
+ -------------
133
+
134
+ While Log Detective is compatible with a wide range of LLMs, it does require an instruction tuned model to function properly.
135
+
136
+ Whether or not the model has been trained to work with instructions can be determined by examining the model card, or simply by checking if it has `instruct` in its name.
137
+
138
+ When deployed as a server, Log Detective uses `/chat/completions` API as defined by OpenAI. The API must support both `system` and `user` roles, in order to properly work with a system prompt.
139
+
140
+ Configuration fields `system_role` and `user_role` can be used to set role names for APIs with non-standard roles.
141
+
142
+ > **Note:**
143
+ > In cases when no system role is available, it is possible to set both fields to the same value. This will concatenate system and standard prompt.
144
+ > This may have negative impact coherence of response.
130
145
 
131
146
  Real Example
132
147
  ------------
@@ -73,6 +73,20 @@ Note that streaming with some models (notably Meta-Llama-3) is broken and can be
73
73
 
74
74
  logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename_suffix Q5_K_M.gguf --no-stream
75
75
 
76
+ Choice of LLM
77
+ -------------
78
+
79
+ While Log Detective is compatible with a wide range of LLMs, it does require an instruction tuned model to function properly.
80
+
81
+ Whether or not the model has been trained to work with instructions can be determined by examining the model card, or simply by checking if it has `instruct` in its name.
82
+
83
+ When deployed as a server, Log Detective uses `/chat/completions` API as defined by OpenAI. The API must support both `system` and `user` roles, in order to properly work with a system prompt.
84
+
85
+ Configuration fields `system_role` and `user_role` can be used to set role names for APIs with non-standard roles.
86
+
87
+ > **Note:**
88
+ > In cases when no system role is available, it is possible to set both fields to the same value. This will concatenate system and standard prompt.
89
+ > This may have negative impact coherence of response.
76
90
 
77
91
  Real Example
78
92
  ------------
@@ -18,5 +18,3 @@ prompt_template: |
18
18
  Snippets:
19
19
 
20
20
  {}
21
-
22
- Analysis:
@@ -19,7 +19,6 @@ prompt_template: |
19
19
 
20
20
  {}
21
21
 
22
- Analysis:
23
22
 
24
23
  snippet_prompt_template: |
25
24
  Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution
@@ -30,7 +29,6 @@ snippet_prompt_template: |
30
29
 
31
30
  {}
32
31
 
33
- Analysis:
34
32
 
35
33
  prompt_template_staged: |
36
34
  Given following log snippets, their explanation, and nothing else, explain what failure, if any, occurred during build of this package.
@@ -47,7 +45,6 @@ prompt_template_staged: |
47
45
 
48
46
  {}
49
47
 
50
- Analysis:
51
48
 
52
49
  # System prompts
53
50
  # System prompts are meant to serve as general guide for model behavior,
@@ -0,0 +1,71 @@
1
+ from os import getenv
2
+ from contextlib import asynccontextmanager
3
+ from sqlalchemy.orm import DeclarativeBase
4
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
5
+ from logdetective import logger
6
+
7
+
8
+ def get_pg_url() -> str:
9
+ """create postgresql connection string"""
10
+ return (
11
+ f"postgresql+asyncpg://{getenv('POSTGRESQL_USER')}"
12
+ f":{getenv('POSTGRESQL_PASSWORD')}@{getenv('POSTGRESQL_HOST', 'postgres')}"
13
+ f":{getenv('POSTGRESQL_PORT', '5432')}/{getenv('POSTGRESQL_DATABASE')}"
14
+ )
15
+
16
+
17
+ # To log SQL statements, set SQLALCHEMY_ECHO env. var. to True|T|Yes|Y|1
18
+ sqlalchemy_echo = getenv("SQLALCHEMY_ECHO", "False").lower() in (
19
+ "true",
20
+ "t",
21
+ "yes",
22
+ "y",
23
+ "1",
24
+ )
25
+ engine = create_async_engine(get_pg_url(), echo=sqlalchemy_echo)
26
+ SessionFactory = async_sessionmaker(autoflush=True, bind=engine) # pylint: disable=invalid-name
27
+
28
+
29
+ class Base(DeclarativeBase):
30
+ """Declarative base class for all ORM models."""
31
+
32
+
33
+ @asynccontextmanager
34
+ async def transaction(commit: bool = False):
35
+ """
36
+ Context manager for 'framing' a db transaction.
37
+
38
+ Args:
39
+ commit: Whether to call `Session.commit()` upon exiting the context. Should be set to True
40
+ if any changes are made within the context. Defaults to False.
41
+ """
42
+
43
+ session = SessionFactory()
44
+ async with session:
45
+ try:
46
+ yield session
47
+ if commit:
48
+ await session.commit()
49
+ except Exception as ex:
50
+ logger.warning("Exception while working with database: %s", str(ex))
51
+ await session.rollback()
52
+ raise
53
+ finally:
54
+ await session.close()
55
+
56
+
57
+ async def init():
58
+ """Init db"""
59
+ async with engine.begin() as conn:
60
+ await conn.run_sync(Base.metadata.create_all)
61
+ logger.debug("Database initialized")
62
+
63
+
64
+ async def destroy():
65
+ """Destroy db"""
66
+ async with engine.begin() as conn:
67
+ await conn.run_sync(Base.metadata.drop_all)
68
+ logger.warning("Database cleaned")
69
+
70
+
71
+ DB_MAX_RETRIES = 3 # How many times retry a db operation
@@ -1,4 +1,3 @@
1
- from logdetective.server.database.base import Base
2
1
  from logdetective.server.database.models.merge_request_jobs import (
3
2
  Forge,
4
3
  GitlabMergeRequestJobs,
@@ -18,8 +17,9 @@ from logdetective.server.database.models.exceptions import (
18
17
  KojiTaskAnalysisTimeoutError,
19
18
  )
20
19
 
20
+ # pylint: disable=undefined-all-variable
21
+
21
22
  __all__ = [
22
- Base.__name__,
23
23
  GitlabMergeRequestJobs.__name__,
24
24
  Comments.__name__,
25
25
  Reactions.__name__,
@@ -1,6 +1,9 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
1
3
  from datetime import datetime, timedelta, timezone
2
- from sqlalchemy import Column, BigInteger, DateTime, ForeignKey, Integer, String
3
- from sqlalchemy.orm import relationship
4
+ from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, select
5
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
6
+
4
7
  from sqlalchemy.exc import OperationalError
5
8
  import backoff
6
9
 
@@ -21,42 +24,47 @@ class KojiTaskAnalysis(Base):
21
24
 
22
25
  __tablename__ = "koji_task_analysis"
23
26
 
24
- id = Column(Integer, primary_key=True)
25
- koji_instance = Column(String(255), nullable=False, index=True)
26
- task_id = Column(BigInteger, nullable=False, index=True, unique=True)
27
- log_file_name = Column(String(255), nullable=False, index=True)
28
- request_received_at = Column(
29
- DateTime,
27
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
28
+ koji_instance: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
29
+ task_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True, unique=True)
30
+ log_file_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
31
+ request_received_at: Mapped[datetime] = mapped_column(
32
+ DateTime(timezone=True),
30
33
  nullable=False,
31
34
  index=True,
32
35
  default=datetime.now(timezone.utc),
33
36
  comment="Timestamp when the request was received",
34
37
  )
35
- response_id = Column(
38
+ response_id: Mapped[Optional[int]] = mapped_column(
36
39
  Integer,
37
40
  ForeignKey("analyze_request_metrics.id"),
38
41
  nullable=True,
39
42
  index=False,
40
43
  comment="The id of the analyze request metrics for this task",
41
44
  )
42
- response = relationship("AnalyzeRequestMetrics")
45
+ response: Mapped[Optional["AnalyzeRequestMetrics"]] = relationship(
46
+ "AnalyzeRequestMetrics",
47
+ back_populates="koji_tasks"
48
+ )
43
49
 
44
50
  @classmethod
45
51
  @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
46
- def create_or_restart(cls, koji_instance: str, task_id: int, log_file_name: str):
52
+ async def create_or_restart(
53
+ cls, koji_instance: str, task_id: int, log_file_name: str
54
+ ):
47
55
  """Create a new koji task analysis"""
48
- with transaction(commit=True) as session:
56
+ query = select(cls).filter(
57
+ cls.koji_instance == koji_instance, cls.task_id == task_id
58
+ )
59
+ async with transaction(commit=True) as session:
49
60
  # Check if the task analysis already exists
50
- koji_task_analysis = (
51
- session.query(cls)
52
- .filter_by(koji_instance=koji_instance, task_id=task_id)
53
- .first()
54
- )
61
+ query_result = await session.execute(query)
62
+ koji_task_analysis = query_result.first()
55
63
  if koji_task_analysis:
56
64
  # If it does, update the request_received_at timestamp
57
65
  koji_task_analysis.request_received_at = datetime.now(timezone.utc)
58
66
  session.add(koji_task_analysis)
59
- session.flush()
67
+ await session.flush()
60
68
  return
61
69
 
62
70
  # If it doesn't, create a new one
@@ -65,14 +73,19 @@ class KojiTaskAnalysis(Base):
65
73
  koji_task_analysis.task_id = task_id
66
74
  koji_task_analysis.log_file_name = log_file_name
67
75
  session.add(koji_task_analysis)
68
- session.flush()
76
+ await session.flush()
69
77
 
70
78
  @classmethod
71
79
  @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
72
- def add_response(cls, task_id: int, metric_id: int):
80
+ async def add_response(cls, task_id: int, metric_id: int):
73
81
  """Add a response to a koji task analysis"""
74
- with transaction(commit=True) as session:
75
- koji_task_analysis = session.query(cls).filter_by(task_id=task_id).first()
82
+ query = select(cls).filter(cls.task_id == task_id)
83
+ metrics_query = select(AnalyzeRequestMetrics).filter(
84
+ AnalyzeRequestMetrics.id == metric_id
85
+ )
86
+ async with transaction(commit=True) as session:
87
+ query_result = await session.execute(query)
88
+ koji_task_analysis = query_result.scalars().first()
76
89
  # Ensure that the task analysis doesn't already have a response
77
90
  if koji_task_analysis.response:
78
91
  # This is probably due to an analysis that took so long that
@@ -81,20 +94,20 @@ class KojiTaskAnalysis(Base):
81
94
  # returned to the consumer, so we'll just drop this extra one
82
95
  # on the floor and keep the one saved in the database.
83
96
  return
84
-
85
- metric = (
86
- session.query(AnalyzeRequestMetrics).filter_by(id=metric_id).first()
87
- )
97
+ metrics_query_result = await session.execute(metrics_query)
98
+ metric = metrics_query_result.scalars().first()
88
99
  koji_task_analysis.response = metric
89
100
  session.add(koji_task_analysis)
90
- session.flush()
101
+ await session.flush()
91
102
 
92
103
  @classmethod
93
104
  @backoff.on_exception(backoff.expo, OperationalError, max_tries=DB_MAX_RETRIES)
94
- def get_response_by_task_id(cls, task_id: int) -> KojiStagedResponse:
105
+ async def get_response_by_task_id(cls, task_id: int) -> KojiStagedResponse:
95
106
  """Get a koji task analysis by task id"""
96
- with transaction(commit=False) as session:
97
- koji_task_analysis = session.query(cls).filter_by(task_id=task_id).first()
107
+ query = select(cls).filter(cls.task_id == task_id)
108
+ async with transaction(commit=False) as session:
109
+ query_result = await session.execute(query)
110
+ koji_task_analysis = query_result.scalars().first()
98
111
  if not koji_task_analysis:
99
112
  raise KojiTaskNotFoundError(f"Task {task_id} not yet analyzed")
100
113