mmar-mapi 1.0.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.

Potentially problematic release.


This version of mmar-mapi might be problematic. Click here for more details.

@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 AIRI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: mmar-mapi
3
+ Version: 1.0.0
4
+ Summary: Common pure/IO utilities for multi-modal architectures team
5
+ Keywords:
6
+ Author: Eugene Tagin
7
+ Author-email: Eugene Tagin <tagin@airi.net>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Documentation
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Typing :: Typed
22
+ Requires-Dist: pydantic>=2.9.2,<=3.0.0
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # mmar-mapi
27
+
28
+ Multimodal architectures Maestro API
@@ -0,0 +1,3 @@
1
+ # mmar-mapi
2
+
3
+ Multimodal architectures Maestro API
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "mmar-mapi"
3
+ # dynamic version is not supported yet on uv_build
4
+ version = "1.0.0"
5
+ description = "Common pure/IO utilities for multi-modal architectures team"
6
+ authors = [{name = "Eugene Tagin", email = "tagin@airi.net"}]
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ keywords = []
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Documentation",
22
+ "Topic :: Software Development",
23
+ "Topic :: Utilities",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "pydantic>=2.9.2,<=3.0.0",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.8.14,<0.9.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "mmar_mapi"
36
+ source-exclude = [".ruff_cache"]
37
+
38
+ [dependency-groups]
39
+ maintain = [
40
+ "build>=1.2",
41
+ "git-changelog>=2.5",
42
+ "twine>=5.1",
43
+ "yore>=0.3.3",
44
+ ]
45
+ ci = [
46
+ "ruff>=0.4",
47
+ "pytest>=8.2",
48
+ "pytest-cov>=5.0",
49
+ "pytest-randomly>=3.15",
50
+ "pytest-asyncio>=1.0.0",
51
+ "mypy>=1.10",
52
+ "types-markdown>=3.6",
53
+ "types-pyyaml>=6.0",
54
+ ]
55
+
56
+ [tool.uv]
57
+ default-groups = ["maintain", "ci"]
58
+
59
+ [tool.ruff]
60
+ line-length = 120
61
+
62
+ [tool.pytest.ini_options]
63
+ asyncio_mode = "auto"
64
+
65
+ [pytest]
66
+ pythonpath = "src"
67
+ python_files = [ "test_*.py" ]
68
+ testpaths = [ "tests" ]
69
+
70
+ # action:message_regex:warning_class:module_regex:line
71
+ filterwarnings = [ "error" ]
72
+
73
+ [mypy]
74
+ ignore_missing_imports = true
75
+ exclude = "tests/fixtures/"
76
+ warn_unused_ignores = true
77
+ show_error_codes = true
@@ -0,0 +1,11 @@
1
+ __version__ = "5.2.4"
2
+
3
+ from .file_storage import FileStorage, ResourceId
4
+ from .models.base import Base
5
+ from .models.base_config_models import GigaChatConfig
6
+ from .models.chat import Chat, Context, ChatMessage, AIMessage, HumanMessage, MiscMessage, make_content
7
+ from .models.chat_item import ChatItem, OuterContextItem, InnerContextItem, ReplicaItem
8
+ from .models.enums import MTRSLabelEnum, DiagnosticsXMLTagEnum, MTRSXMLTagEnum, DoctorChoiceXMLTagEnum
9
+ from .models.tracks import TrackInfo, DomainInfo
10
+ from .models.widget import Widget
11
+ from .utils import make_session_id
@@ -0,0 +1,62 @@
1
+ from mmar_mapi.models.chat import Chat, ChatMessage
2
+ from mmar_mapi.models.tracks import DomainInfo, TrackInfo
3
+ from pydantic import BaseModel
4
+
5
+
6
+ Value = str
7
+ Interpretation = str
8
+ ResourceId = str
9
+
10
+
11
+ class ChatManagerAPI:
12
+ def get_domains(self, *, client_id: str, language_code: str = "ru") -> list[DomainInfo]:
13
+ raise NotImplementedError
14
+
15
+ def get_tracks(self, *, client_id: str, language_code: str = "ru") -> list[TrackInfo]:
16
+ raise NotImplementedError
17
+
18
+ def get_response(self, *, chat: Chat) -> list[ChatMessage]:
19
+ raise NotImplementedError
20
+
21
+
22
+ class TextGeneratorAPI:
23
+ def process(self, *, chat: Chat) -> str:
24
+ raise NotImplementedError
25
+
26
+
27
+ class ContentInterpreterRemoteResponse(BaseModel):
28
+ interpretation: str
29
+ resource_fname: str
30
+ resource: bytes
31
+
32
+
33
+ class ContentInterpreterRemoteAPI:
34
+ def interpret_remote(
35
+ self, *, kind: str, query: str, resource: bytes, chat: Chat | None = None
36
+ ) -> ContentInterpreterRemoteResponse:
37
+ raise NotImplementedError
38
+
39
+
40
+ class ClassifierAPI:
41
+ def get_values(self) -> list[Value]:
42
+ raise NotImplementedError
43
+
44
+ def evaluate(self, *, chat: Chat) -> Value:
45
+ raise NotImplementedError
46
+
47
+
48
+ class CriticAPI:
49
+ def evaluate(self, *, text: str, chat: Chat | None = None) -> float:
50
+ raise NotImplementedError
51
+
52
+
53
+ class ContentInterpreterAPI:
54
+ def interpret(
55
+ self, *, kind: str, query: str, resource_id: str = "", chat: Chat | None = None
56
+ ) -> tuple[Interpretation, ResourceId | None]:
57
+ raise NotImplementedError
58
+
59
+
60
+ class TextProcessorAPI:
61
+ def process(self, *, text: str, chat: Chat | None = None) -> str:
62
+ raise NotImplementedError
@@ -0,0 +1,113 @@
1
+ import string
2
+ from hashlib import md5
3
+ from pathlib import Path
4
+ from zipfile import ZipFile, is_zipfile
5
+
6
+
7
+ ResourceId = str
8
+ ASCII_DIGITS = set(string.ascii_lowercase + string.digits)
9
+ SUFFIX_DIR = ".dir"
10
+
11
+
12
+ def _validate_exist(files_dir):
13
+ if not files_dir.exists():
14
+ err = f"Failed to access file-storage directory: {files_dir}"
15
+ raise OSError(err)
16
+
17
+
18
+ def _validate_dtype(dtype: str):
19
+ if all(map(ASCII_DIGITS.__contains__, dtype)):
20
+ return
21
+ raise ValueError(f"Bad dtype: {dtype}")
22
+
23
+
24
+ def generate_fname(content, dtype):
25
+ fname_hash = md5(content).hexdigest()
26
+ fname = f"{fname_hash}.{dtype}"
27
+ return fname
28
+
29
+
30
+ class FileStorage:
31
+ def __init__(self, files_dir):
32
+ self.files_dir = Path(files_dir)
33
+ self.files_dir.mkdir(exist_ok=True, parents=True)
34
+ _validate_exist(self.files_dir)
35
+
36
+ def _generate_fname_path(self, content: bytes, dtype: str):
37
+ fpath = self.files_dir / generate_fname(content, dtype)
38
+ return fpath
39
+
40
+ def upload_maybe(self, content: bytes | None, dtype: str) -> ResourceId | None:
41
+ if not content:
42
+ return None
43
+ resource_id = self.upload(content, dtype)
44
+ return resource_id
45
+
46
+ def upload(self, content: bytes | str, dtype: str) -> ResourceId:
47
+ _validate_dtype(dtype)
48
+ if isinstance(content, str):
49
+ content = content.encode()
50
+ fpath = self._generate_fname_path(content, dtype)
51
+ fpath.write_bytes(content)
52
+ return str(fpath)
53
+
54
+ def upload_dir(self, resource_ids: list[ResourceId]) -> ResourceId:
55
+ content = "\n".join(resource_ids)
56
+ res = self.upload(content, "dir")
57
+ return res
58
+
59
+ def download(self, resource_id: ResourceId) -> bytes:
60
+ return Path(resource_id).read_bytes()
61
+
62
+ def download_text(self, resource_id: ResourceId) -> str:
63
+ return Path(resource_id).read_text(encoding="utf-8")
64
+
65
+ def read_dir_or_none(self, resource_id: ResourceId) -> list[ResourceId] | None:
66
+ if not self.is_dir(resource_id):
67
+ return None
68
+ res = self.download_text(resource_id).split("\n")
69
+ return res
70
+
71
+ def _get_path(self, resource_id: ResourceId | None) -> Path | None:
72
+ if not resource_id:
73
+ return None
74
+ path = Path(resource_id)
75
+ return path if (path.exists() and path.is_file()) else None
76
+
77
+ def is_valid(self, resource_id: ResourceId | None) -> bool:
78
+ path = self._get_path(resource_id)
79
+ return path is not None
80
+
81
+ def is_file(self, resource_id: ResourceId | None) -> bool:
82
+ path = self._get_path(resource_id)
83
+ return path and path.suffix != SUFFIX_DIR
84
+
85
+ def is_dir(self, resource_id: ResourceId | None) -> bool:
86
+ path = self._get_path(resource_id)
87
+ return path and path.suffix == SUFFIX_DIR
88
+
89
+ def get_dtype(self, resource_id: ResourceId | None) -> str | None:
90
+ return resource_id and resource_id.rsplit(".")[-1]
91
+
92
+ def unzip_file(self, resource_id: str) -> ResourceId:
93
+ """ takes resource_id which refer to zip-archive, unpacks it and returns directory ResourceId with content of zip-archive """
94
+ path = self._get_path(resource_id)
95
+ if not path:
96
+ raise ValueError(f"Not found path: {resource_id}")
97
+ if not is_zipfile(resource_id):
98
+ raise ValueError(f"Expected zip archive but found: {resource_id}")
99
+
100
+ resource_ids = []
101
+
102
+ with ZipFile(path, mode='r') as zip_file:
103
+ files_info = zip_file.filelist
104
+
105
+ for file_info in zip_file.filelist:
106
+ file_dtype = file_info.filename.rsplit('.')[-1]
107
+ file_bytes = zip_file.read(file_info)
108
+ rid = self.upload(file_bytes, file_dtype)
109
+ resource_ids.append(rid)
110
+
111
+ res = self.upload_dir(resource_ids)
112
+ return res
113
+
File without changes
@@ -0,0 +1,15 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from pydantic import ConfigDict, BaseModel, model_validator
5
+
6
+
7
+ class Base(BaseModel):
8
+ model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True)
9
+
10
+ @model_validator(mode="before")
11
+ @classmethod
12
+ def validate_to_json(cls, value: str | Any) -> Any:
13
+ if isinstance(value, str):
14
+ return cls(**json.loads(value))
15
+ return value
@@ -0,0 +1 @@
1
+ from .gigachat_config import GigaChatConfig
@@ -0,0 +1,14 @@
1
+ from typing import Self
2
+
3
+ from pydantic import BaseModel, model_validator, SecretStr
4
+
5
+
6
+ class GigaChatConfig(BaseModel):
7
+ client_id: SecretStr = SecretStr("")
8
+ client_secret: SecretStr = SecretStr("")
9
+
10
+ @model_validator(mode="after")
11
+ def empty_validator(self) -> Self:
12
+ if not (self.client_id and self.client_secret):
13
+ raise ValueError("creds for gigachat is not filled")
14
+ return self
@@ -0,0 +1,399 @@
1
+ import warnings
2
+ from copy import deepcopy
3
+ from datetime import datetime
4
+ from typing import Any, Literal, TypeVar
5
+ from collections.abc import Callable
6
+
7
+ from mmar_mapi.models.chat_item import ChatItem, ReplicaItem, OuterContextItem
8
+ from mmar_mapi.models.widget import Widget
9
+ from mmar_mapi.type_union import TypeUnion
10
+ from pydantic import Field, ValidationError, Extra
11
+
12
+ from .base import Base
13
+
14
+
15
+ _DT_FORMAT: str = "%Y-%m-%d-%H-%M-%S"
16
+ _EXAMPLE_DT: str = datetime(year=1970, month=1, day=1).strftime(_DT_FORMAT)
17
+ StrDict = dict[str, Any]
18
+ ContentBase = str | Widget | StrDict
19
+ Content = ContentBase | list[ContentBase]
20
+ T = TypeVar("T")
21
+
22
+
23
+ def now_pretty() -> str:
24
+ return datetime.now().strftime(_DT_FORMAT)
25
+
26
+
27
+ class Context(Base):
28
+ client_id: str = Field("", examples=["543216789"])
29
+ user_id: str = Field("", examples=["123456789"])
30
+ session_id: str = Field(default_factory=now_pretty, examples=["987654321"])
31
+ track_id: str = Field("", examples=["Hello"])
32
+ extra: StrDict | None = Field(None, examples=[None])
33
+
34
+ def create_id(self, short: bool = False) -> str:
35
+ uid, sid, cid = self.user_id, self.session_id, self.client_id
36
+ if short:
37
+ return f"{cid}_{uid}_{sid}"
38
+ return f"client_{cid}_user_{uid}_session_{sid}"
39
+
40
+ def _get_deprecated_extra(self, field, default):
41
+ # legacy: eliminate after migration
42
+ res = (self.extra or {}).get(field, default)
43
+ warnings.warn(f"Deprecated property `{field}`, should be eliminated", stacklevel=2)
44
+ return res
45
+
46
+ # fmt: off
47
+ @property
48
+ def sex(self) -> bool: return self._get_deprecated_extra('sex', True)
49
+ @property
50
+ def age(self) -> int: return self._get_deprecated_extra('age', 0)
51
+ @property
52
+ def entrypoint_key(self) -> str: return self._get_deprecated_extra('entrypoint_key', '')
53
+ @property
54
+ def language_code(self) -> str: return self._get_deprecated_extra('language_code', '')
55
+ @property
56
+ def parent_session_id(self) -> str: return self._get_deprecated_extra('parent_session_id', '')
57
+ # fmt: on
58
+
59
+
60
+ def _get_field(obj: dict, field, val_type: type[T]) -> T | None:
61
+ if not isinstance(obj, dict):
62
+ return None
63
+ val = obj.get(field)
64
+ if val is not None and isinstance(val, val_type):
65
+ return val
66
+ return None
67
+
68
+
69
+ def _get_text(obj: Content) -> str:
70
+ if isinstance(obj, str):
71
+ return obj
72
+ if isinstance(obj, list):
73
+ return "".join(map(_get_text, obj))
74
+ if isinstance(obj, dict) and obj.get("type") == "text":
75
+ return _get_field(obj, "text", str) or ""
76
+ return ""
77
+
78
+
79
+ def _modify_text(obj: Content, callback: Callable[[str], str | None]) -> str:
80
+ if isinstance(obj, str):
81
+ return callback(obj)
82
+ if isinstance(obj, list):
83
+ return [_modify_text(el, callback) for el in obj]
84
+ if isinstance(obj, dict) and obj.get("type") == "text":
85
+ text = _get_field(obj, "text", str) or ""
86
+ text_upd = callback(text)
87
+ return {"type": "text", "text": text_upd}
88
+ return obj
89
+
90
+
91
+ def _get_resource_id(obj: Content) -> str | None:
92
+ if isinstance(obj, list):
93
+ return next((el for el in map(_get_resource_id, obj) if el), None)
94
+ if isinstance(obj, dict) and obj.get("type") == "resource_id":
95
+ return _get_field(obj, "resource_id", str)
96
+ return None
97
+
98
+
99
+ def _get_command(obj: Content) -> dict | None:
100
+ if isinstance(obj, list):
101
+ return next((el for el in map(_get_command, obj) if el), None)
102
+ if isinstance(obj, dict) and obj.get("type") == "command":
103
+ return _get_field(obj, "command", dict)
104
+ return None
105
+
106
+
107
+ def _get_widget(obj: Content) -> Widget | None:
108
+ if isinstance(obj, list):
109
+ return next((el for el in map(_get_widget, obj) if el), None)
110
+ if isinstance(obj, Widget):
111
+ return obj
112
+ return None
113
+
114
+
115
+ # todo fix: generalize functions _get_field
116
+
117
+
118
+ class BaseMessage(Base):
119
+ type: str
120
+ content: Content = Field("", examples=["Привет"])
121
+ date_time: str = Field(default_factory=now_pretty, examples=[_EXAMPLE_DT])
122
+ extra: StrDict | None = Field(None, examples=[None])
123
+
124
+ @property
125
+ def text(self) -> str:
126
+ return _get_text(self.content)
127
+
128
+ def modify_text(self, callback: Callable[[str], str]) -> "BaseMessage":
129
+ content_upd = _modify_text(self.content, callback)
130
+ return self.model_copy(update=dict(content=content_upd))
131
+
132
+ @property
133
+ def body(self) -> str:
134
+ # legacy: eliminate after migration
135
+ return self.text
136
+
137
+ @property
138
+ def resource_id(self) -> str | None:
139
+ return _get_resource_id(self.content)
140
+
141
+ @property
142
+ def command(self) -> dict | None:
143
+ return _get_command(self.content)
144
+
145
+ @property
146
+ def widget(self) -> Widget | None:
147
+ return _get_widget(self.content)
148
+
149
+ @staticmethod
150
+ def DATETIME_FORMAT() -> str:
151
+ return _DT_FORMAT
152
+
153
+ def with_now_datetime(self):
154
+ return self.model_copy(update=dict(date_time=now_pretty()))
155
+
156
+ @property
157
+ def is_ai(self):
158
+ return self.type == "ai"
159
+
160
+ @property
161
+ def is_human(self):
162
+ return self.type == "human"
163
+
164
+
165
+ class HumanMessage(BaseMessage):
166
+ type: Literal["human"] = "human"
167
+
168
+
169
+ class AIMessage(BaseMessage):
170
+ type: Literal["ai"] = "ai"
171
+ state: str = Field("", examples=["COLLECTION"])
172
+
173
+ @property
174
+ def action(self) -> str:
175
+ return (self.extra or {}).get("action", "")
176
+
177
+
178
+ class MiscMessage(BaseMessage):
179
+ type: Literal["misc"] = "misc"
180
+
181
+
182
+ ChatMessage = TypeUnion[HumanMessage, AIMessage, MiscMessage]
183
+
184
+
185
+ class Chat(Base):
186
+ context: Context = Field(default_factory=Context)
187
+ messages: list[ChatMessage] = Field(default_factory=list)
188
+
189
+ class Config:
190
+ extra = Extra.ignore
191
+
192
+ def __init__(self, **data):
193
+ extra_fields = set(data.keys()) - set(self.__fields__.keys())
194
+ if extra_fields:
195
+ warnings.warn(f"Chat initialization: extra fields will be ignored: {extra_fields}")
196
+ super().__init__(**data)
197
+
198
+ def create_id(self, short: bool = False) -> str:
199
+ return self.context.create_id(short)
200
+
201
+ @staticmethod
202
+ def parse(chat_obj: str | dict | ChatItem) -> "Chat":
203
+ return _parse_chat_compat(chat_obj)
204
+
205
+ def to_chat_item(self) -> ChatItem:
206
+ return convert_chat_to_chat_item(self)
207
+
208
+ def add_message(self, message: ChatMessage):
209
+ self.messages.append(message)
210
+
211
+ def add_messages(self, messages: list[ChatMessage]):
212
+ for message in messages:
213
+ self.messages.append(message)
214
+
215
+ def replace_messages(self, messages: list[ChatMessage]):
216
+ return self.model_copy(update=dict(messages=messages))
217
+
218
+ def get_last_state(self, default: str = "empty") -> str:
219
+ for ii in range(len(self.messages) - 1, -1, -1):
220
+ message = self.messages[ii]
221
+ if isinstance(message, AIMessage):
222
+ return message.state
223
+ return default
224
+
225
+
226
+ def make_content(
227
+ text: str | None = None,
228
+ *,
229
+ resource_id: str | None = None,
230
+ command: dict | None = None,
231
+ widget: Widget | None = None,
232
+ ) -> Content:
233
+ resource_id = (resource_id or None) and {"type": "resource_id", "resource_id": resource_id}
234
+ command = (command or None) and {"type": "command", "command": command}
235
+
236
+ content = list(filter(None, [text, resource_id, command, widget]))
237
+ if len(content) == 0:
238
+ content = ""
239
+ elif len(content) == 1:
240
+ content = content[0]
241
+
242
+ return content
243
+
244
+
245
+ def convert_replica_item_to_message(replica: ReplicaItem) -> ChatMessage:
246
+ date_time = replica.date_time
247
+ content = make_content(
248
+ text=replica.body,
249
+ resource_id=replica.resource_id,
250
+ command=replica.command,
251
+ widget=replica.widget,
252
+ )
253
+ # legacy: eliminate after migration
254
+ resource_id = (replica.resource_id or None) and {"type": "resource_id", "resource_id": replica.resource_id}
255
+ body = replica.body
256
+ command = (replica.command or None) and {"type": "command", "command": replica.command}
257
+ widget = replica.widget
258
+ date_time = replica.date_time
259
+
260
+ content = list(filter(None, [body, resource_id, command, widget]))
261
+ if len(content) == 0:
262
+ content = ""
263
+ elif len(content) == 1:
264
+ content = content[0]
265
+
266
+ is_bot_message = replica.role
267
+
268
+ if is_bot_message:
269
+ kwargs = dict(
270
+ content=content,
271
+ date_time=date_time,
272
+ state=replica.state,
273
+ extra=dict(
274
+ **(replica.extra or {}),
275
+ action=replica.action,
276
+ moderation=replica.moderation,
277
+ ),
278
+ )
279
+ res = AIMessage(**kwargs)
280
+ else:
281
+ kwargs = dict(content=content, date_time=date_time)
282
+ res = HumanMessage(**kwargs)
283
+ return res
284
+
285
+
286
+ def convert_outer_context_to_context(octx: OuterContextItem) -> Context:
287
+ # legacy: eliminate after migration
288
+ context = Context(
289
+ client_id=octx.client_id,
290
+ user_id=octx.user_id,
291
+ session_id=octx.session_id,
292
+ track_id=octx.track_id,
293
+ extra=dict(
294
+ sex=octx.sex,
295
+ age=octx.age,
296
+ parent_session_id=octx.parent_session_id,
297
+ entrypoint_key=octx.entrypoint_key,
298
+ language_code=octx.language_code,
299
+ ),
300
+ )
301
+ return context
302
+
303
+
304
+ def convert_chat_item_to_chat(chat_item: ChatItem) -> Chat:
305
+ # legacy: eliminate after migration
306
+ context = convert_outer_context_to_context(chat_item.outer_context)
307
+ messages = list(map(convert_replica_item_to_message, chat_item.inner_context.replicas))
308
+ res = Chat(context=context, messages=messages)
309
+ return res
310
+
311
+
312
+ def convert_context_to_outer_context(context: Context) -> OuterContextItem:
313
+ # legacy: eliminate after migration
314
+ extra = context.extra or {}
315
+ return OuterContextItem(
316
+ client_id=context.client_id,
317
+ user_id=context.user_id,
318
+ session_id=context.session_id,
319
+ track_id=context.track_id,
320
+ sex=extra.get("sex"),
321
+ age=extra.get("age"),
322
+ parent_session_id=extra.get("parent_session_id"),
323
+ entrypoint_key=extra.get("entrypoint_key"),
324
+ language_code=extra.get("language_code"),
325
+ )
326
+
327
+
328
+ def convert_message_to_replica_item(message: ChatMessage) -> ReplicaItem | None:
329
+ # legacy: eliminate after migration
330
+ m_type = message.type
331
+ if m_type in {"ai", "human"}:
332
+ role = m_type == "ai"
333
+ else:
334
+ return None
335
+
336
+ extra = deepcopy(message.extra) if message.extra else {}
337
+ action = extra.pop("action", "")
338
+ moderation = extra.pop("moderation", "OK")
339
+
340
+ kwargs = dict(
341
+ role=role,
342
+ body=message.text,
343
+ resource_id=message.resource_id,
344
+ command=message.command,
345
+ widget=message.widget,
346
+ date_time=message.date_time,
347
+ extra=extra or None,
348
+ state=getattr(message, "state", ""),
349
+ action=action,
350
+ moderation=moderation,
351
+ )
352
+ return ReplicaItem(**kwargs)
353
+
354
+
355
+ def convert_chat_to_chat_item(chat: Chat) -> ChatItem:
356
+ # legacy: eliminate after migration
357
+ return ChatItem(
358
+ outer_context=convert_context_to_outer_context(chat.context),
359
+ inner_context=dict(replicas=list(map(convert_message_to_replica_item, chat.messages))),
360
+ )
361
+
362
+
363
+ def parse_chat_item_as_chat(chat_obj: str | dict | ChatItem) -> Chat:
364
+ # legacy: eliminate after migration
365
+ if isinstance(chat_obj, ChatItem):
366
+ chat_item = chat_obj
367
+ else:
368
+ chat_item = ChatItem.parse(chat_obj)
369
+ res = convert_chat_item_to_chat(chat_item)
370
+ return res
371
+
372
+
373
+ def _parse_chat(chat_obj: str | dict) -> Chat:
374
+ if isinstance(chat_obj, dict):
375
+ return Chat.model_validate(chat_obj)
376
+
377
+ return Chat.model_validate_json(chat_obj)
378
+
379
+
380
+ def is_chat_item(chat_obj: str | dict | ChatItem) -> bool:
381
+ if isinstance(chat_obj, ChatItem):
382
+ return True
383
+ if isinstance(chat_obj, dict):
384
+ return "OuterContext" in chat_obj
385
+ if isinstance(chat_obj, str):
386
+ return "OuterContext" in chat_obj
387
+ warnings.warn(f"Unexpected chat object: {chat_obj} :: {type(chat_obj)}")
388
+ return False
389
+
390
+
391
+ def _parse_chat_compat(chat_obj: str | dict | ChatItem) -> Chat:
392
+ # legacy: eliminate after migration
393
+ if is_chat_item(chat_obj):
394
+ return parse_chat_item_as_chat(chat_obj)
395
+ try:
396
+ return _parse_chat(chat_obj)
397
+ except ValidationError as ex:
398
+ warnings.warn(f"Failed to parse chat: {ex}")
399
+ return parse_chat_item_as_chat(chat_obj)
@@ -0,0 +1,157 @@
1
+ from datetime import datetime
2
+ from typing import Annotated, Any
3
+ from collections.abc import Callable
4
+
5
+ from mmar_mapi.models.widget import Widget
6
+ from pydantic import Field, ConfigDict, BeforeValidator, AfterValidator
7
+
8
+ from .base import Base
9
+
10
+
11
+ _DT_FORMAT: str = "%Y-%m-%d-%H-%M-%S"
12
+ _EXAMPLE_DT_0 = datetime(1970, 1, 1, 0, 0, 0)
13
+ _EXAMPLE_DT: str = _EXAMPLE_DT_0.strftime(_DT_FORMAT)
14
+
15
+
16
+ def now_pretty() -> str:
17
+ return datetime.now().strftime(ReplicaItem.DATETIME_FORMAT())
18
+
19
+
20
+ class OuterContextItem(Base):
21
+ # remove annoying warning for protected `model_` namespace
22
+ model_config = ConfigDict(protected_namespaces=())
23
+
24
+ sex: bool = Field(False, alias="Sex", description="True = male, False = female", examples=[True])
25
+ age: int = Field(0, alias="Age", examples=[20])
26
+ user_id: str = Field("", alias="UserId", examples=["123456789"])
27
+ parent_session_id: str | None = Field(None, alias="ParentSessionId", examples=["987654320"])
28
+ session_id: str = Field("", alias="SessionId", examples=["987654321"])
29
+ client_id: str = Field("", alias="ClientId", examples=["543216789"])
30
+ track_id: str = Field(default="Consultation", alias="TrackId")
31
+ entrypoint_key: str = Field("", alias="EntrypointKey", examples=["giga"])
32
+ language_code: str = Field("ru", alias="LanguageCode", examples=["ru"])
33
+
34
+ def create_id(self, short: bool = False) -> str:
35
+ uid, sid, cid = self.user_id, self.session_id, self.client_id
36
+ if short:
37
+ return f"{uid}_{sid}_{cid}"
38
+ return f"user_{uid}_session_{sid}_client_{cid}"
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return self.model_dump(by_alias=True)
42
+
43
+
44
+ LABELS = {
45
+ 0: "OK",
46
+ 1: "NON_MED",
47
+ 2: "CHILD",
48
+ 3: "ABSURD",
49
+ 4: "GREETING",
50
+ 5: "RECEIPT",
51
+ }
52
+
53
+
54
+ def fix_deprecated_moderation(moderation):
55
+ if isinstance(moderation, int):
56
+ return LABELS.get(moderation, "OK")
57
+ elif isinstance(moderation, str):
58
+ return moderation
59
+ else:
60
+ raise ValueError(f"Unsupported moderation: {moderation} :: {type(moderation)}")
61
+
62
+
63
+ def nullify_empty(text: str) -> str | None:
64
+ return text or None
65
+
66
+
67
+ class ReplicaItem(Base):
68
+ body: str = Field("", alias="Body", examples=["Привет"])
69
+ resource_id: Annotated[str | None, AfterValidator(nullify_empty)] = Field(
70
+ None, alias="ResourceId", examples=["<link-id>"]
71
+ )
72
+ widget: Widget | None = Field(None, alias="Widget", examples=[None])
73
+ command: dict | None = Field(None, alias="Command", examples=[None])
74
+ role: bool = Field(False, alias="Role", description="True = ai, False = client", examples=[False])
75
+ date_time: str = Field(
76
+ default_factory=now_pretty, alias="DateTime", examples=[_EXAMPLE_DT], description=f"Format: {_DT_FORMAT}"
77
+ )
78
+ state: str = Field("", alias="State", description="chat manager fsm state", examples=["COLLECTION"])
79
+ action: str = Field("", alias="Action", description="chat manager fsm action", examples=["DIAGNOSIS"])
80
+ # todo fix: support loading from `moderation: int`
81
+ moderation: Annotated[str, BeforeValidator(str)] = Field(
82
+ "OK", alias="Moderation", description="moderation outcome", examples=["OK"]
83
+ )
84
+ extra: dict | None = Field(None, alias="Extra", examples=[None])
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return self.model_dump(by_alias=True)
88
+
89
+ @staticmethod
90
+ def DATETIME_FORMAT() -> str:
91
+ return _DT_FORMAT
92
+
93
+ def with_now_datetime(self):
94
+ return self.model_copy(update=dict(date_time=now_pretty()))
95
+
96
+ @property
97
+ def is_ai(self):
98
+ return self.role
99
+
100
+ @property
101
+ def is_human(self):
102
+ return not self.role
103
+
104
+ def modify_text(self, callback: Callable[[str], str]) -> "ReplicaItem":
105
+ body_upd = callback(self.body)
106
+ return self.model_copy(update=dict(body=body_upd))
107
+
108
+
109
+ class InnerContextItem(Base):
110
+ replicas: list[ReplicaItem] = Field(alias="Replicas")
111
+ attrs: dict[str, str | int] | None = Field(default={}, alias="Attrs")
112
+
113
+ def to_dict(self) -> dict[str, list]:
114
+ return self.model_dump(by_alias=True)
115
+
116
+
117
+ class ChatItem(Base):
118
+ outer_context: OuterContextItem = Field(alias="OuterContext")
119
+ inner_context: InnerContextItem = Field(alias="InnerContext")
120
+
121
+ def create_id(self, short: bool = False) -> str:
122
+ return self.outer_context.create_id(short)
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ return self.model_dump(by_alias=True)
126
+
127
+ def add_replica(self, replica: ReplicaItem):
128
+ self.inner_context.replicas.append(replica)
129
+
130
+ def add_replicas(self, replicas: list[ReplicaItem]):
131
+ for replica in replicas:
132
+ self.inner_context.replicas.append(replica)
133
+
134
+ def replace_replicas(self, replicas: list[ReplicaItem]):
135
+ return self.model_copy(update=dict(inner_context=InnerContextItem(replicas=replicas)))
136
+
137
+ def get_last_state(self, default: str = "empty") -> str:
138
+ replicas = self.inner_context.replicas
139
+ for ii in range(len(replicas) - 1, -1, -1):
140
+ replica = replicas[ii]
141
+ if replica.role:
142
+ return replica.state
143
+ return default
144
+
145
+ def zip_history(self, field: str) -> list[Any]:
146
+ return [replica.to_dict().get(field, None) for replica in self.inner_context.replicas]
147
+
148
+ @classmethod
149
+ def parse(cls, chat_obj: str | dict) -> "ChatItem":
150
+ return _parse_chat_item(chat_obj)
151
+
152
+
153
+ def _parse_chat_item(chat_obj: str | dict) -> ChatItem:
154
+ if isinstance(chat_obj, dict):
155
+ return ChatItem.model_validate(chat_obj)
156
+
157
+ return ChatItem.model_validate_json(chat_obj)
@@ -0,0 +1,40 @@
1
+ from enum import StrEnum, auto
2
+
3
+
4
+ class DoctorChoiceXMLTagEnum(StrEnum):
5
+ @staticmethod
6
+ def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str:
7
+ return f"{name.lower()}"
8
+
9
+ DIAGNOSTICS = auto()
10
+ SUMMARIZATION = auto()
11
+ MTRS = auto()
12
+
13
+
14
+ class MTRSXMLTagEnum(StrEnum):
15
+ @staticmethod
16
+ def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str:
17
+ return f"mtrs_{name.lower()}"
18
+
19
+ NAME = auto()
20
+ LABEL = auto()
21
+ DESC = auto()
22
+
23
+
24
+ class MTRSLabelEnum(StrEnum):
25
+ @staticmethod
26
+ def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str:
27
+ return name.upper()
28
+
29
+ LABORATORY = auto()
30
+ INSTRUMENTAL = auto()
31
+
32
+
33
+ class DiagnosticsXMLTagEnum(StrEnum):
34
+ @staticmethod
35
+ def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str:
36
+ return f"diag_{name.lower()}"
37
+
38
+ DIAG = auto()
39
+ DOC = auto()
40
+ DESC = auto()
@@ -0,0 +1,14 @@
1
+ from pydantic import Field
2
+
3
+ from .base import Base
4
+
5
+
6
+ class TrackInfo(Base):
7
+ track_id: str = Field(alias="TrackId")
8
+ name: str = Field(alias="Name")
9
+ domain_id: str = Field(alias="DomainId")
10
+
11
+
12
+ class DomainInfo(Base):
13
+ domain_id: str = Field(alias="DomainId")
14
+ name: str = Field(alias="Name")
@@ -0,0 +1,22 @@
1
+ from typing import Self, Literal
2
+
3
+ from pydantic import BaseModel, model_validator
4
+
5
+
6
+ class Widget(BaseModel):
7
+ type: Literal["widget"] = "widget"
8
+ buttons: list[list[str]] | None = None
9
+ ibuttons: list[list[str]] | None = None
10
+
11
+ @model_validator(mode="after")
12
+ def check(self) -> Self:
13
+ if not self.buttons and not self.ibuttons:
14
+ raise ValueError("Empty widget is not allowed!")
15
+ if not self.ibuttons:
16
+ return self
17
+ for row in self.ibuttons:
18
+ for btn in row:
19
+ if ":" in btn:
20
+ continue
21
+ raise ValueError(f"Expected buttons like `<callback>:<caption>`, found: {btn}")
22
+ return self
@@ -0,0 +1,63 @@
1
+ from typing import Any, Union, Annotated, get_args, get_origin, Literal
2
+
3
+ from pydantic import Discriminator, BaseModel, Tag
4
+
5
+
6
+ class _TypeUnionMeta(type):
7
+ def __getitem__(cls, types):
8
+ if not isinstance(types, tuple):
9
+ types = (types,)
10
+
11
+ for tp in types:
12
+ if not isinstance(tp, type) or not issubclass(tp, BaseModel):
13
+ raise ValueError(f"Type {tp} must derived from BaseModel")
14
+
15
+ # Create tagged union
16
+ tagged_types = []
17
+ type_map = {}
18
+ type_names = set()
19
+ for t in types:
20
+ type_annot = t.__annotations__.get("type")
21
+ if not type_annot or get_origin(type_annot) != Literal:
22
+ raise ValueError(f"Type {t} must have a 'type' Literal[..] field for discrimination")
23
+ type_name = get_args(type_annot)[0]
24
+ type_map[type_name] = t
25
+ tagged_types.append(Annotated[t, Tag(type_name)])
26
+ type_names.add(type_name)
27
+
28
+ # Create the union type
29
+ union_type = Union[tuple(tagged_types)]
30
+
31
+ # Create discriminator function
32
+ def type_discriminator(v: Any) -> str:
33
+ if isinstance(v, types):
34
+ return v.type
35
+ if isinstance(v, dict):
36
+ tp = v.get("type")
37
+ return tp if tp in type_names else None
38
+ return None
39
+
40
+ # Add discriminator
41
+ return Annotated[union_type, Discriminator(type_discriminator)]
42
+
43
+
44
+ class TypeUnion(metaclass=_TypeUnionMeta):
45
+ """
46
+ Wrapper around Union which derive type from field 'type' for effective deserialization via TypeAdapter.
47
+
48
+ Usage example:
49
+ ```
50
+ class BaseFruit(BaseModel):
51
+ type: str
52
+
53
+ class Apple(BaseFruit):
54
+ type: Literal['apple'] = 'apple'
55
+
56
+ class Orange(BaseFruit):
57
+ type: Literal['orange'] = 'orange'
58
+
59
+ Fruit = TypeUnion[Apple, Orange]
60
+ ```
61
+ """
62
+
63
+ pass
@@ -0,0 +1,5 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def make_session_id() -> str:
5
+ return f"{datetime.now():%Y-%m-%d--%H-%M-%S}"