stark-engine 4.2.2__tar.gz → 4.3.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 (64) hide show
  1. {stark_engine-4.2.2 → stark_engine-4.3.0}/PKG-INFO +4 -2
  2. {stark_engine-4.2.2 → stark_engine-4.3.0}/pyproject.toml +5 -2
  3. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/__init__.py +24 -20
  4. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/__init__.py +1 -4
  5. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/command.py +60 -23
  6. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/commands_context.py +73 -48
  7. stark_engine-4.3.0/stark/core/commands_context_processor.py +87 -0
  8. stark_engine-4.3.0/stark/core/commands_manager.py +73 -0
  9. stark_engine-4.3.0/stark/core/health_check.py +51 -0
  10. stark_engine-4.3.0/stark/core/parsing.py +365 -0
  11. stark_engine-4.3.0/stark/core/patterns/__init__.py +2 -0
  12. stark_engine-4.3.0/stark/core/patterns/pattern.py +82 -0
  13. stark_engine-4.3.0/stark/core/processors/__init__.py +2 -0
  14. stark_engine-4.3.0/stark/core/processors/search_processor.py +82 -0
  15. stark_engine-4.3.0/stark/core/processors/spacy_ner_processor.py +102 -0
  16. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/types/__init__.py +1 -1
  17. stark_engine-4.3.0/stark/core/types/location.py +5 -0
  18. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/types/object.py +9 -5
  19. stark_engine-4.3.0/stark/core/types/slots.py +77 -0
  20. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/types/word.py +3 -3
  21. stark_engine-4.3.0/stark/general/cache.py +58 -0
  22. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/nl_dictionary_name.py +4 -4
  23. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/sliding_window_parser.py +4 -9
  24. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/voice_assistant/voice_assistant.py +27 -13
  25. stark_engine-4.2.2/stark/core/commands_manager.py +0 -139
  26. stark_engine-4.2.2/stark/core/patterns/__init__.py +0 -3
  27. stark_engine-4.2.2/stark/core/patterns/parsing.py +0 -68
  28. stark_engine-4.2.2/stark/core/patterns/pattern.py +0 -328
  29. stark_engine-4.2.2/stark/core/types/number.py +0 -27
  30. stark_engine-4.2.2/stark/core/types/slots.py +0 -89
  31. stark_engine-4.2.2/stark/core/types/time.py +0 -31
  32. stark_engine-4.2.2/stark/core/types/time_interval.py +0 -78
  33. {stark_engine-4.2.2 → stark_engine-4.3.0}/LICENSE.md +0 -0
  34. {stark_engine-4.2.2 → stark_engine-4.3.0}/README.md +0 -0
  35. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/patterns/rules.py +0 -0
  36. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/core/types/string.py +0 -0
  37. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/general/blockage_detector.py +0 -0
  38. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/general/classproperty.py +0 -0
  39. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/general/dependencies.py +0 -0
  40. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/general/json_encoder.py +0 -0
  41. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/interfaces/gcloud.py +0 -0
  42. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/interfaces/protocols.py +0 -0
  43. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/interfaces/silero.py +0 -0
  44. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/interfaces/vosk.py +0 -0
  45. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/common/span.py +0 -0
  46. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/!examples.py +0 -0
  47. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/__init__.py +0 -0
  48. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/dictionary.py +0 -0
  49. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/models.py +0 -0
  50. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/storage/__init__.py +0 -0
  51. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/storage/storage_memory.py +0 -0
  52. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/dictionary/storage/storage_sqlite.py +0 -0
  53. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/levenshtein/__init__.py +0 -0
  54. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/levenshtein/levenshtein.pyi +0 -0
  55. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/levenshtein/levenshtein.pyx +0 -0
  56. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/phonetic/simplephone.py +0 -0
  57. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/phonetic/transcription/__init__.py +0 -0
  58. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/phonetic/transcription/epitran.py +0 -0
  59. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/phonetic/transcription/espeak.py +0 -0
  60. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/phonetic/transcription/ipa2lat.py +0 -0
  61. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/phonetic/transcription/protocol.py +0 -0
  62. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/tools/strtools.py +0 -0
  63. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/voice_assistant/__init__.py +0 -0
  64. {stark_engine-4.2.2 → stark_engine-4.3.0}/stark/voice_assistant/mode.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stark-engine
