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.
- lightman_ai/__init__.py +0 -0
- lightman_ai/ai/base/__init__.py +0 -0
- lightman_ai/ai/base/agent.py +26 -0
- lightman_ai/ai/base/exceptions.py +4 -0
- lightman_ai/ai/gemini/__init__.py +0 -0
- lightman_ai/ai/gemini/agent.py +19 -0
- lightman_ai/ai/gemini/exceptions.py +19 -0
- lightman_ai/ai/openai/__init__.py +0 -0
- lightman_ai/ai/openai/agent.py +29 -0
- lightman_ai/ai/openai/exceptions.py +85 -0
- lightman_ai/ai/utils.py +14 -0
- lightman_ai/article/__init__.py +0 -0
- lightman_ai/article/models.py +64 -0
- lightman_ai/cli.py +113 -0
- lightman_ai/constants.py +5 -0
- lightman_ai/core/__init__.py +0 -0
- lightman_ai/core/config.py +92 -0
- lightman_ai/core/exceptions.py +13 -0
- lightman_ai/core/sentry.py +28 -0
- lightman_ai/core/settings.py +14 -0
- lightman_ai/integrations/__init__.py +0 -0
- lightman_ai/integrations/service_desk/__init__.py +1 -0
- lightman_ai/integrations/service_desk/constants.py +7 -0
- lightman_ai/integrations/service_desk/exceptions.py +76 -0
- lightman_ai/integrations/service_desk/integration.py +89 -0
- lightman_ai/main.py +95 -0
- lightman_ai/py.typed +0 -0
- lightman_ai/sources/base.py +8 -0
- lightman_ai/sources/the_hacker_news.py +57 -0
- lightman_ai-0.20.1.dist-info/METADATA +394 -0
- lightman_ai-0.20.1.dist-info/RECORD +34 -0
- lightman_ai-0.20.1.dist-info/WHEEL +4 -0
- lightman_ai-0.20.1.dist-info/entry_points.txt +2 -0
- lightman_ai-0.20.1.dist-info/licenses/LICENSE +6 -0
lightman_ai/__init__.py
ADDED
|
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: ...
|
|
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
|
lightman_ai/ai/utils.py
ADDED
|
@@ -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
|
lightman_ai/constants.py
ADDED
|
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
|