lightman-ai 0.16.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 (31) hide show
  1. lightman_ai-0.16.0/LICENSE +6 -0
  2. lightman_ai-0.16.0/PKG-INFO +17 -0
  3. lightman_ai-0.16.0/pyproject.toml +243 -0
  4. lightman_ai-0.16.0/src/lightman_ai/__init__.py +0 -0
  5. lightman_ai-0.16.0/src/lightman_ai/ai/base/__init__.py +0 -0
  6. lightman_ai-0.16.0/src/lightman_ai/ai/base/agent.py +26 -0
  7. lightman_ai-0.16.0/src/lightman_ai/ai/base/exceptions.py +4 -0
  8. lightman_ai-0.16.0/src/lightman_ai/ai/gemini/__init__.py +0 -0
  9. lightman_ai-0.16.0/src/lightman_ai/ai/gemini/agent.py +19 -0
  10. lightman_ai-0.16.0/src/lightman_ai/ai/gemini/exceptions.py +19 -0
  11. lightman_ai-0.16.0/src/lightman_ai/ai/openai/__init__.py +0 -0
  12. lightman_ai-0.16.0/src/lightman_ai/ai/openai/agent.py +29 -0
  13. lightman_ai-0.16.0/src/lightman_ai/ai/openai/exceptions.py +85 -0
  14. lightman_ai-0.16.0/src/lightman_ai/ai/utils.py +14 -0
  15. lightman_ai-0.16.0/src/lightman_ai/article/__init__.py +0 -0
  16. lightman_ai-0.16.0/src/lightman_ai/article/models.py +107 -0
  17. lightman_ai-0.16.0/src/lightman_ai/cli.py +100 -0
  18. lightman_ai-0.16.0/src/lightman_ai/constants.py +5 -0
  19. lightman_ai-0.16.0/src/lightman_ai/core/__init__.py +0 -0
  20. lightman_ai-0.16.0/src/lightman_ai/core/config.py +90 -0
  21. lightman_ai-0.16.0/src/lightman_ai/core/exceptions.py +13 -0
  22. lightman_ai-0.16.0/src/lightman_ai/core/settings.py +14 -0
  23. lightman_ai-0.16.0/src/lightman_ai/integrations/__init__.py +0 -0
  24. lightman_ai-0.16.0/src/lightman_ai/integrations/service_desk/__init__.py +1 -0
  25. lightman_ai-0.16.0/src/lightman_ai/integrations/service_desk/constants.py +7 -0
  26. lightman_ai-0.16.0/src/lightman_ai/integrations/service_desk/exceptions.py +76 -0
  27. lightman_ai-0.16.0/src/lightman_ai/integrations/service_desk/integration.py +89 -0
  28. lightman_ai-0.16.0/src/lightman_ai/main.py +92 -0
  29. lightman_ai-0.16.0/src/lightman_ai/py.typed +0 -0
  30. lightman_ai-0.16.0/src/lightman_ai/sources/base.py +8 -0
  31. lightman_ai-0.16.0/src/lightman_ai/sources/the_hacker_news.py +57 -0