3
- Version: 4.2.2
3
+ Version: 4.3.0
4
4
  Summary: S.T.A.R.K - Speech and Text Algorithmic Recognition Kit. Modern framework for creating powerfull voice assistants.
5
5
  License: CC BY-NC-SA 4.0
6
6
  License-File: LICENSE.md
7
7
  Keywords: python,open-source,natural-language-processing,framework,cross-platform,natural-language,voice,voice-commands,python3,voice-recognition,speech-recognition,speech-to-text,community-project,voice-assistant,voice-interface,NLP,machine-learning,AI,text-analysis,stark,stark-place,stark-engine,mark parker
8
8
  Author: MarkParker5
9
9
  Author-email: mark@parker-programs.com
10
- Requires-Python: >=3.12,<4.0
10
+ Requires-Python: >=3.12,<3.15
11
11
  Classifier: License :: Other/Proprietary License
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.12
@@ -17,6 +17,7 @@ Provides-Extra: all
17
17
  Provides-Extra: gcloud
18
18
  Provides-Extra: silero
19
19
  Provides-Extra: sound
20
+ Provides-Extra: spacy
20
21
  Provides-Extra: vosk
21
22
  Requires-Dist: asyncer (>=0.0.2,<0.0.3)
22
23
  Requires-Dist: google-cloud-texttospeech (>=2.14.1,<3.0.0) ; extra == "gcloud" or extra == "all"
@@ -24,6 +25,7 @@ Requires-Dist: numpy (>=2.3.3,<3.0.0) ; extra == "silero" or extra == "all"
24
25
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
25
26
  Requires-Dist: sounddevice (>=0.4.5,<0.5.0) ; extra == "gcloud" or extra == "vosk" or extra == "silero" or extra == "sound" or extra == "all"
26
27
  Requires-Dist: soundfile (>=0.11.0,<0.12.0) ; extra == "gcloud" or extra == "sound" or extra == "all"
28
+ Requires-Dist: spacy (>=3.8.11,<4.0.0) ; extra == "spacy" or extra == "all"
27
29
  Requires-Dist: torch (>=1.13.1,<2.0.0) ; extra == "silero" or extra == "all"
28
30
  Requires-Dist: vosk (==0.3.44) ; extra == "vosk" or extra == "all"
29
31
  Project-URL: Documentation, https://stark.markparker.me
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "stark-engine"
3
- version = "4.2.2"
3
+ version = "4.3.0"
4
4
  description = "S.T.A.R.K - Speech and Text Algorithmic Recognition Kit. Modern framework for creating powerfull voice assistants."
5
5
  authors = ["MarkParker5 <mark@parker-programs.com>"]
6
6
  license = "CC BY-NC-SA 4.0"
