lightman_ai 0.20.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.

Potentially problematic release.


This version of lightman_ai might be problematic. Click here for more details.

File without changes
File without changes
@@ -0,0 +1,26 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from typing import Never
4
+
5
+ from lightman_ai.article.models import SelectedArticlesList
6
+ from pydantic_ai import Agent
7
+ from pydantic_ai.models.google import GoogleModel
8
+ from pydantic_ai.models.openai import OpenAIModel
9
+
10
+
11
+ class BaseAgent(ABC):
12
+ _class: type[OpenAIModel] | type[GoogleModel]
13
+ _default_model_name: str
14
+
15
+ def __init__(self, system_prompt: str, model: str | None = None, logger: logging.Logger | None = None) -> None:
16
+ agent_model = self._class(model or self._default_model_name)
17
+ self.agent: Agent[Never, SelectedArticlesList] = Agent(
18
+ model=agent_model, output_type=SelectedArticlesList, system_prompt=system_prompt
19
+ )
20
+ self.logger = logger or logging.getLogger("lightman")
21
+
22
+ def get_prompt_result(self, prompt: str) -> SelectedArticlesList:
23
+ return self._run_prompt(prompt)
24
+
25
+ @abstractmethod
26
+ def _run_prompt(self, prompt: str) -> SelectedArticlesList: ...
@@ -0,0 +1,4 @@
1
+ from lightman_ai.core.exceptions import BaseHackermanError
2
+
3
+
4
+ class BaseAgentError(BaseHackermanError): ...
File without changes
@@ -0,0 +1,19 @@
1
+ from typing import override
2
+
3
+ from lightman_ai.ai.base.agent import BaseAgent
4
+ from lightman_ai.ai.gemini.exceptions import map_gemini_exceptions
5
+ from lightman_ai.article.models import SelectedArticlesList
6
+ from pydantic_ai.models.google import GoogleModel
7
+
8
+
9
+ class GeminiAgent(BaseAgent):
10
+ """Class that provides an interface to operate with the Gemini model."""
11
+
12
+ _class = GoogleModel
13
+ _default_model_name = "gemini-2.5-pro-preview-05-06"
14
+
15
+ @override
16
+ def _run_prompt(self, prompt: str) -> SelectedArticlesList:
17
+ with map_gemini_exceptions():
18
+ result = self.agent.run_sync(prompt)
19
+ return result.output
@@ -0,0 +1,19 @@
1
+ from collections.abc import Generator
2
+ from contextlib import contextmanager
3
+ from typing import Any
4
+
5
+ from lightman_ai.core.exceptions import BaseHackermanError
6
+
7
+
8
+ class BaseGeminiError(BaseHackermanError): ...
9
+
10
+
11
+ class GeminiError(BaseGeminiError): ...
12
+
13
+
14
+ @contextmanager
15
+ def map_gemini_exceptions() -> Generator[Any, Any]:
16
+ try:
17
+ yield
18
+ except Exception as err:
19
+ raise GeminiError from err
File without changes
@@ -0,0 +1,29 @@
1
+ import time
2
+ from typing import override
3
+
4
+ from lightman_ai.ai.base.agent import BaseAgent
5
+ from lightman_ai.ai.openai.exceptions import LimitTokensExceededError, map_openai_exceptions
6
+ from lightman_ai.article.models import SelectedArticlesList
7
+ from pydantic_ai.agent import AgentRunResult
8
+ from pydantic_ai.models.openai import OpenAIModel
9
+
10
+
11
+ class OpenAIAgent(BaseAgent):
12
+ """Class that provides an interface to operate with the OpenAI model."""
13
+
14
+ _class = OpenAIModel
15
+ _default_model_name = "gpt-4.1"
16
+
17
+ def _execute_agent(self, prompt: str) -> AgentRunResult[SelectedArticlesList]:
18
+ with map_openai_exceptions():
19
+ return self.agent.run_sync(prompt)
20
+
21
+ @override
22
+ def _run_prompt(self, prompt: str) -> SelectedArticlesList:
23
+ try:
24
+ result = self._execute_agent(prompt)
25
+ except LimitTokensExceededError as err:
26
+ self.logger.warning("waiting %s", err.wait_time)
27
+ time.sleep(err.wait_time)
28
+ result = self._execute_agent(prompt)
29
+ return result.output
@@ -0,0 +1,85 @@
1
+ import math
2
+ import re
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
5
+ from typing import Any, override
6
+
7
+ from lightman_ai.core.exceptions import BaseHackermanError
8
+ from pydantic_ai.exceptions import ModelHTTPError
9
+
10
+ from openai import RateLimitError
11
+
12
+
13
+ class BaseOpenAIError(BaseHackermanError): ...
14
+
15
+
16
+ class UnknownOpenAIError(BaseOpenAIError): ...
17
+
18
+
19
+ class OpenAIRateLimitError(BaseOpenAIError):
20
+ regex: str
21
+
22
+ @classmethod
23
+ def get_matches(cls, message: str) -> tuple[str, ...]:
24
+ match = re.search(cls.regex, message)
25
+ return match.groups() if match else ()
26
+
27
+ @classmethod
28
+ def is_match(cls, message: str) -> bool:
29
+ return bool(re.search(cls.regex, message))
30
+
31
+
32
+ class InputTooLargeError(OpenAIRateLimitError):
33
+ limit: int
34
+ requested: int
35
+
36
+ regex: str = (
37
+ r"Limit (\d+), Requested (\d+)\. The input or output tokens must be reduced in order to run successfully\."
38
+ )
39
+
40
+ def __init__(self, values: tuple[str, ...]) -> None:
41
+ self.limit = int(values[0])
42
+ self.requested = int(values[1])
43
+
44
+
45
+ class LimitTokensExceededError(OpenAIRateLimitError):
46
+ limit: int
47
+ used: int
48
+ requested: int
49
+ wait_time: int
50
+
51
+ regex = r"Limit (\d+), Used (\d+), Requested (\d+)\. Please try again in (\d+\.?(\d+)?)s\."
52
+
53
+ def __init__(self, values: tuple[str, ...]) -> None:
54
+ self.limit = int(values[0])
55
+ self.used = int(values[1])
56
+ self.requested = int(values[2])
57
+ self.wait_time = math.ceil(float(values[3]))
58
+
59
+
60
+ class QuotaExceededError(OpenAIRateLimitError):
61
+ regex = r"You exceeded your current quota"
62
+
63
+ @override
64
+ @classmethod
65
+ def get_matches(cls, message: str) -> tuple[str, ...]:
66
+ match = re.search(cls.regex, message)
67
+ return match.groups() if match else ()
68
+
69
+
70
+ type TRateLimitErr = type[InputTooLargeError | LimitTokensExceededError]
71
+ RATE_LIMIT_ERRORS: list[TRateLimitErr] = [LimitTokensExceededError, InputTooLargeError]
72
+
73
+
74
+ @contextmanager
75
+ def map_openai_exceptions() -> Generator[Any, Any]:
76
+ try:
77
+ yield
78
+ except RateLimitError as err:
79
+ for error in RATE_LIMIT_ERRORS:
80
+ if matches := error.get_matches(err.message):
81
+ raise error(matches) from err
82
+ raise UnknownOpenAIError from err
83
+ except ModelHTTPError as err:
84
+ if QuotaExceededError.is_match(err.message):
85
+ raise QuotaExceededError() from err
@@ -0,0 +1,14 @@
1
+ from lightman_ai.ai.base.agent import BaseAgent
2
+ from lightman_ai.ai.gemini.agent import GeminiAgent
3
+ from lightman_ai.ai.openai.agent import OpenAIAgent
4
+
5
+ AGENT_MAPPING = {"openai": OpenAIAgent, "gemini": GeminiAgent}
6
+
7
+
8
+ def get_agent_class_from_agent_name(agent: str) -> type[BaseAgent]:
9
+ if agent not in AGENT_MAPPING:
10
+ raise ValueError(f"Agent '{agent}' is not recognized. Available agents: {list(AGENT_MAPPING.keys())}")
11
+ return AGENT_MAPPING[agent]
12
+
13
+
14
+ AGENT_CHOICES = list(AGENT_MAPPING.keys())
File without changes
@@ -0,0 +1,64 @@
1
+ from abc import ABC
2
+ from typing import override
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class BaseArticle(BaseModel, ABC):
8
+ """Base abstract class for all Articles."""
9
+
10
+ title: str
11
+ link: str
12
+
13
+ @override
14
+ def __eq__(self, value: object) -> bool:
15
+ if not isinstance(value, BaseArticle):
16
+ return False
17
+
18
+ return self.link == value.link
19
+
20
+ @override
21
+ def __hash__(self) -> int:
22
+ return hash(self.link.encode())
23
+
24
+
25
+ class SelectedArticle(BaseArticle):
26
+ why_is_relevant: str
27
+ relevance_score: int
28
+
29
+
30
+ class Article(BaseArticle):
31
+ title: str
32
+ description: str
33
+
34
+
35
+ class BaseArticlesList[TArticle: BaseArticle](BaseModel):
36
+ articles: list[TArticle]
37
+
38
+ def __len__(self) -> int:
39
+ return len(self.articles)
40
+
41
+ @property
42
+ def titles(self) -> list[str]:
43
+ return [new.title for new in self.articles]
44
+
45
+ @property
46
+ def links(self) -> list[str]:
47
+ return [new.link for new in self.articles]
48
+
49
+
50
+ class SelectedArticlesList(BaseArticlesList[SelectedArticle]):
51
+ """
52
+ Model that holds all the articles that were selected by the AI model.
53
+
54
+ It saves the minimum information so that they are identifiable.
55
+ """
56
+
57
+ def get_articles_with_score_gte_threshold(self, score_threshold: int) -> list[SelectedArticle]:
58
+ if not score_threshold > 0:
59
+ raise ValueError("score threshold must be > 0.")
60
+ return [article for article in self.articles if article.relevance_score >= score_threshold]
61
+
62
+
63
+ class ArticlesList(BaseArticlesList[Article]):
64
+ """Model that saves articles with all their information."""
lightman_ai/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ import logging
2
+ from importlib import metadata
3
+
4
+ import click
5
+ from dotenv import load_dotenv
6
+ from lightman_ai.ai.utils import AGENT_CHOICES
7
+ from lightman_ai.constants import DEFAULT_CONFIG_FILE, DEFAULT_CONFIG_SECTION, DEFAULT_ENV_FILE
8
+ from lightman_ai.core.config import FileConfig, FinalConfig, PromptConfig
9
+ from lightman_ai.core.exceptions import ConfigNotFoundError, InvalidConfigError, PromptNotFoundError
10
+ from lightman_ai.core.sentry import configure_sentry
11
+ from lightman_ai.main import lightman
12
+
13
+ logger = logging.getLogger("lightman")
14
+
15
+
16
+ def get_version() -> str:
17
+ """Read version from VERSION file."""
18
+ return metadata.version("lightman-ai")
19
+
20
+
21
+ @click.group()
22
+ @click.version_option(version=get_version(), prog_name="lightman-ai")
23
+ def entry_point() -> None:
24
+ pass
25
+
26
+
27
+ @entry_point.command()
28
+ @click.option("--agent", type=click.Choice(AGENT_CHOICES), help=("Which agent to use"))
29
+ @click.option(
30
+ "--prompt-file",
31
+ type=str,
32
+ default=DEFAULT_CONFIG_FILE,
33
+ help=(f"Location of the config file containing the prompts. Defaults to `{DEFAULT_CONFIG_FILE}`."),
34
+ )
35
+ @click.option("--prompt", type=str, help=("Which prompt to use"))
36
+ @click.option("--model", type=str, default=None, help=("Which model to use. Must be set in conjunction with --agent."))
37
+ @click.option(
38
+ "--score",
39
+ type=int,
40
+ help=("The minimum score relevance that an article needs to have to be considered relevant"),
41
+ default=None,
42
+ )
43
+ @click.option(
44
+ "--config-file",
45
+ type=str,
46
+ default=DEFAULT_CONFIG_FILE,
47
+ help=(f"The config file path. Defaults to `{DEFAULT_CONFIG_FILE}`."),
48
+ )
49
+ @click.option(
50
+ "--config",
51
+ type=str,
52
+ default=DEFAULT_CONFIG_SECTION,
53
+ help=(f"The config settings to use. Defaults to `{DEFAULT_CONFIG_SECTION}`."),
54
+ )
55
+ @click.option(
56
+ "--env-file",
57
+ type=str,
58
+ default=DEFAULT_ENV_FILE,
59
+ help=(f"Path to the environment file. Defaults to `{DEFAULT_ENV_FILE}`."),
60
+ )
61
+ @click.option(
62
+ "--dry-run",
63
+ is_flag=True,
64
+ help=(
65
+ "When set, runs the script without publishing the results to the integrated services, just shows them in stdout."
66
+ ),
67
+ )
68
+ def run(
69
+ agent: str,
70
+ prompt: str,
71
+ prompt_file: str,
72
+ model: str | None,
73
+ score: int | None,
74
+ config_file: str,
75
+ config: str,
76
+ env_file: str,
77
+ dry_run: bool,
78
+ ) -> int:
79
+ """
80
+ Entrypoint of the application.
81
+
82
+ Holds no logic. It calls the main method and returns 0 when succesful .
83
+ """
84
+ load_dotenv(env_file)
85
+ configure_sentry()
86
+ try:
87
+ prompt_config = PromptConfig.get_config_from_file(path=prompt_file)
88
+ config_from_file = FileConfig.get_config_from_file(config_section=config, path=config_file)
89
+ final_config = FinalConfig.init_from_dict(
90
+ data={
91
+ "agent": agent or config_from_file.agent,
92
+ "prompt": prompt or config_from_file.prompt,
93
+ "score_threshold": score or config_from_file.score_threshold,
94
+ "model": model or config_from_file.model,
95
+ }
96
+ )
97
+
98
+ prompt_text = prompt_config.get_prompt(final_config.prompt)
99
+ except (InvalidConfigError, PromptNotFoundError, ConfigNotFoundError) as err:
100
+ raise click.BadParameter(err.args[0]) from None
101
+
102
+ relevant_articles = lightman(
103
+ agent=final_config.agent,
104
+ prompt=prompt_text,
105
+ score_threshold=final_config.score_threshold,
106
+ dry_run=dry_run,
107
+ project_key=config_from_file.service_desk_project_key,
108
+ request_id_type=config_from_file.service_desk_request_id_type,
109
+ model=final_config.model,
110
+ )
111
+ relevant_articles_metadata = [f"{article.title} ({article.link})" for article in relevant_articles]
112
+ logger.warning("Found these articles: \n- %s", "\n- ".join(relevant_articles_metadata))
113
+ return 0
@@ -0,0 +1,5 @@
1
+ DEFAULT_CONFIG_SECTION = "default"
2
+
3
+ DEFAULT_CONFIG_FILE = "lightman.toml"
4
+
5
+ DEFAULT_ENV_FILE = ".env"
File without changes
@@ -0,0 +1,92 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any, Self, cast
5
+
6
+ import tomlkit
7
+ from lightman_ai.core.exceptions import ConfigNotFoundError, InvalidConfigError, PromptNotFoundError
8
+ from pydantic import BaseModel, ConfigDict, PositiveInt, ValidationError, field_validator
9
+ from pydantic_core.core_schema import FieldValidationInfo
10
+
11
+ PROMPTS_SECTION = "prompts"
12
+ logger = logging.getLogger("lightman")
13
+
14
+
15
+ def read_config_from_file(*, config_section: str, path: str) -> dict[str, Any]:
16
+ fpath = Path(path)
17
+ if not fpath.exists():
18
+ raise ConfigNotFoundError(f"`{path}` not found!")
19
+
20
+ content = fpath.read_text()
21
+ parsed_content = tomlkit.parse(content)
22
+
23
+ return cast("dict[str, Any]", parsed_content.get(config_section, {}))
24
+
25
+
26
+ @dataclass
27
+ class PromptConfig:
28
+ prompts: dict[str, str]
29
+
30
+ @classmethod
31
+ def get_config_from_file(cls, path: str) -> Self:
32
+ config = read_config_from_file(config_section=PROMPTS_SECTION, path=path)
33
+ return cls(prompts=config)
34
+
35
+ def get_prompt(self, prompt: str) -> str:
36
+ if prompt not in self.prompts:
37
+ raise PromptNotFoundError(f"prompt `{prompt}` not found in config file")
38
+ return self.prompts[prompt]
39
+
40
+
41
+ class FileConfig(BaseModel):
42
+ prompt: str | None = None
43
+ agent: str | None = None
44
+ score_threshold: int | None = None
45
+ service_desk_project_key: str | None = None
46
+ service_desk_request_id_type: str | None = None
47
+ model: str | None = None
48
+
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+ @field_validator("service_desk_project_key", "service_desk_request_id_type", mode="before")
52
+ @classmethod
53
+ def _cast_service_fields(cls, v: Any, info: FieldValidationInfo) -> Any:
54
+ if v is None:
55
+ return v
56
+ if isinstance(v, int):
57
+ return str(v)
58
+ if isinstance(v, str):
59
+ if v == "":
60
+ return v
61
+ try:
62
+ int(v)
63
+ except ValueError as err:
64
+ raise ValueError(f"{info.field_name} must be a number!") from err
65
+ return v
66
+
67
+ @classmethod
68
+ def get_config_from_file(cls, config_section: str, path: str) -> Self:
69
+ try:
70
+ config = read_config_from_file(config_section=config_section, path=path)
71
+ except ConfigNotFoundError:
72
+ logger.warning("Config file `%s` not found! Proceeding with empty config.", path)
73
+ config = {}
74
+ return cls(**config)
75
+
76
+
77
+ class FinalConfig(BaseModel):
78
+ prompt: str
79
+ agent: str
80
+ score_threshold: PositiveInt
81
+ model: str | None = None
82
+
83
+ @classmethod
84
+ def init_from_dict(cls, data: dict[str, Any]) -> Self:
85
+ try:
86
+ return cls(**data)
87
+ except ValidationError as error:
88
+ error_list = []
89
+ for err in error.errors():
90
+ error_list.append(f"`{err['loc'][0]}`: {err['msg']}")
91
+ err_msg = f"Invalid configuration provided: [{','.join(error_list)}]"
92
+ raise InvalidConfigError(err_msg) from error
@@ -0,0 +1,13 @@
1
+ class BaseHackermanError(Exception): ...
2
+
3
+
4
+ class BaseConfigError(BaseHackermanError): ...
5
+
6
+
7
+ class InvalidConfigError(BaseConfigError): ...
8
+
9
+
10
+ class ConfigNotFoundError(BaseConfigError): ...
11
+
12
+
13
+ class PromptNotFoundError(BaseConfigError): ...
@@ -0,0 +1,28 @@
1
+ import logging
2
+ import os
3
+ from importlib import metadata
4
+
5
+ import sentry_sdk
6
+ from sentry_sdk.integrations.logging import LoggingIntegration
7
+
8
+
9
+ def configure_sentry() -> None:
10
+ """Configure Sentry for error tracking and performance monitoring using env vars with fallbacks."""
11
+ try:
12
+ if not os.getenv("SENTRY_DSN"):
13
+ logging.getLogger("lightman").info("SENTRY_DSN not configured, skipping Sentry initialization")
14
+ return
15
+
16
+ # Logging level from ENV
17
+ logging_level_str = os.getenv("LOGGING_LEVEL", "ERROR").upper()
18
+ logging_level = getattr(logging, logging_level_str, logging.ERROR)
19
+
20
+ # Set up logging integration
21
+ sentry_logging = LoggingIntegration(level=logging.INFO, event_level=logging_level)
22
+
23
+ sentry_sdk.init(
24
+ release=metadata.version("lightman-ai"),
25
+ integrations=[sentry_logging],
26
+ )
27
+ except Exception as e:
28
+ logging.getLogger("lightman").warning("Could not instantiate Sentry! %s.\nContinuing with the execution.", e)
@@ -0,0 +1,14 @@
1
+ from typing import Any
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
8
+ super().__init__(*args, **kwargs)
9
+
10
+ OPENAI_ENCODING: str = "cl100k_base"
11
+ PARALLEL_WORKERS: int = 5
12
+
13
+
14
+ settings = Settings()
File without changes
@@ -0,0 +1 @@
1
+ # This file marks the service_desk directory as a Python package.
@@ -0,0 +1,7 @@
1
+ import httpx
2
+ from lightman_ai.integrations.service_desk.exceptions import ServiceDeskServerError
3
+
4
+ # Retry configuration for ServiceDeskIntegration
5
+ SERVICE_DESK_RETRY_ON = (httpx.TransportError, httpx.TimeoutException, ServiceDeskServerError)
6
+ SERVICE_DESK_RETRY_ATTEMPTS = 3
7
+ SERVICE_DESK_RETRY_TIMEOUT = 10
@@ -0,0 +1,76 @@
1
+ import logging
2
+ from collections.abc import AsyncGenerator
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from lightman_ai.core.exceptions import BaseHackermanError
8
+
9
+ logger = logging.getLogger("lightman")
10
+
11
+
12
+ class BaseServiceDeskError(BaseHackermanError):
13
+ """Base exception for all SERVICE_DESK integration errors."""
14
+
15
+
16
+ class ServiceDeskConnectionError(BaseServiceDeskError):
17
+ """Raised when there are network or connection issues with SERVICE_DESK."""
18
+
19
+
20
+ class ServiceDeskAuthenticationError(BaseServiceDeskError):
21
+ """Raised when authentication with SERVICE_DESK fails."""
22
+
23
+
24
+ class ServiceDeskPermissionError(BaseServiceDeskError):
25
+ """Raised when user lacks permissions to perform the requested operation."""
26
+
27
+
28
+ class MissingIssueIDError(BaseServiceDeskError):
29
+ """Error for when we don't get the issue ID in the response."""
30
+
31
+ pass
32
+
33
+
34
+ class ServiceDeskHTTPStatusError(BaseServiceDeskError):
35
+ def __init__(self, status_code: int, message: str) -> None:
36
+ self.status_code = status_code
37
+ self.message = message
38
+ super().__init__(f"SERVICE_DESK API error {status_code}: {message}")
39
+
40
+
41
+ class ServiceDeskClientError(ServiceDeskHTTPStatusError):
42
+ """Exception for unmapped HTTP 4xx errors."""
43
+
44
+ pass
45
+
46
+
47
+ class ServiceDeskServerError(ServiceDeskHTTPStatusError):
48
+ """Exception for HTTP 5xx errors."""
49
+
50
+ pass
51
+
52
+
53
+ class ServiceDeskApiResponseParsingError(BaseServiceDeskError):
54
+ """Raised when SERVICE_DESK API returns an error response that cannot be parsed."""
55
+
56
+ def __init__(self, status_code: int, raw_response: str) -> None:
57
+ self.status_code = status_code
58
+ self.raw_response = raw_response
59
+ super().__init__(f"SERVICE_DESK API error {status_code}: Unable to parse error response")
60
+
61
+
62
+ @asynccontextmanager
63
+ async def handle_service_desk_exceptions() -> AsyncGenerator[Any, Any]:
64
+ try:
65
+ yield
66
+ except httpx.HTTPStatusError as e:
67
+ if e.response.status_code == httpx.codes.UNAUTHORIZED:
68
+ raise ServiceDeskAuthenticationError from e
69
+ elif e.response.status_code == httpx.codes.FORBIDDEN:
70
+ raise ServiceDeskPermissionError from e
71
+ elif e.response.is_client_error:
72
+ raise ServiceDeskClientError(e.response.status_code, e.response.text) from e
73
+ else:
74
+ raise ServiceDeskServerError(e.response.status_code, e.response.text) from e
75
+ except httpx.TransportError as e:
76
+ raise ServiceDeskConnectionError(f"Network error: {e}") from e