@@ -0,0 +1,6 @@
1
+ Copyright (c) 2025, Elements Interactive B.V.
2
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
3
+
4
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
5
+
6
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: lightman_ai
3
+ Version: 0.16.0
4
+ Summary: Cybersecurity news aggregator.
5
+ Author: sdn4z
6
+ Author-email: imsdn4z@gmail.com
7
+ Requires-Python: >=3.13,<4
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Requires-Dist: click (>=8.1.7,<9.0.0)
11
+ Requires-Dist: httpx (>=0.28.0,<0.29.0)
12
+ Requires-Dist: pydantic-ai[logfire] (>=0.2.0,<0.3.0)
13
+ Requires-Dist: pydantic-settings[dotenv] (>=2.9.1,<3.0.0)
14
+ Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
15
+ Requires-Dist: stamina (>=25.1.0,<26.0.0)
16
+ Requires-Dist: tiktoken (>=0.9.0,<0.10.0)
17
+ Requires-Dist: tomlkit (>=0.13.3,<0.14.0)
@@ -0,0 +1,243 @@
1
+ [tool.poetry]
2
+ name = "lightman_ai"
3
+ version = "0.16.0"
4
+ description = "Cybersecurity news aggregator."
5
+ authors = ["sdn4z <imsdn4z@gmail.com>"]
6
+
7
+ [tool.poetry.scripts]
8
+ lightman = "lightman_ai.cli:entry_point"
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.13,<4"
12
+ httpx = "^0.28.0"
13
+ python-dotenv = "^1.1.1"
14
+ pydantic-ai = {version = "^0.2.0", extras = ["logfire"]}
15
+ click = "^8.1.7"
16
+ stamina = "^25.1.0"
17
+ pydantic-settings = {extras = ["dotenv"], version = "^2.9.1"}
18
+ tiktoken = "^0.9.0"
19
+ tomlkit = "^0.13.3"
20
+
21
+ [tool.poetry.group.test.dependencies]
22
+ pytest = "^8.0.0"
23
+ pytest-cov = "^5.0.0"
24
+ pytest-asyncio = "^0.26.0"
25
+
26
+ [tool.poetry.group.lint.dependencies]
27
+ mypy = "^1.1.1"
28
+ ruff = "^0.11.0"
29
+
30
+ [tool.poetry.group.local.dependencies]
31
+ ipdb = "^0.13.13"
32
+ pdbpp = "^0.11.6"
33
+ pre-commit = "^3.2.2"
34
+ commitizen = "^4.8.3"
35
+ codespell = "^2.2.4"
36
+
37
+ [build-system]
38
+ requires = ["poetry-core>=1.0.0"]
39
+ build-backend = "poetry.core.masonry.api"
40
+
41
+
42
+ [tool.pytest.ini_options]
43
+ addopts = """
44
+ -s
45
+ --cov
46
+ --cov-report term-missing
47
+ --cov-report=xml:./tests/coverage.xml
48
+ --junitxml=./tests/junit.xml
49
+ """
50
+ asyncio_mode = "auto"
51
+ asyncio_default_fixture_loop_scope = "session"
52
+
53
+ [tool.logfire]
54
+ ignore_no_config=true
55
+
56
+ [tool.ruff]
57
+ target-version = "py312"
58
+ line-length = 120
59
+ src = ["src", "tests"]
60
+
61
+ [tool.ruff.lint]
62
+ select = [
63
+ "B", # flake8-bugbear
64
+ "C", # mccabe
65
+ "E", # pycodestyle
66
+ "W", # pycodestyle warnings
67
+ "F", # pyflakes
68
+ "I", # isort
69
+ "B", # bugbear
70
+ "D", # pydocstyle
71
+ "I", # isort
72
+ "C4", # comprehensions
73
+ "SIM", # simplify
74
+ "N", # pep8-naming
75
+ "TRY", # tryceratops
76
+ "UP", # pyupgrade
77
+ "PT", # pytest-style
78
+ "TCH", # type-checking
79
+ "PL", # pylint
80
+ "G", # logging format
81
+ "TID", # tidy-imports
82
+ ]
83
+ ignore = [
84
+ "E501", # line-length (enforced by black instead)
85
+ 'D1', # pydocstyle enforces docstrings everywhere
86
+ 'TRY003', # tryceratops rule not useful
87
+ "PLR0913", # pylint rule not useful
88
+ "B008", # checks for function calls in default function arguments.
89
+ 'D206', # conflicts with formatter
90
+ 'W191', # conflicts with formatter
91
+ 'D203', # conflicts with formatter
92
+ 'D211', # conflicts with formatter
93
+ 'D212', # conflicts with formatter
94
+ 'UP031', # allow strings formated with %s
95
+ 'PLR2004' # magic value
96
+ ]
97
+
98
+ [tool.ruff.lint.mccabe]
99
+ max-complexity = 10
100
+
101
+ [tool.ruff.lint.pydocstyle]
102
+ convention = "pep257"
103
+
104
+ [tool.ruff.lint.isort]
105
+ known-third-party = ["lightman_ai", "tests"]
106
+
107
+ [tool.ruff.lint.per-file-ignores]
108
+ "tests/*" = [
109
+ "ANN", # no enforcement of type annotations in tests
110
+ "S", # bandit is not useful in tests
111
+ ]
112
+
113
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
114
+ "dateutil.tz".msg = "Use `zoneinfo` instead."
115
+ "pytz".msg = "Use `zoneinfo` instead."
116
+
117
+
118
+ [tool.mypy]
119
+ python_version = "3.13"
120
+ mypy_path = "src"
121
+ strict = true
122
+ pretty = true
123
+ warn_unused_ignores = true
124
+ enable_error_code = ["explicit-override"]
125
+
126
+
127
+ [tool.coverage.run]
128
+ omit = ["tests/*", "eval/*"]
129
+
130
+ [tool.coverage.report]
131
+ fail_under = 95
132
+ exclude_lines = [
133
+ "if TYPE_CHECKING:",
134
+ "pragma: no cover",
135
+ "raise NotImplementedError",
136
+ ]
137
+
138
+
139
+ [tool.commitizen]
140
+ version_files = ["pyproject.toml:^version"]
141
+ name = "cz_customize"
142
+ version = "0.16.0"
143
+ tag_format = "v$version"
144
+
145
+ [tool.commitizen.customize]
146
+ message_template = "{{prefix}}{% if scope %}({{scope}}){% endif %}: {{subject}}{% if body %}\n\n{{body}}{% endif %}{% if is_breaking_change %}\n\nBREAKING CHANGE: {{footer}}{% else %}\n\n{{footer}}{% endif %}"
147
+
148
+ bump_pattern = "^(BREAKING[\\-\\ ]CHANGE|feat|fix|refactor|perf|ci|docs|style|test|chore|revert|build)(\\(.+\\))?(!)?"
149
+ change_type_order = [
150
+ "BREAKING CHANGE",
151
+ "Feat",
152
+ "Fix",
153
+ "Refactor",
154
+ "Perf",
155
+ "CI",
156
+ "Docs",
157
+ "Technical",
158
+ "Tests",
159
+ ]
160
+ bump_message = "bump: version $current_version → $new_version"
161
+ schema = "<type>(<scope>): <subject>\n<BLANK LINE>\n<body>\n<BLANK LINE>\n(BREAKING CHANGE: )<footer>"
162
+ schema_pattern = "(?s)(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)(\\(\\S+\\))?!?:( [^\\n\\r]+)((\\n\\n.*)|(\\s*))?$"
163
+ commit_parser = "^(?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE|ci|docs|style|test|chore|revert|build)(?:\\((?P<scope>[^()\\r\\n]*)\\)|\\()?(?P<breaking>!)?:\\s(?P<message>.*)?"
164
+ version_parser = "(?P<version>([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?(\\w+)?)"
165
+ changelog_pattern = "^(BREAKING[\\-\\ ]CHANGE|feat|fix|refactor|perf|ci|docs|style|test|chore|revert|build)(\\(.+\\))?(!)?" # same as bump_pattern
166
+ [tool.commitizen.customize.bump_map]
167
+ "^.+!$" = "MAJOR"
168
+ "^BREAKING[\\-\\ ]CHANGE" = "MAJOR"
169
+ "^feat" = "MINOR"
170
+ "^fix" = "PATCH"
171
+ "^refactor" = "PATCH"
172
+ "^perf" = "PATCH"
173
+ "^ci" = "PATCH"
174
+ "^docs" = "PATCH"
175
+ "^style" = "PATCH"
176
+ "^test" = "PATCH"
177
+ "^chore" = "PATCH"
178
+ "^revert" = "PATCH"
179
+ "^build" = "PATCH"
180
+
181
+ [tool.commitizen.customize.change_type_map]
182
+ "feat" = "Feat"
183
+ "fix" = "Fix"
184
+ "refactor" = "Refactor"
185
+ "perf" = "Perf"
186
+ "ci" = "CI"
187
+ "docs" = "Docs"
188
+ "style" = "Technical"
189
+ "test" = "Tests"
190
+ "chore" = "Technical"
191
+ "revert" = "Fix"
192
+ "build" = "Technical"
193
+
194
+ [[tool.commitizen.customize.questions]]
195
+ type = "list"
196
+ name = "prefix"
197
+ message = "Select the type of change you are committing"
198
+ choices = [
199
+ { value = "fix", name = "fix: A bug fix. Correlates with PATCH in SemVer", key = "x" },
200
+ { value = "feat", name = "feat: A new feature. Correlates with MINOR in SemVer", key = "f" },
201
+ { value = "docs", name = "docs: Documentation only changes", key = "d" },
202
+ { value = "style", name = "style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)", key = "s" },
203
+ { value = "refactor", name = "refactor: A code change that neither fixes a bug nor adds a feature", key = "r" },
204
+ { value = "perf", name = "perf: A code change that improves performance", key = "p" },
205
+ { value = "test", name = "test: Adding missing or correcting existing tests", key = "t" },
206
+ { value = "build", name = "build: Changes that affect the build system or external dependencies (example scopes: pip, docker, npm)", key = "b" },
207
+ { value = "ci", name = "ci: Changes to our CI configuration files and scripts (example scopes: GitLabCI)", key = "c" },
208
+ ]
209
+
210
+ [[tool.commitizen.customize.questions]]
211
+ type = "input"
212
+ name = "scope"
213
+ message = "What is the scope of this change? ((story number, class or file name): (press [enter] to skip)\n"
214
+
215
+ [[tool.commitizen.customize.questions]]
216
+ type = "input"
217
+ name = "subject"
218
+ message = "Write a short and imperative summary of the code changes: (lower case and no period)\n"
219
+
220
+ [[tool.commitizen.customize.questions]]
221
+ type = "input"
222
+ name = "body"
223
+ message = "Provide additional contextual information about the code changes: (press [enter] to skip)\n"
224
+
225
+ [[tool.commitizen.customize.questions]]
226
+ type = "confirm"
227
+ message = "Is this a BREAKING CHANGE? Correlates with MAJOR in SemVer"
228
+ name = "is_breaking_change"
229
+ default = false
230
+
231
+ [[tool.commitizen.customize.questions]]
232
+ type = "input"
233
+ name = "footer"
234
+ message = "Footer. Information about Breaking Changes and reference issues that this commit closes: (press [enter] to skip)\n"
235
+
236
+ # TODO: Find a way to include pyproject.toml without the version changes
237
+ [tool.cruft]
238
+ skip = ["pyproject.toml"]
239
+
240
+
241
+ [[IgnoredVulns]]
242
+ # False positive for mkdocs, more info: https://github.com/mkdocs/mkdocs/issues/2601
243
+ id = "PYSEC-2021-878"
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
+ _model: type[OpenAIModel] | type[GoogleModel]
13
+ _model_name: str
14
+
15
+ def __init__(self, system_prompt: str, logger: logging.Logger | None = None) -> None:
16
+ model = self._model(self._model_name)
17
+ self.agent: Agent[Never, SelectedArticlesList] = Agent(
18
+ model=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): ...
@@ -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
+ _model = GoogleModel
13
+ _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
@@ -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
+ _model = OpenAIModel
15
+ _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_instance_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,107 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import override
3
+
4
+ import tiktoken
5
+ from lightman_ai.core.settings import settings
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class BaseArticle(BaseModel, ABC):
10
+ """Base abstract class for all Articles."""
11
+
12
+ title: str
13
+ link: str
14
+ _encoding: tiktoken.Encoding = tiktoken.get_encoding(settings.OPENAI_ENCODING)
15
+
16
+ @override
17
+ def __eq__(self, value: object) -> bool:
18
+ if not isinstance(value, BaseArticle):
19
+ return False
20
+
21
+ return self.link == value.link
22
+
23
+ @override
24
+ def __hash__(self) -> int:
25
+ return hash(self.link.encode())
26
+
27
+ @property
28
+ @abstractmethod
29
+ def number_of_tokens(self) -> int: ...
30
+
31
+
32
+ class SelectedArticle(BaseArticle):
33
+ why_is_relevant: str
34
+ relevance_score: int
35
+
36
+ @override
37
+ @property
38
+ def number_of_tokens(self) -> int:
39
+ """
40
+ Number of tokens that this Article has.
41
+
42
+ Worth taking into account that this is not the final number of tokens to be sent,
43
+ as pydantic-ai adds some extra characters.
44
+
45
+ It is a rough estimation of the total tokens to be sent for this Article.
46
+ """
47
+ tokens = self._encoding.encode(
48
+ f'"link": "{self.link}", "title": "{self.title}", "why_is_relevant": "{self.why_is_relevant}", "score_threshold": "{self.relevance_score}"'
49
+ )
50
+ return len(tokens)
51
+
52
+
53
+ class Article(BaseArticle):
54
+ title: str
55
+ description: str
56
+
57
+ @override
58
+ @property
59
+ def number_of_tokens(self) -> int:
60
+ """
61
+ Number of tokens that this Article has.
62
+
63
+ Worth taking into account that this is not the final number of tokens to be sent,
64
+ as pydantic-ai adds some extra characters.
65
+
66
+ It is a rough estimation of the total tokens to be sent for this Article.
67
+ """
68
+ tokens = self._encoding.encode(
69
+ f'"title": "{self.title}", "description": "{self.description}", "link": "{self.link}"'
70
+ )
71
+ return len(tokens)
72
+
73
+
74
+ class BaseArticlesList[TArticle: BaseArticle](BaseModel):
75
+ articles: list[TArticle]
76
+
77
+ def __len__(self) -> int:
78
+ return len(self.articles)
79
+
80
+ @property
81
+ def titles(self) -> list[str]:
82
+ return [new.title for new in self.articles]
83
+
84
+ @property
85
+ def links(self) -> list[str]:
86
+ return [new.link for new in self.articles]
87
+
88
+ @property
89
+ def total_number_of_tokens(self) -> int:
90
+ return sum(article.number_of_tokens for article in self.articles)
91
+
92
+
93
+ class SelectedArticlesList(BaseArticlesList[SelectedArticle]):
94
+ """
95
+ Model that holds all the articles that were selected by the AI model.
96
+
97
+ It saves the minimum information so that they are identifiable.
98
+ """
99
+
100
+ def get_articles_with_score_gte_threshold(self, score_threshold: int) -> list[SelectedArticle]:
101
+ if not score_threshold > 0:
102
+ raise ValueError("score threshold must be > 0.")
103
+ return [article for article in self.articles if article.relevance_score >= score_threshold]
104
+
105
+
106
+ class ArticlesList(BaseArticlesList[Article]):
107
+ """Model that saves articles with all their information."""
@@ -0,0 +1,100 @@
1
+ import logging
2
+
3
+ import click
4
+ from dotenv import load_dotenv
5
+ from lightman_ai.ai.utils import AGENT_CHOICES
6
+ from lightman_ai.constants import DEFAULT_CONFIG_FILE, DEFAULT_CONFIG_SECTION, DEFAULT_ENV_FILE
7
+ from lightman_ai.core.config import FileConfig, FinalConfig, PromptConfig
8
+ from lightman_ai.core.exceptions import ConfigNotFoundError, InvalidConfigError, PromptNotFoundError
9
+ from lightman_ai.main import lightman
10
+
11
+ logger = logging.getLogger("lightman")
12
+
13
+
14
+ @click.group()
15
+ def entry_point() -> None:
16
+ pass
17
+
18
+
19
+ @entry_point.command()
20
+ @click.option("--agent", type=click.Choice(AGENT_CHOICES), help=("Which agent to use"))
21
+ @click.option(
22
+ "--prompt-file",
23
+ type=str,
24
+ default=DEFAULT_CONFIG_FILE,
25
+ help=(f"Location of the config file containing the prompts. Defaults to `{DEFAULT_CONFIG_FILE}`."),
26
+ )
27
+ @click.option("--prompt", type=str, help=("Which prompt to use"))
28
+ @click.option(
29
+ "--score",
30
+ type=int,
31
+ help=("The minimum score relevance that an article needs to have to be considered relevant"),
32
+ default=None,
33
+ )
34
+ @click.option(
35
+ "--config-file",
36
+ type=str,
37
+ default=DEFAULT_CONFIG_FILE,
38
+ help=(f"The config file path. Defaults to `{DEFAULT_CONFIG_FILE}`."),
39
+ )
40
+ @click.option(
41
+ "--config",
42
+ type=str,
43
+ default=DEFAULT_CONFIG_SECTION,
44
+ help=(f"The config settings to use. Defaults to `{DEFAULT_CONFIG_SECTION}`."),
45
+ )
46
+ @click.option(
47
+ "--env-file",
48
+ type=str,
49
+ default=DEFAULT_ENV_FILE,
50
+ help=(f"Path to the environment file. Defaults to `{DEFAULT_ENV_FILE}`."),
51
+ )
52
+ @click.option(
53
+ "--dry-run",
54
+ is_flag=True,
55
+ help=(
56
+ "When set, runs the script without publishing the results to the integrated services, just shows them in stdout."
57
+ ),
58
+ )
59
+ def run(
60
+ agent: str,
61
+ prompt: str,
62
+ prompt_file: str,
63
+ score: int | None,
64
+ config_file: str,
65
+ config: str,
66
+ env_file: str,
67
+ dry_run: bool,
68
+ ) -> int:
69
+ """
70
+ Entrypoint of the application.
71
+
72
+ Holds no logic. It calls the main method and returns 0 when succesful .
73
+ """
74
+ load_dotenv(env_file)
75
+ try:
76
+ prompt_config = PromptConfig.get_config_from_file(path=prompt_file)
77
+ config_from_file = FileConfig.get_config_from_file(config_section=config, path=config_file)
78
+ final_config = FinalConfig.init_from_dict(
79
+ data={
80
+ "agent": agent or config_from_file.agent,
81
+ "prompt": prompt or config_from_file.prompt,
82
+ "score_threshold": score or config_from_file.score_threshold,
83
+ }
84
+ )
85
+
86
+ prompt_text = prompt_config.get_prompt(final_config.prompt)
87
+ except (InvalidConfigError, PromptNotFoundError, ConfigNotFoundError) as err:
88
+ raise click.BadParameter(err.args[0]) from None
89
+
90
+ relevant_articles = lightman(
91
+ agent=final_config.agent,
92
+ prompt=prompt_text,
93
+ score_threshold=final_config.score_threshold,
94
+ dry_run=dry_run,
95
+ project_key=config_from_file.service_desk_project_key,
96
+ request_id_type=config_from_file.service_desk_request_id_type,
97
+ )
98
+ relevant_articles_titles = [article.title for article in relevant_articles]
99
+ logger.warning("Found these articles: %s", "\n".join(relevant_articles_titles))
100
+ 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,90 @@
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
+
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ @field_validator("service_desk_project_key", "service_desk_request_id_type", mode="before")
51
+ @classmethod
52
+ def _cast_service_fields(cls, v: Any, info: FieldValidationInfo) -> Any:
53
+ if v is None:
54
+ return v
55
+ if isinstance(v, int):
56
+ return str(v)
57
+ if isinstance(v, str):
58
+ if v == "":
59
+ return v
60
+ try:
61
+ int(v)
62
+ except ValueError as err:
63
+ raise ValueError(f"{info.field_name} must be a number!") from err
64
+ return v
65
+
66
+ @classmethod
67
+ def get_config_from_file(cls, config_section: str, path: str) -> Self:
68
+ try:
69
+ config = read_config_from_file(config_section=config_section, path=path)
70
+ except ConfigNotFoundError:
71
+ logger.warning("Config file `%s` not found! Proceeding with empty config.", path)
72
+ config = {}
73
+ return cls(**config)
74
+
75
+
76
+ class FinalConfig(BaseModel):
77
+ prompt: str
78
+ agent: str
79
+ score_threshold: PositiveInt
80
+
81
+ @classmethod
82
+ def init_from_dict(cls, data: dict[str, Any]) -> Self:
83
+ try:
84
+ return cls(**data)
85
+ except ValidationError as error:
86
+ error_list = []
87
+ for err in error.errors():
88
+ error_list.append(f"`{err['loc'][0]}`: {err['msg']}")
89
+ err_msg = f"Invalid configuration provided: [{','.join(error_list)}]"
90
+ 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,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()
@@ -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
@@ -0,0 +1,89 @@
1
+ import logging
2
+ import os
3
+ from typing import Any, Self
4
+
5
+ import httpx
6
+ import stamina
7
+ from lightman_ai.integrations.service_desk.constants import (
8
+ SERVICE_DESK_RETRY_ATTEMPTS,
9
+ SERVICE_DESK_RETRY_ON,
10
+ SERVICE_DESK_RETRY_TIMEOUT,
11
+ )
12
+ from lightman_ai.integrations.service_desk.exceptions import (
13
+ MissingIssueIDError,
14
+ handle_service_desk_exceptions,
15
+ )
16
+
17
+ logger = logging.getLogger("lightman")
18
+
19
+
20
+ class ServiceDeskIntegration:
21
+ """
22
+ Service Desk integration using httpx to create tickets.
23
+
24
+ Provides a simple interface to create Service Desk issues with proper
25
+ authentication and error handling.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ base_url: str,
31
+ username: str,
32
+ api_token: str,
33
+ verify_ssl: bool = True,
34
+ timeout: float = 30.0,
35
+ ) -> None:
36
+ self.base_url = base_url.rstrip("/")
37
+ self.username = username
38
+ self.api_token = api_token
39
+ self.client = httpx.AsyncClient(
40
+ base_url=self.base_url,
41
+ auth=(username, api_token),
42
+ headers={
43
+ "Content-Type": "application/json",
44
+ "Accept": "application/json",
45
+ },
46
+ verify=verify_ssl,
47
+ timeout=timeout,
48
+ )
49
+
50
+ @classmethod
51
+ def from_env(cls) -> Self:
52
+ required_vars = ["SERVICE_DESK_URL", "SERVICE_DESK_USER", "SERVICE_DESK_TOKEN"]
53
+ missing = [var for var in required_vars if not os.environ.get(var)]
54
+ if missing:
55
+ raise ValueError(f"Missing required environment variable(s): {', '.join(missing)}")
56
+ return cls(
57
+ base_url=os.environ["SERVICE_DESK_URL"],
58
+ username=os.environ["SERVICE_DESK_USER"],
59
+ api_token=os.environ["SERVICE_DESK_TOKEN"],
60
+ )
61
+
62
+ async def create_request_of_type(
63
+ self,
64
+ *,
65
+ project_key: str,
66
+ summary: str,
67
+ description: str,
68
+ request_id_type: str,
69
+ ) -> str:
70
+ payload: dict[str, Any] = {
71
+ "serviceDeskId": project_key,
72
+ "requestTypeId": request_id_type,
73
+ "requestFieldValues": {"summary": summary, "description": description},
74
+ }
75
+ for attempt in stamina.retry_context(
76
+ on=SERVICE_DESK_RETRY_ON,
77
+ attempts=SERVICE_DESK_RETRY_ATTEMPTS,
78
+ timeout=SERVICE_DESK_RETRY_TIMEOUT,
79
+ ):
80
+ with attempt:
81
+ async with handle_service_desk_exceptions():
82
+ response = await self.client.post("/rest/servicedeskapi/request", json=payload)
83
+ response.raise_for_status()
84
+ data = response.json()
85
+ issue_id = data.get("issueId")
86
+ if not issue_id:
87
+ raise MissingIssueIDError()
88
+ logger.info("Successfully created Service Desk issue: %s", issue_id)
89
+ return str(issue_id)
@@ -0,0 +1,92 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ from lightman_ai.ai.base.agent import BaseAgent
5
+ from lightman_ai.ai.utils import get_agent_instance_from_agent_name
6
+ from lightman_ai.article.models import ArticlesList, SelectedArticle, SelectedArticlesList
7
+ from lightman_ai.integrations.service_desk.integration import (
8
+ ServiceDeskIntegration,
9
+ )
10
+ from lightman_ai.sources.the_hacker_news import TheHackerNewsSource
11
+
12
+ logger = logging.getLogger("lightman")
13
+
14
+
15
+ def _get_articles() -> ArticlesList:
16
+ return TheHackerNewsSource().get_articles()
17
+
18
+
19
+ def _classify_articles(articles: ArticlesList, agent: BaseAgent) -> SelectedArticlesList:
20
+ return agent.get_prompt_result(prompt=str(articles))
21
+
22
+
23
+ def _create_service_desk_issues(
24
+ selected_articles: list[SelectedArticle],
25
+ service_desk_client: ServiceDeskIntegration,
26
+ project_key: str,
27
+ request_id_type: str,
28
+ ) -> None:
29
+ async def schedule_task(article: SelectedArticle) -> None:
30
+ try:
31
+ await service_desk_client.create_request_of_type(
32
+ project_key=project_key,
33
+ summary=article.title,
34
+ description=article.link,
35
+ request_id_type=request_id_type,
36
+ )
37
+ except Exception:
38
+ logger.exception("Could not create ServiceDesk issue: %s, %s", article.title, article.link)
39
+ raise
40
+
41
+ async def create_all() -> None:
42
+ tasks = []
43
+ for article in selected_articles:
44
+ tasks.append(schedule_task(article))
45
+ results = await asyncio.gather(*tasks, return_exceptions=True)
46
+ errors = [result for result in results if isinstance(result, Exception)]
47
+ if errors:
48
+ raise ExceptionGroup("Could not create all ServiceDesk issues", errors)
49
+
50
+ asyncio.run(create_all())
51
+
52
+
53
+ def lightman(
54
+ agent: str,
55
+ prompt: str,
56
+ score_threshold: int,
57
+ project_key: str | None = None,
58
+ request_id_type: str | None = None,
59
+ dry_run: bool = False,
60
+ ) -> list[SelectedArticle]:
61
+ articles: ArticlesList = _get_articles()
62
+
63
+ agent_class = get_agent_instance_from_agent_name(agent)
64
+ agent_instance = agent_class(prompt)
65
+ logger.info("Selected %s.", agent_instance)
66
+
67
+ classified_articles = _classify_articles(
68
+ articles=articles,
69
+ agent=agent_instance,
70
+ )
71
+
72
+ selected_articles: list[SelectedArticle] = classified_articles.get_articles_with_score_gte_threshold(
73
+ score_threshold
74
+ )
75
+ if selected_articles:
76
+ logger.warning("Found these articles: %s", selected_articles)
77
+ else:
78
+ logger.warning("No articles found to be relevant. Total returned articles by AI %s", len(classified_articles))
79
+
80
+ if not dry_run:
81
+ if not project_key or not request_id_type:
82
+ raise ValueError("Missing Service Desk's project key or issue id type")
83
+
84
+ service_desk_client = ServiceDeskIntegration.from_env()
85
+ _create_service_desk_issues(
86
+ selected_articles=selected_articles,
87
+ service_desk_client=service_desk_client,
88
+ project_key=project_key,
89
+ request_id_type=request_id_type,
90
+ )
91
+
92
+ return selected_articles
File without changes
@@ -0,0 +1,8 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from lightman_ai.article.models import ArticlesList
4
+
5
+
6
+ class BaseSource(ABC):
7
+ @abstractmethod
8
+ def get_articles(self) -> ArticlesList: ...
@@ -0,0 +1,57 @@
1
+ from typing import override
2
+ from xml.etree import ElementTree
3
+
4
+ import httpx
5
+ import stamina
6
+ from httpx import Client
7
+ from lightman_ai.article.models import Article, ArticlesList
8
+ from lightman_ai.sources.base import BaseSource
9
+
10
+ _RETRY_ON = httpx.TransportError
11
+ _ATTEMPTS = 5
12
+ _TIMEOUT = 5
13
+
14
+
15
+ THN_URL = "https://feeds.feedburner.com/TheHackersNews"
16
+
17
+
18
+ class TheHackerNewsSource(BaseSource):
19
+ @override
20
+ def get_articles(self) -> ArticlesList:
21
+ """Return the articles that are present in THN feed."""
22
+ feed = self.get_feed()
23
+ articles = self._xml_to_list_of_articles(feed)
24
+ return ArticlesList(articles=articles)
25
+
26
+ def get_feed(self) -> str:
27
+ """Retrieve the TheHackerNews' RSS Feed."""
28
+ for attempt in stamina.retry_context(
29
+ on=_RETRY_ON,
30
+ attempts=_ATTEMPTS,
31
+ timeout=_TIMEOUT,
32
+ ):
33
+ with Client() as http_client, attempt:
34
+ hacker_news_feed = http_client.get(THN_URL)
35
+ hacker_news_feed.raise_for_status()
36
+ return hacker_news_feed.text
37
+
38
+ def _xml_to_list_of_articles(self, xml: str) -> list[Article]:
39
+ root = ElementTree.fromstring(xml)
40
+ channel = root.find("channel")
41
+ assert channel
42
+ items = channel.findall("item")
43
+
44
+ parsed = []
45
+
46
+ for item in items:
47
+ title = item.findtext("title", default="").strip()
48
+ description = self._clean(item.findtext("description", default="").strip())
49
+ link = item.findtext("link", default="").strip()
50
+
51
+ parsed.append(Article(title=title, description=description, link=link))
52
+ return parsed
53
+
54
+ @staticmethod
55
+ def _clean(text: str) -> str:
56
+ """Remove non-useful characters."""
57
+ return text.replace("\\n", "").replace(" ", "")