@@ -37,7 +37,7 @@ keywords = [
37
37
 
38
38
 
39
39
  [tool.poetry.dependencies]
40
- python = "^3.12"
40
+ python = ">=3.12,<3.15"
41
41
  pydantic = "^2.0.0"
42
42
  asyncer = "^0.0.2"
43
43
  numpy = "^2.3.3"
@@ -48,12 +48,14 @@ soundfile = { version = "^0.11.0", optional = true }
48
48
  vosk = { version = "0.3.44", optional = true }
49
49
  google-cloud-texttospeech = { version = "^2.14.1", optional = true }
50
50
  torch = { version = "^1.13.1", optional = true }
51
+ spacy = { version = "^3.8.11", optional = true }
51
52
 
52
53
  [tool.poetry.extras]
53
54
  gcloud = ["google-cloud-texttospeech", "sounddevice", "soundfile"]
54
55
  vosk = ["vosk", "sounddevice"]
55
56
  silero = ["torch", "numpy", "sounddevice"]
56
57
  sound = ["sounddevice", "soundfile"]
58
+ spacy = ["spacy"]
57
59
  all = [
58
60
  "google-cloud-texttospeech",
59
61
  "vosk",
@@ -61,6 +63,7 @@ all = [
61
63
  "numpy",
62
64
  "sounddevice",
63
65
  "soundfile",
66
+ "spacy",
64
67
  ]
65
68
 
66
69
  [tool.poetry.group.dev.dependencies]
@@ -1,46 +1,50 @@
1
1
  import asyncer
2
2
 
3
- from stark.interfaces.protocols import (
4
- SpeechRecognizer,
5
- SpeechRecognizerDelegate,
6
- SpeechSynthesizer,
7
- SpeechSynthesizerResult
8
- )
9
3
  from stark.core import (
10
4
  Command,
5
+ CommandsContext,
6
+ CommandsManager,
11
7
  Pattern,
12
8
  Response,
13
9
  ResponseStatus,
14
- CommandsContext,
15
- CommandsManager
16
- )
17
- from stark.voice_assistant import (
18
- VoiceAssistant,
19
- Mode
20
10
  )
11
+ from stark.core.commands_context_processor import CommandsContextProcessor
12
+ from stark.core.health_check import health_check
13
+ from stark.core.processors.search_processor import SearchProcessor
21
14
  from stark.general.blockage_detector import BlockageDetector
15
+ from stark.interfaces.protocols import (
16
+ SpeechRecognizer,
17
+ SpeechRecognizerDelegate,
18
+ SpeechSynthesizer,
19
+ SpeechSynthesizerResult,
20
+ )
21
+ from stark.voice_assistant import Mode, VoiceAssistant
22
22
 
23
23
 
24
24
  async def run(
25
25
  manager: CommandsManager,
26
26
  speech_recognizer: SpeechRecognizer,
27
- speech_synthesizer: SpeechSynthesizer
27
+ speech_synthesizer: SpeechSynthesizer,
28
+ processors: list[CommandsContextProcessor] = [SearchProcessor()],
28
29
  ):
29
30
  async with asyncer.create_task_group() as main_task_group:
30
31
  context = CommandsContext(
31
- task_group = main_task_group,
32
- commands_manager = manager
32
+ task_group=main_task_group,
33
+ commands_manager=manager,
34
+ processors=processors,
33
35
  )
34
36
  voice_assistant = VoiceAssistant(
35
- speech_recognizer = speech_recognizer,
36
- speech_synthesizer = speech_synthesizer,
37
- commands_context = context
37
+ speech_recognizer=speech_recognizer,
38
+ speech_synthesizer=speech_synthesizer,
39
+ commands_context=context,
38
40
  )
39
41
  speech_recognizer.delegate = voice_assistant
40
42
  context.delegate = voice_assistant
41
-
43
+
44
+ health_check(context.pattern_parser, manager.commands)
45
+
42
46
  main_task_group.soonify(speech_recognizer.start_listening)()
43
47
  main_task_group.soonify(context.handle_responses)()
44
-
48
+
45
49
  detector = BlockageDetector()
46
50
  main_task_group.soonify(detector.monitor)()
@@ -1,4 +1,3 @@
1
- from .patterns import Pattern, PatternParameter, rules
2
1
  from . import types
3
2
  from .command import (
4
3
  AsyncResponseHandler,
@@ -13,6 +12,4 @@ from .commands_context import (
13
12
  CommandsContextLayer,
14
13
  )
15
14
  from .commands_manager import CommandsManager
16
-
17
- Pattern.add_parameter_type(types.String)
18
- Pattern.add_parameter_type(types.Word)
15
+ from .patterns import Pattern, PatternParameter, rules
@@ -31,11 +31,16 @@ from pydantic import BaseModel, Field
31
31
  from ..general.classproperty import classproperty
32
32
  from .patterns import Pattern
33
33
 
34
- ResponseOptions = Optional['Response'] | Generator[Optional['Response'], None, None] | AsyncGenerator[Optional['Response'], None]
34
+ ResponseOptions = (
35
+ Optional["Response"]
36
+ | Generator[Optional["Response"], None, None]
37
+ | AsyncGenerator[Optional["Response"], None]
38
+ )
35
39
  AwaitResponse = Awaitable[ResponseOptions]
36
40
  AsyncCommandRunner = Callable[..., AwaitResponse]
37
- SyncCommandRunner = Callable[..., Optional['Response']]
38
- CommandRunner = TypeVar('CommandRunner', bound = SyncCommandRunner | AsyncCommandRunner)
41
+ SyncCommandRunner = Callable[..., Optional["Response"]]
42
+ CommandRunner = TypeVar("CommandRunner", bound=SyncCommandRunner | AsyncCommandRunner)
43
+
39
44
 
40
45
  class Command(Generic[CommandRunner]):
41
46
  name: str
@@ -49,7 +54,12 @@ class Command(Generic[CommandRunner]):
49
54
  self._runner = runner
50
55
  update_wrapper(self, runner)
51
56
 
52
- def run(self, parameters_dict: dict[str, Any] | None = None, / , **kwparameters: dict[str, Any]) -> AwaitResponse:
57
+ def run(
58
+ self,
59
+ parameters_dict: dict[str, Any] | None = None,
60
+ /,
61
+ **kwparameters: dict[str, Any],
62
+ ) -> AwaitResponse:
53
63
  # allow call both with and without dict unpacking
54
64
  # e.g. command.run(foo = bar, lorem = ipsum), command.run(**parameters) and command.run(parameters)
55
65
  # where parameters is dict {'foo': bar, 'lorem': ipsum}
@@ -64,20 +74,31 @@ class Command(Generic[CommandRunner]):
64
74
 
65
75
  runner: AsyncCommandRunner
66
76
 
67
- if inspect.iscoroutinefunction(self._runner) or inspect.isasyncgen(self._runner):
68
- # async functions (coroutines) and async generators are remain as is
77
+ if inspect.iscoroutinefunction(self._runner) or inspect.isasyncgen(
78
+ self._runner
79
+ ):
80
+ # async functions (coroutines) and async generators are remain as is
69
81
  runner = cast(AsyncCommandRunner, self._runner)
70
82
  else:
71
83
  # sync functions are wrapped with asyncer.asyncify to make them async (coroutines)
72
84
  # async generators are not supported yet by asyncer.asyncify (https://github.com/tiangolo/asyncer/discussions/86)
73
85
  runner = asyncer.asyncify(cast(SyncCommandRunner, self._runner))
74
86
 
75
- if any(p.kind == p.VAR_KEYWORD for p in inspect.signature(self._runner).parameters.values()):
87
+ if any(
88
+ p.kind == p.VAR_KEYWORD
89
+ for p in inspect.signature(self._runner).parameters.values()
90
+ ):
76
91
  # if command runner accepts **kwargs, pass all parameters
77
92
  coroutine = runner(**parameters)
78
93
  else:
79
94
  # otherwise pass only parameters that are in command runner signature to prevent TypeError: got an unexpected keyword argument
80
- coroutine = runner(**{k: v for k, v in parameters.items() if k in self._runner.__code__.co_varnames})
95
+ coroutine = runner(
96
+ **{
97
+ k: v
98
+ for k, v in parameters.items()
99
+ if k in self._runner.__code__.co_varnames
100
+ }
101
+ )
81
102
 
82
103
  @wraps(runner)
83
104
  async def coroutine_wrapper() -> ResponseOptions:
@@ -86,13 +107,15 @@ class Command(Generic[CommandRunner]):
86
107
  except Exception as e:
87
108
  logger.error(e)
88
109
  response = Response(
89
- text = f'Command {self} raised an exception: {e.__class__.__name__}',
90
- voice = f'Command {self} raised an exception: {e.__class__.__name__}',
91
- status = ResponseStatus.error
110
+ text=f"Command {self} raised an exception: {e.__class__.__name__}",
111
+ voice=f"Command {self} raised an exception: {e.__class__.__name__}",
112
+ status=ResponseStatus.error,
92
113
  )
93
114
  if inspect.isgenerator(response):
94
- message = f'[WARNING] Command {self} is a sync GeneratorType that is not fully supported and may block the main thread. ' + \
95
- 'Consider using the ResponseHandler.respond() or async approach instead.'
115
+ message = (
116
+ f"[WARNING] Command {self} is a sync GeneratorType that is not fully supported and may block the main thread. "
117
+ + "Consider using the ResponseHandler.respond() or async approach instead."
118
+ )
96
119
  warnings.warn(message, UserWarning)
97
120
  return response
98
121
 
@@ -103,7 +126,8 @@ class Command(Generic[CommandRunner]):
103
126
  return self.run(*args, **kwargs)
104
127
 
105
128
  def __repr__(self):
106
- return f'<Command {self.name}>'
129
+ return f"<Command {self.name}>"
130
+
107
131
 
108
132
  class ResponseStatus(Enum):
109
133
  none = auto()
@@ -113,9 +137,10 @@ class ResponseStatus(Enum):
113
137
  info = auto()
114
138
  error = auto()
115
139
 
140
+
116
141
  class Response(BaseModel):
117
- voice: str = ''
118
- text: str = ''
142
+ voice: str = ""
143
+ text: str = ""
119
144
  status: ResponseStatus = ResponseStatus.success
120
145
  needs_user_input: bool = False
121
146
  commands: list[Command] = []
@@ -124,7 +149,7 @@ class Response(BaseModel):
124
149
  id: UUID = Field(default_factory=uuid4)
125
150
  time: datetime = Field(default_factory=datetime.now)
126
151
 
127
- _repeat_last: ClassVar[Response | None] = None # static instance
152
+ _repeat_last: ClassVar[Response | None] = None # static instance
128
153
 
129
154
  @classproperty
130
155
  def repeat_last(cls) -> Response:
@@ -139,12 +164,24 @@ class Response(BaseModel):
139
164
  def __eq__(self, other):
140
165
  return self.id == other.id
141
166
 
167
+
142
168
  class ResponseHandler(Protocol):
143
- def respond(self, response: Response): pass
144
- def unrespond(self, response: Response): pass
145
- def pop_context(self): pass
169
+ def respond(self, response: Response):
170
+ pass
171
+
172
+ def unrespond(self, response: Response):
173
+ pass
174
+
175
+ def pop_context(self):
176
+ pass
177
+
146
178
 
147
179
  class AsyncResponseHandler(Protocol):
148
- async def respond(self, response: Response): pass
149
- async def unrespond(self, response: Response): pass
150
- async def pop_context(self): pass
180
+ async def respond(self, response: Response):
181
+ pass
182
+
183
+ async def unrespond(self, response: Response):
184
+ pass
185
+
186
+ async def pop_context(self):
187
+ pass
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import warnings
4
- from dataclasses import dataclass
5
5
  from types import AsyncGeneratorType, GeneratorType
6
6
  from typing import Any, Protocol, runtime_checkable
7
7
 
@@ -9,6 +9,13 @@ import anyio
9
9
  from asyncer import syncify
10
10
  from asyncer._main import TaskGroup
11
11
 
12
+ from stark.core.commands_context_processor import (
13
+ CommandsContextLayer,
14
+ RecognizedEntity,
15
+ )
16
+ from stark.core.parsing import PatternParser
17
+ from stark.core.types.object import Object
18
+
12
19
  from ..general.dependencies import DependencyManager, default_dependency_manager
13
20
  from .command import (
14
21
  AsyncResponseHandler,
@@ -18,47 +25,58 @@ from .command import (
18
25
  ResponseHandler,
19
26
  ResponseOptions,
20
27
  )
21
- from .commands_manager import CommandsManager, SearchResult
28
+ from .commands_manager import CommandsManager
22
29
 
30
+ logger = logging.getLogger(__name__)
23
31
 
24
- @dataclass
25
- class CommandsContextLayer:
26
- commands: list[Command]
27
- parameters: dict[str, Any]
28
32
 
29
33
  @runtime_checkable
30
34
  class CommandsContextDelegate(Protocol):
31
- async def commands_context_did_receive_response(self, response: Response): pass
32
- def remove_response(self, response: Response): pass
35
+ async def commands_context_did_receive_response(self, response: Response):
36
+ pass
33
37
 
34
- class CommandsContext:
38
+ def remove_response(self, response: Response):
39
+ pass
35
40
 
41
+
42
+ class CommandsContext:
36
43
  is_stopped = False
37
44
  commands_manager: CommandsManager
38
45
  dependency_manager: DependencyManager
46
+ pattern_parser: PatternParser
39
47
  last_response: Response | None = None
40
- fallback_command: Command | None = None
48
+ context_queue: list[CommandsContextLayer]
41
49
 
42
50
  _delegate: CommandsContextDelegate | None = None
43
51
  _response_queue: list[Response]
44
- _context_queue: list[CommandsContextLayer]
45
52
  _task_group: TaskGroup
46
53
 
47
- def __init__(self, task_group: TaskGroup, commands_manager: CommandsManager, dependency_manager: DependencyManager = default_dependency_manager):
54
+ def __init__(
55
+ self,
56
+ task_group: TaskGroup,
57
+ commands_manager: CommandsManager,
58
+ dependency_manager: DependencyManager = default_dependency_manager,
59
+ processors: list[Any] | None = None,
60
+ ):
48
61
  assert isinstance(task_group, TaskGroup), task_group
49
62
  assert isinstance(commands_manager, CommandsManager)
50
63
  assert isinstance(dependency_manager, DependencyManager)
64
+ self.pattern_parser = PatternParser()
51
65
  self.commands_manager = commands_manager
52
- self._context_queue = [self.root_context]
66
+ self.context_queue = [self.root_context]
53
67
  self._response_queue = []
54
68
  self._task_group = task_group
55
69
  self.dependency_manager = dependency_manager
56
70
  self.dependency_manager.add_dependency(None, AsyncResponseHandler, self)
57
71
  self.dependency_manager.add_dependency(None, ResponseHandler, SyncResponseHandler(self))
58
- self.dependency_manager.add_dependency('inject_dependencies', None, self.inject_dependencies)
72
+ self.dependency_manager.add_dependency("inject_dependencies", None, self.inject_dependencies)
73
+ from .processors import SearchProcessor
74
+
75
+ self.processors = processors if processors is not None else [SearchProcessor()]
59
76
 
60
77
  @property
61
- def delegate(self):
78
+ def delegate(self) -> CommandsContextDelegate:
79
+ assert self._delegate is not None
62
80
  return self._delegate
63
81
 
64
82
  @delegate.setter
@@ -71,46 +89,48 @@ class CommandsContext:
71
89
  return CommandsContextLayer(self.commands_manager.commands, {})
72
90
 
73
91
  async def process_string(self, string: str):
92
+ if not self.context_queue:
93
+ self.context_queue.append(self.root_context)
74
94
 
75
- if not self._context_queue:
76
- self._context_queue.append(self.root_context)
95
+ # Run the string and context queue through all the processors
77
96
 
78
- # search commands
79
- search_results = []
80
- while self._context_queue:
81
-
82
- current_context = self._context_queue[0]
83
- search_results = await self.commands_manager.search(string = string, commands = current_context.commands)
97
+ recognized_entities: list[RecognizedEntity] = []
98
+ search_results: list[Any] = []
99
+ context_pops: int = 0
84
100
 
101
+ for processor in self.processors:
102
+ logger.debug(f"Processing context {processor=} with {string=} {recognized_entities=} {self.context_queue=}")
103
+ search_results, context_pops = await processor.process_string(string, self, recognized_entities)
85
104
  if search_results:
105
+ # Pop contexts as directed by processor
106
+ for _ in range(context_pops):
107
+ if self.context_queue:
108
+ self.context_queue.pop(0)
86
109
  break
87
- else:
88
- self._context_queue.pop(0)
110
+ else: # no results found at all
111
+ self.context_queue = [self.root_context] # nothing found, reset to root context
89
112
 
90
- if not search_results and self.fallback_command and (matches := await self.fallback_command.pattern.match(string)):
91
- for match in matches:
92
- search_results = [SearchResult(
93
- command = self.fallback_command,
94
- match_result = match,
95
- index = 0
96
- )]
113
+ # Prepare and execute found commands;
97
114
 
98
115
  for search_result in search_results or []:
99
-
100
- parameters = current_context.parameters
116
+ current_context = self.context_queue[0]
117
+ parameters: dict[str, Object] = {}
118
+ parameters.update(current_context.parameters)
101
119
  parameters.update(search_result.match_result.parameters)
102
120
  parameters.update(self.dependency_manager.resolve(search_result.command._runner))
103
-
104
121
  self.run_command(search_result.command, parameters)
105
122
 
123
+ return search_results or []
124
+
106
125
  def inject_dependencies(self, runner: Command[CommandRunner] | CommandRunner) -> CommandRunner:
107
126
  def injected_func(**kwargs) -> ResponseOptions:
108
127
  kwargs.update(self.dependency_manager.resolve(runner._runner if isinstance(runner, Command) else runner))
109
- return runner(**kwargs) # type: ignore
110
- return injected_func # type: ignore
128
+ return runner(**kwargs) # type: ignore
129
+
130
+ return injected_func # type: ignore
111
131
 
112
132
  def run_command(self, command: Command, parameters: dict[str, Any] = {}):
113
- async def command_runner():
133
+ async def command_task():
114
134
  command_return = await command(parameters)
115
135
 
116
136
  if isinstance(command_return, Response):
@@ -122,8 +142,10 @@ class CommandsContext:
122
142
  await self.respond(response)
123
143
 
124
144
  elif isinstance(command_return, GeneratorType):
125
- message = f'[WARNING] Command {command} is a sync GeneratorType that is not fully supported and may block the main thread. ' + \
126
- 'Consider using the ResponseHandler.respond() or async approach instead.'
145
+ message = (
146
+ f"[WARNING] Command {command} is a sync GeneratorType that is not fully supported and may block the main thread. "
147
+ + "Consider using the ResponseHandler.respond() or async approach instead."
148
+ )
127
149
  warnings.warn(message, UserWarning)
128
150
  for response in command_return:
129
151
  if response:
@@ -133,13 +155,15 @@ class CommandsContext:
133
155
  pass
134
156
 
135
157
  else:
136
- raise TypeError(f'Command {command} returned {command_return} of type {type(command_return)} instead of Response or AsyncGeneratorType[Response]')
158
+ raise TypeError(
159
+ f"Command {command} returned {command_return} of type {type(command_return)} instead of Response or AsyncGeneratorType[Response]"
160
+ )
137
161
 
138
- self._task_group.soonify(command_runner)()
162
+ self._task_group.soonify(command_task)()
139
163
 
140
164
  # ResponseHandler
141
165
 
142
- async def respond(self, response: Response): # async forces to run in main thread
166
+ async def respond(self, response: Response): # async forces to run in main thread
143
167
  assert isinstance(response, Response)
144
168
  self._response_queue.append(response)
145
169
 
@@ -149,15 +173,15 @@ class CommandsContext:
149
173
  self.delegate.remove_response(response)
150
174
 
151
175
  async def pop_context(self):
152
- self._context_queue.pop(0)
176
+ self.context_queue.pop(0)
153
177
 
154
178
  # Context
155
179
 
156
180
  def pop_to_root_context(self):
157
- self._context_queue = [self.root_context]
181
+ self.context_queue = [self.root_context]
158
182
 
159
183
  def add_context(self, context: CommandsContextLayer):
160
- self._context_queue.insert(0, context)
184
+ self.context_queue.insert(0, context)
161
185
 
162
186
  # ResponseQueue
163
187
 
@@ -181,18 +205,19 @@ class CommandsContext:
181
205
 
182
206
  if response.commands:
183
207
  newContext = CommandsContextLayer(response.commands, response.parameters)
184
- self._context_queue.insert(0, newContext)
208
+ self.context_queue.insert(0, newContext)
185
209
 
186
210
  await self.delegate.commands_context_did_receive_response(response)
187
211
 
188
- class SyncResponseHandler: # needs for changing thread from worker to main in commands ran with asyncify
189
212
 
213
+ class SyncResponseHandler: # needs for changing thread from worker to main in commands ran with asyncify
190
214
  async_response_handler: ResponseHandler
191
215
 
192
216
  def __init__(self, async_response_handler: ResponseHandler):
193
217
  self.async_response_handler = async_response_handler
194
218
 
195
219
  # ResponseHandler
220
+ # TODO: review
196
221
 
197
222
  def respond(self, response: Response):
198
223
  syncify(self.async_response_handler.respond)(response)
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from abc import ABC
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from stark.core.command import Command
9
+ from stark.core.parsing import RecognizedEntity
10
+ from stark.core.types.object import Object
11
+
12
+ from .commands_manager import SearchResult
13
+
14
+ if TYPE_CHECKING:
15
+ from stark.core.commands_context import CommandsContext
16
+
17
+
18
+ @dataclass
19
+ class CommandsContextLayer:
20
+ commands: list[Command]
21
+ parameters: dict[str, Object]
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class CommandsContextProcessor(ABC):
28
+ """
29
+ Abstract base class for processors in the CommandsContext pipeline.
30
+
31
+ Use cases:
32
+ - Override `process` for processors that need to view or operate on the entire context queue at once.
33
+ Example: NER markup for the string or AI-powered command search that considers all available contexts and commands in a single shot.
34
+ - Override `process_context` for processors that operate on each context layer individually.
35
+ Example: Classic pattern-based search.
36
+
37
+ In most cases you should only override/implement one.
38
+
39
+ The default implementation of `process` loops through the context queue, calling `process_context` for each layer.
40
+ If any layer returns non-empty results, processing stops and returns those results along with the number of contexts popped.
41
+ If all layers return empty results, all contexts are popped and an empty result list is returned.
42
+ """
43
+
44
+ async def process_string(
45
+ self, string: str, context: CommandsContext, recognized_entities: list[RecognizedEntity]
46
+ ) -> tuple[list[SearchResult], int]:
47
+ """
48
+ Processes the entire context queue.
49
+ Returns a tuple: (results, pops)
50
+ - results: list of SearchResult objects found (may be empty)
51
+ - pops: number of context layers to pop after processing (0 if found a command in the current context)
52
+
53
+ Default behavior: loop through context_queue, calling process_context for each.
54
+ Stops at the first non-empty result and returns (results, pops).
55
+ If all contexts return empty, returns ([], len(context_queue)). If no results, pop amount doesn't matter.
56
+
57
+ If don't implement a command search (for example, implementing some type of pre-processing), return ([], 0)
58
+ """
59
+ pops = 0
60
+ for layer in context.context_queue:
61
+ logger.debug(f"Command search processing context {layer=} with {recognized_entities=}")
62
+ results = await self.process_context_layer(
63
+ string,
64
+ context,
65
+ layer,
66
+ recognized_entities,
67
+ )
68
+ if results:
69
+ return results, pops
70
+ pops += 1
71
+ return [], pops
72
+
73
+ async def process_context_layer(
74
+ self,
75
+ string: str,
76
+ context: CommandsContext,
77
+ context_layer: CommandsContextLayer,
78
+ recognized_entities: list[RecognizedEntity],
79
+ ) -> list[SearchResult]:
80
+ """
81
+ Processes a single context layer.
82
+ Returns a list of SearchResult objects (may be empty).
83
+
84
+ Use case: Implement classic command search or other per-context logic.
85
+ If results are empty, the context will be popped by the caller.
86
+ """
87
+ ...