service-forge 0.1.11__py3-none-any.whl → 0.1.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of service-forge might be problematic. Click here for more details.

Files changed (42) hide show
  1. service_forge/api/http_api.py +4 -0
  2. service_forge/api/routers/feedback/feedback_router.py +148 -0
  3. service_forge/api/routers/service/service_router.py +22 -32
  4. service_forge/current_service.py +14 -0
  5. service_forge/db/database.py +46 -32
  6. service_forge/db/migrations/feedback_migration.py +154 -0
  7. service_forge/db/models/__init__.py +0 -0
  8. service_forge/db/models/feedback.py +33 -0
  9. service_forge/llm/__init__.py +5 -0
  10. service_forge/model/feedback.py +30 -0
  11. service_forge/service.py +118 -126
  12. service_forge/service_config.py +42 -156
  13. service_forge/sft/cli.py +39 -0
  14. service_forge/sft/cmd/remote_deploy.py +160 -0
  15. service_forge/sft/cmd/remote_list_tars.py +111 -0
  16. service_forge/sft/config/injector.py +46 -24
  17. service_forge/sft/config/injector_default_files.py +1 -1
  18. service_forge/sft/config/sft_config.py +55 -8
  19. service_forge/storage/__init__.py +5 -0
  20. service_forge/storage/feedback_storage.py +245 -0
  21. service_forge/utils/default_type_converter.py +1 -1
  22. service_forge/utils/type_converter.py +5 -0
  23. service_forge/utils/workflow_clone.py +3 -2
  24. service_forge/workflow/node.py +8 -0
  25. service_forge/workflow/nodes/llm/query_llm_node.py +1 -1
  26. service_forge/workflow/trigger.py +4 -0
  27. service_forge/workflow/triggers/a2a_api_trigger.py +2 -0
  28. service_forge/workflow/triggers/fast_api_trigger.py +32 -0
  29. service_forge/workflow/triggers/kafka_api_trigger.py +3 -0
  30. service_forge/workflow/triggers/once_trigger.py +4 -1
  31. service_forge/workflow/triggers/period_trigger.py +4 -1
  32. service_forge/workflow/triggers/websocket_api_trigger.py +15 -11
  33. service_forge/workflow/workflow.py +74 -31
  34. service_forge/workflow/workflow_callback.py +3 -2
  35. service_forge/workflow/workflow_config.py +66 -0
  36. service_forge/workflow/workflow_factory.py +86 -85
  37. service_forge/workflow/workflow_group.py +33 -9
  38. {service_forge-0.1.11.dist-info → service_forge-0.1.24.dist-info}/METADATA +1 -1
  39. {service_forge-0.1.11.dist-info → service_forge-0.1.24.dist-info}/RECORD +41 -31
  40. service_forge/api/routers/service/__init__.py +0 -4
  41. {service_forge-0.1.11.dist-info → service_forge-0.1.24.dist-info}/WHEEL +0 -0
  42. {service_forge-0.1.11.dist-info → service_forge-0.1.24.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,160 @@
1
+ import os
2
+ import json
3
+ import requests
4
+ from pathlib import Path
5
+ from service_forge.sft.util.logger import log_error, log_info, log_success, log_warning
6
+ from service_forge.sft.config.sft_config import sft_config
7
+
8
+ def remote_deploy_tar(filename: str, service_center_url: str = None) -> None:
9
+ """
10
+ Remote deploy specified tar package from service-center
11
+ """
12
+ # If URL is not provided, try to get it from configuration
13
+ if not service_center_url:
14
+ service_center_url = getattr(sft_config, 'service_center_address', 'http://localhost:5000')
15
+
16
+ # Ensure URL ends with /
17
+ if not service_center_url.endswith('/'):
18
+ service_center_url += '/'
19
+
20
+ api_url = f"{service_center_url}api/v1/services/deploy-from-tar"
21
+
22
+ log_info(f"Sending deployment request to {api_url}...")
23
+ log_info(f"Tar package to deploy: {filename}")
24
+
25
+ try:
26
+ # Prepare request data
27
+ data = {
28
+ "filename": filename
29
+ }
30
+
31
+ # Send POST request
32
+ response = requests.post(
33
+ api_url,
34
+ json=data,
35
+ headers={'Content-Type': 'application/json'},
36
+ timeout=300 # 5 minute timeout
37
+ )
38
+
39
+ if response.status_code != 200:
40
+ log_error(f"Deployment request failed, status code: {response.status_code}")
41
+ try:
42
+ error_data = response.json()
43
+ log_error(f"Error message: {error_data.get('message', 'Unknown error')}")
44
+ if 'data' in error_data and error_data['data']:
45
+ log_error(f"Details: {json.dumps(error_data['data'], indent=2, ensure_ascii=False)}")
46
+ except:
47
+ log_error(f"Response content: {response.text}")
48
+ return
49
+
50
+ # Parse response data
51
+ result = response.json()
52
+
53
+ if result.get('code') != 200:
54
+ log_error(f"Deployment failed: {result.get('message', 'Unknown error')}")
55
+ if 'data' in result and result['data']:
56
+ log_error(f"Details: {json.dumps(result['data'], indent=2, ensure_ascii=False)}")
57
+ return
58
+
59
+ # Deployment successful
60
+ data = result.get('data', {})
61
+ service_name = data.get('service_name', 'Unknown')
62
+ version = data.get('version', 'Unknown')
63
+ deploy_output = data.get('deploy_output', '')
64
+
65
+ log_success(f"Successfully deployed service: {service_name} version: {version}")
66
+
67
+ if deploy_output:
68
+ log_info("Deployment output:")
69
+ print(deploy_output)
70
+
71
+ except requests.exceptions.Timeout:
72
+ log_error("Deployment request timed out (exceeded 5 minutes), please check service status or try again later")
73
+ except requests.exceptions.RequestException as e:
74
+ log_error(f"Request failed: {str(e)}")
75
+ log_info(f"Please check if service-center service is running normally and if the URL is correct: {service_center_url}")
76
+ except Exception as e:
77
+ log_error(f"Exception occurred while deploying tar package: {str(e)}")
78
+
79
+ def remote_list_and_deploy(service_center_url: str = None) -> None:
80
+ """
81
+ List remote tar packages first, then let user select which package to deploy
82
+ """
83
+ # If URL is not provided, try to get it from configuration
84
+ if not service_center_url:
85
+ service_center_url = getattr(sft_config, 'service_center_address', 'http://localhost:5000')
86
+
87
+ # Ensure URL ends with /
88
+ if not service_center_url.endswith('/'):
89
+ service_center_url += '/'
90
+
91
+ api_url = f"{service_center_url}api/v1/services/tar-list"
92
+
93
+ log_info(f"Getting tar package list from {api_url}...")
94
+
95
+ try:
96
+ # 发送GET请求获取tar包列表
97
+ response = requests.get(api_url, timeout=30)
98
+
99
+ if response.status_code != 200:
100
+ log_error(f"Failed to get tar package list, status code: {response.status_code}")
101
+ return
102
+
103
+ # Parse response data
104
+ result = response.json()
105
+
106
+ if result.get('code') != 200:
107
+ log_error(f"Failed to get tar package list: {result.get('message', 'Unknown error')}")
108
+ return
109
+
110
+ tar_files = result.get('data', [])
111
+
112
+ if not tar_files:
113
+ log_info("No tar packages found")
114
+ return
115
+
116
+ # Display tar package list
117
+ log_info("Available tar package list:")
118
+ for i, tar_file in enumerate(tar_files, 1):
119
+ filename = tar_file.get('filename', '-')
120
+ service_name = tar_file.get('service_name', '-')
121
+ version = tar_file.get('version', '-')
122
+ deployed_status = "Deployed" if tar_file.get('deployed_status', False) else "Not Deployed"
123
+
124
+ print(f"{i}. {filename} (service: {service_name}, version: {version}, status: {deployed_status})")
125
+
126
+ # Let user choose
127
+ try:
128
+ choice = input("\nEnter the number of the tar package to deploy (enter 'q' to exit): ").strip()
129
+
130
+ if choice.lower() == 'q':
131
+ log_info("Deployment cancelled")
132
+ return
133
+
134
+ index = int(choice) - 1
135
+ if 0 <= index < len(tar_files):
136
+ selected_tar = tar_files[index]
137
+ filename = selected_tar.get('filename')
138
+
139
+ if selected_tar.get('deployed_status', False):
140
+ log_warning(f"Tar package {filename} is already deployed, continue deployment?")
141
+ confirm = input("Enter 'y' to continue, any other key to cancel: ").strip().lower()
142
+ if confirm != 'y':
143
+ log_info("Deployment cancelled")
144
+ return
145
+
146
+ log_info(f"Selected for deployment: {filename}")
147
+ remote_deploy_tar(filename, service_center_url)
148
+ else:
149
+ log_error("Invalid selection")
150
+
151
+ except ValueError:
152
+ log_error("Please enter a valid number")
153
+ except KeyboardInterrupt:
154
+ log_info("\nDeployment cancelled")
155
+
156
+ except requests.exceptions.RequestException as e:
157
+ log_error(f"Request failed: {str(e)}")
158
+ log_info(f"Please check if service-center service is running normally and if the URL is correct: {service_center_url}")
159
+ except Exception as e:
160
+ log_error(f"Exception occurred while getting tar package list: {str(e)}")
@@ -0,0 +1,111 @@
1
+ import os
2
+ import json
3
+ import requests
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from service_forge.sft.util.logger import log_error, log_info, log_success, log_warning
8
+ from service_forge.sft.config.sft_config import sft_config
9
+
10
+ def remote_list_tars(service_center_url: str = None) -> None:
11
+ """
12
+ Get remote tar package list and status from service-center
13
+ """
14
+ # If URL is not provided, try to get it from configuration
15
+ if not service_center_url:
16
+ service_center_url = getattr(sft_config, 'service_center_address', 'http://localhost:5000')
17
+
18
+ # Ensure URL ends with /
19
+ if not service_center_url.endswith('/'):
20
+ service_center_url += '/'
21
+
22
+ api_url = f"{service_center_url}api/v1/services/tar-list"
23
+
24
+ log_info(f"Getting tar package list from {api_url}...")
25
+
26
+ try:
27
+ # 发送GET请求
28
+ response = requests.get(api_url, timeout=30)
29
+
30
+ if response.status_code != 200:
31
+ log_error(f"Failed to get tar package list, status code: {response.status_code}")
32
+ try:
33
+ error_data = response.json()
34
+ log_error(f"Error message: {error_data.get('message', 'Unknown error')}")
35
+ except:
36
+ log_error(f"Response content: {response.text}")
37
+ return
38
+
39
+ # Parse response data
40
+ result = response.json()
41
+
42
+ if result.get('code') != 200:
43
+ log_error(f"Failed to get tar package list: {result.get('message', 'Unknown error')}")
44
+ return
45
+
46
+ tar_files = result.get('data', [])
47
+
48
+ if not tar_files:
49
+ log_info("No tar packages found")
50
+ return
51
+
52
+ # Use rich table to display results
53
+ console = Console()
54
+ table = Table(title="Remote Server Tar Package List", show_header=True, header_style="bold magenta")
55
+ table.add_column("Filename", style="cyan", no_wrap=True)
56
+ table.add_column("Service Name", style="green", no_wrap=True)
57
+ table.add_column("Version", style="blue", no_wrap=True)
58
+ table.add_column("Size", justify="right", style="yellow")
59
+ table.add_column("Modified Time", style="dim")
60
+ table.add_column("Deploy Status", justify="center", style="bold")
61
+
62
+ for tar_file in tar_files:
63
+ # Format file size
64
+ size = _format_size(tar_file.get('file_size', 0))
65
+
66
+ # Format modification time
67
+ modified_time = _format_time(tar_file.get('modified_time', 0))
68
+
69
+ # Deployment status
70
+ deployed_status = "✅ Deployed" if tar_file.get('deployed_status', False) else "❌ Not Deployed"
71
+ status_style = "green" if tar_file.get('deployed_status', False) else "red"
72
+
73
+ table.add_row(
74
+ tar_file.get('filename', '-'),
75
+ tar_file.get('service_name', '-'),
76
+ tar_file.get('version', '-'),
77
+ size,
78
+ modified_time,
79
+ f"[{status_style}]{deployed_status}[/{status_style}]"
80
+ )
81
+
82
+ console.print(table)
83
+ log_success(f"Found {len(tar_files)} tar packages in total")
84
+
85
+ except requests.exceptions.RequestException as e:
86
+ log_error(f"Request failed: {str(e)}")
87
+ log_info(f"Please check if service-center service is running normally and if the URL is correct: {service_center_url}")
88
+ except Exception as e:
89
+ log_error(f"Exception occurred while getting tar package list: {str(e)}")
90
+
91
+ def _format_size(size_bytes: int) -> str:
92
+ """Format file size"""
93
+ if size_bytes == 0:
94
+ return "0 B"
95
+
96
+ for unit in ['B', 'KB', 'MB', 'GB']:
97
+ if size_bytes < 1024.0:
98
+ return f"{size_bytes:.2f} {unit}"
99
+ size_bytes /= 1024.0
100
+ return f"{size_bytes:.2f} TB"
101
+
102
+ def _format_time(timestamp: float) -> str:
103
+ """Format timestamp"""
104
+ if timestamp == 0:
105
+ return "-"
106
+
107
+ try:
108
+ from datetime import datetime
109
+ return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
110
+ except:
111
+ return "-"
@@ -1,10 +1,10 @@
1
- from omegaconf import OmegaConf
1
+ import yaml
2
2
  from pathlib import Path
3
3
  from service_forge.sft.util.logger import log_info, log_error
4
4
  from service_forge.sft.config.injector_default_files import *
5
5
  from service_forge.sft.config.sf_metadata import load_metadata
6
6
  from service_forge.sft.config.sft_config import sft_config
7
- from service_forge.service_config import ServiceConfig
7
+ from service_forge.service_config import ServiceConfig, ServiceFeedbackConfig
8
8
  from service_forge.sft.util.name_util import get_service_name
9
9
  from service_forge.sft.util.yaml_utils import load_sf_metadata_as_string
10
10
 
@@ -16,6 +16,7 @@ class Injector:
16
16
  self.ingress_yaml_path = project_dir / "ingress.yaml"
17
17
  self.dockerfile_path = project_dir / "Dockerfile"
18
18
  self.pyproject_toml_path = project_dir / "pyproject.toml"
19
+ self.start_sh_path = project_dir / "start.sh"
19
20
  self.metadata = load_metadata(self.metadata_path)
20
21
  self.name = self.metadata.name
21
22
  self.version = self.metadata.version
@@ -60,31 +61,41 @@ class Injector:
60
61
 
61
62
  def inject_service_config(self) -> None:
62
63
  service_config_path = self.project_dir / Path(self.metadata.service_config)
63
- config = ServiceConfig.from_dict(OmegaConf.to_object(OmegaConf.load(self.project_dir / self.metadata.service_config)))
64
+
65
+ config = ServiceConfig.from_yaml_file(service_config_path)
64
66
 
65
67
  config.http_port = sft_config.inject_http_port
66
68
  config.kafka_host = sft_config.inject_kafka_host
67
69
  config.kafka_port = sft_config.inject_kafka_port
68
- for database in config.databases:
69
- if database.postgres_host is not None:
70
- database.postgres_host = sft_config.inject_postgres_host
71
- database.postgres_port = sft_config.inject_postgres_port
72
- database.postgres_user = sft_config.inject_postgres_user
73
- database.postgres_password = sft_config.inject_postgres_password
74
- database.postgres_db = self.service_name
75
- if database.mongo_host is not None:
76
- database.mongo_host = sft_config.inject_mongo_host
77
- database.mongo_port = sft_config.inject_mongo_port
78
- database.mongo_user = sft_config.inject_mongo_user
79
- database.mongo_password = sft_config.inject_mongo_password
80
- database.mongo_db = sft_config.inject_mongo_db
81
- if database.redis_host is not None:
82
- database.redis_host = sft_config.inject_redis_host
83
- database.redis_port = sft_config.inject_redis_port
84
- database.redis_password = sft_config.inject_redis_password
85
-
86
- with open(service_config_path, "w") as f:
87
- f.write(OmegaConf.to_yaml(config.to_dict()))
70
+ if config.databases is not None:
71
+ for database in config.databases:
72
+ if database.postgres_host is not None:
73
+ database.postgres_host = sft_config.inject_postgres_host
74
+ database.postgres_port = sft_config.inject_postgres_port
75
+ database.postgres_user = sft_config.inject_postgres_user
76
+ database.postgres_password = sft_config.inject_postgres_password
77
+ database.postgres_db = self.service_name
78
+ if database.mongo_host is not None:
79
+ database.mongo_host = sft_config.inject_mongo_host
80
+ database.mongo_port = sft_config.inject_mongo_port
81
+ database.mongo_user = sft_config.inject_mongo_user
82
+ database.mongo_password = sft_config.inject_mongo_password
83
+ database.mongo_db = sft_config.inject_mongo_db
84
+ if database.redis_host is not None:
85
+ database.redis_host = sft_config.inject_redis_host
86
+ database.redis_port = sft_config.inject_redis_port
87
+ database.redis_password = sft_config.inject_redis_password
88
+ if config.feedback is not None:
89
+ config.feedback.api_url = sft_config.inject_feedback_api_url
90
+ config.feedback.api_timeout = sft_config.inject_feedback_api_timeout
91
+ else:
92
+ config.feedback = ServiceFeedbackConfig(
93
+ api_url=sft_config.inject_feedback_api_url,
94
+ api_timeout=sft_config.inject_feedback_api_timeout,
95
+ )
96
+
97
+ with open(service_config_path, "w", encoding="utf-8") as f:
98
+ yaml.dump(config.model_dump(), f, allow_unicode=True, indent=2)
88
99
 
89
100
  def inject_ingress(self) -> None:
90
101
  ingress_yaml = DEFAULT_TRAEFIK_INGRESS_YAML.format(
@@ -111,9 +122,20 @@ class Injector:
111
122
  f.write(pyproject_toml)
112
123
  print("pyproject_toml_path: ", self.pyproject_toml_path)
113
124
 
125
+ def clear_start_sh(self) -> None:
126
+ if Path(self.start_sh_path).exists():
127
+ with open(self.start_sh_path, "rb") as f:
128
+ content = f.read()
129
+ content_str = content.decode("utf-8")
130
+ lines = content_str.splitlines()
131
+ new_content = "\n".join(lines) + ("\n" if content_str.endswith(('\n', '\r')) else "")
132
+ with open(self.start_sh_path, "w", encoding="utf-8", newline="\n") as f:
133
+ f.write(new_content)
134
+
114
135
  def inject(self) -> None:
115
136
  self.inject_deployment()
116
137
  self.inject_service_config()
117
138
  self.inject_ingress()
118
139
  self.inject_dockerfile()
119
- self.inject_pyproject_toml()
140
+ self.inject_pyproject_toml()
141
+ self.clear_start_sh()
@@ -128,4 +128,4 @@ DEFAULT_PYPROJECT_TOML = """
128
128
 
129
129
  [tool.uv.sources]
130
130
  service-forge = { workspace = true }
131
- """
131
+ """
@@ -21,6 +21,8 @@ class SftConfig:
21
21
  "inject_postgres_port": "Postgres port for services",
22
22
  "inject_postgres_user": "Postgres user for services",
23
23
  "inject_postgres_password": "Postgres password for services",
24
+ "inject_feedback_api_url": "Feedback API URL for services",
25
+ "inject_feedback_api_timeout": "Feedback API timeout for services",
24
26
  "deepseek_api_key": "DeepSeek API key",
25
27
  "deepseek_base_url": "DeepSeek base URL",
26
28
  }
@@ -52,6 +54,9 @@ class SftConfig:
52
54
  inject_redis_port: int = 6379,
53
55
  inject_redis_password: str = "rDdM2Y2gX9",
54
56
 
57
+ inject_feedback_api_url: str = "http://vps.shiweinan.com:37919/api/v1/feedback",
58
+ inject_feedback_api_timeout: int = 5,
59
+
55
60
  deepseek_api_key: str = "82c9df22-f6ed-411e-90d7-c5255376b7ca",
56
61
  deepseek_base_url: str = "https://ark.cn-beijing.volces.com/api/v3",
57
62
  ):
@@ -80,6 +85,9 @@ class SftConfig:
80
85
  self.inject_redis_port = inject_redis_port
81
86
  self.inject_redis_password = inject_redis_password
82
87
 
88
+ self.inject_feedback_api_url = inject_feedback_api_url
89
+ self.inject_feedback_api_timeout = inject_feedback_api_timeout
90
+
83
91
  self.deepseek_api_key = deepseek_api_key
84
92
  self.deepseek_base_url = deepseek_base_url
85
93
 
@@ -91,10 +99,23 @@ class SftConfig:
91
99
  def upload_timeout(self) -> int:
92
100
  return 300 # 5 minutes default timeout
93
101
 
94
- @classmethod
95
- def get_config_keys(cls) -> list[str]:
96
- sig = inspect.signature(cls.__init__)
97
- return [param for param in sig.parameters.keys() if param != 'self']
102
+ def get_config_keys(self) -> list[str]:
103
+ # Get initial configuration parameters from __init__ method
104
+ sig = inspect.signature(self.__class__.__init__)
105
+ init_keys = [param for param in sig.parameters.keys() if param != 'self']
106
+
107
+ # Get all instance attributes (including dynamically added configurations)
108
+ instance_keys = []
109
+ for attr_name in dir(self):
110
+ # Exclude special methods, private attributes, class attributes, and methods
111
+ if (not attr_name.startswith('_') and
112
+ not callable(getattr(self, attr_name)) and
113
+ attr_name not in ['CONFIG_ROOT', 'CONFIG_DESCRIPTIONS']):
114
+ instance_keys.append(attr_name)
115
+
116
+ # Merge and deduplicate, maintaining order (initial configs first, then dynamically added)
117
+ all_keys = list(dict.fromkeys(init_keys + instance_keys))
118
+ return all_keys
98
119
 
99
120
  @property
100
121
  def config_file_path(self) -> Path:
@@ -105,13 +126,30 @@ class SftConfig:
105
126
 
106
127
  def to_dict(self) -> dict:
107
128
  config_keys = self.get_config_keys()
108
- return {key: getattr(self, key) for key in config_keys}
129
+ result = {}
130
+ for key in config_keys:
131
+ value = getattr(self, key)
132
+ # Convert Path objects to strings for JSON serialization
133
+ if isinstance(value, Path):
134
+ value = str(value)
135
+ result[key] = value
136
+ return result
109
137
 
110
138
  def from_dict(self, data: dict) -> None:
111
- config_keys = self.get_config_keys()
112
- for key in config_keys:
139
+ # Get initial configuration parameters from __init__ method
140
+ sig = inspect.signature(self.__class__.__init__)
141
+ init_keys = [param for param in sig.parameters.keys() if param != 'self']
142
+
143
+ # First, set all initial configuration parameters
144
+ for key in init_keys:
113
145
  if key in data:
114
146
  setattr(self, key, data[key])
147
+
148
+ # Then, handle any additional keys that might be dynamically added configurations
149
+ for key, value in data.items():
150
+ if key not in init_keys and key not in ['CONFIG_ROOT', 'CONFIG_DESCRIPTIONS']:
151
+ # This might be a dynamically added configuration
152
+ setattr(self, key, value)
115
153
 
116
154
  def save(self) -> None:
117
155
  self.ensure_config_dir()
@@ -121,13 +159,22 @@ class SftConfig:
121
159
  def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
122
160
  return getattr(self, key, default)
123
161
 
124
- def set(self, key: str, value: str) -> None:
162
+ def set(self, key: str, value: str, description: Optional[str] = None) -> None:
125
163
  if key in ["config_root"]:
126
164
  raise ValueError(f"{key} is read-only")
127
165
  if hasattr(self, key):
128
166
  setattr(self, key, value)
167
+ if description:
168
+ self.CONFIG_DESCRIPTIONS[key] = description
129
169
  else:
130
170
  raise ValueError(f"Unknown config key: {key}")
171
+
172
+ def add(self, key: str, value: str, description: Optional[str] = None) -> None:
173
+ if hasattr(self, key):
174
+ raise ValueError(f"{key} already exists")
175
+ setattr(self, key, value)
176
+ if description:
177
+ self.CONFIG_DESCRIPTIONS[key] = description
131
178
 
132
179
  def update(self, updates: dict) -> None:
133
180
  for key, value in updates.items():
@@ -0,0 +1,5 @@
1
+ """Storage layer for business logic."""
2
+
3
+ from .feedback_storage import FeedbackStorage, feedback_storage
4
+
5
+ __all__ = ["FeedbackStorage", "feedback_storage"]