querychat 0.2.1__tar.gz → 0.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 (29) hide show
  1. {querychat-0.2.1 → querychat-0.3.0}/.gitignore +5 -0
  2. {querychat-0.2.1 → querychat-0.3.0}/PKG-INFO +6 -6
  3. querychat-0.3.0/pkg-py/src/querychat/__init__.py +15 -0
  4. querychat-0.2.1/pkg-py/src/querychat/datasource.py → querychat-0.3.0/pkg-py/src/querychat/_datasource.py +170 -53
  5. querychat-0.3.0/pkg-py/src/querychat/_deprecated.py +108 -0
  6. querychat-0.3.0/pkg-py/src/querychat/_icons.py +20 -0
  7. querychat-0.3.0/pkg-py/src/querychat/_querychat.py +733 -0
  8. querychat-0.3.0/pkg-py/src/querychat/_querychat_module.py +197 -0
  9. querychat-0.3.0/pkg-py/src/querychat/_utils.py +157 -0
  10. querychat-0.3.0/pkg-py/src/querychat/data/__init__.py +68 -0
  11. querychat-0.3.0/pkg-py/src/querychat/data/tips.csv.gz +0 -0
  12. querychat-0.3.0/pkg-py/src/querychat/data/titanic.csv.gz +0 -0
  13. querychat-0.3.0/pkg-py/src/querychat/express/__init__.py +3 -0
  14. querychat-0.3.0/pkg-py/src/querychat/prompts/prompt.md +148 -0
  15. querychat-0.3.0/pkg-py/src/querychat/prompts/tool-query.md +34 -0
  16. querychat-0.3.0/pkg-py/src/querychat/prompts/tool-reset-dashboard.md +12 -0
  17. querychat-0.3.0/pkg-py/src/querychat/prompts/tool-update-dashboard.md +28 -0
  18. querychat-0.3.0/pkg-py/src/querychat/static/js/querychat.js +20 -0
  19. querychat-0.3.0/pkg-py/src/querychat/tools.py +248 -0
  20. querychat-0.3.0/pkg-py/src/querychat/types/__init__.py +9 -0
  21. {querychat-0.2.1 → querychat-0.3.0}/pyproject.toml +15 -10
  22. querychat-0.2.1/pkg-py/src/querychat/__init__.py +0 -13
  23. querychat-0.2.1/pkg-py/src/querychat/_utils.py +0 -48
  24. querychat-0.2.1/pkg-py/src/querychat/prompt/prompt.md +0 -103
  25. querychat-0.2.1/pkg-py/src/querychat/querychat.py +0 -663
  26. {querychat-0.2.1 → querychat-0.3.0}/LICENSE.md +0 -0
  27. {querychat-0.2.1 → querychat-0.3.0}/pkg-py/LICENSE +0 -0
  28. {querychat-0.2.1 → querychat-0.3.0}/pkg-py/README.md +0 -0
  29. {querychat-0.2.1 → querychat-0.3.0}/pkg-py/src/querychat/static/css/styles.css +0 -0
@@ -260,3 +260,8 @@ _dev
260
260
  .Rprofile
261
261
  renv/
262
262
  renv.lock
263
+
264
+ # Claude
265
+ .claude/settings.local.json
266
+
267
+ /.luarc.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: querychat
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Chat with your data using natural language
5
5
  Project-URL: Homepage, https://github.com/posit-dev/querychat
6
6
  Project-URL: Repository, https://github.com/posit-dev/querychat
@@ -31,20 +31,20 @@ License: # MIT License
31
31
  SOFTWARE.
32
32
  License-File: LICENSE.md
33
33
  Classifier: Programming Language :: Python
34
- Classifier: Programming Language :: Python :: 3.8
35
- Classifier: Programming Language :: Python :: 3.9
36
34
  Classifier: Programming Language :: Python :: 3.10
37
35
  Classifier: Programming Language :: Python :: 3.11
38
36
  Classifier: Programming Language :: Python :: 3.12
39
37
  Classifier: Programming Language :: Python :: 3.13
40
- Requires-Python: >=3.9
41
- Requires-Dist: chatlas
38
+ Classifier: Programming Language :: Python :: 3.14
39
+ Requires-Python: >=3.10
40
+ Requires-Dist: chatlas>=0.13.2
42
41
  Requires-Dist: chevron
43
42
  Requires-Dist: duckdb
44
43
  Requires-Dist: htmltools
45
44
  Requires-Dist: narwhals
46
45
  Requires-Dist: pandas
47
- Requires-Dist: shiny
46
+ Requires-Dist: shiny>=1.5.1
47
+ Requires-Dist: shinychat>=0.2.8
48
48
  Requires-Dist: shinywidgets
49
49
  Requires-Dist: sqlalchemy>=2.0.0
50
50
  Description-Content-Type: text/markdown
@@ -0,0 +1,15 @@
1
+ from ._deprecated import greeting, init, sidebar, system_prompt
2
+ from ._deprecated import mod_server as server
3
+ from ._deprecated import mod_ui as ui
4
+ from ._querychat import QueryChat
5
+
6
+ __all__ = (
7
+ "QueryChat",
8
+ # TODO(lifecycle): Remove these deprecated functions when we reach v1.0
9
+ "greeting",
10
+ "init",
11
+ "server",
12
+ "sidebar",
13
+ "system_prompt",
14
+ "ui",
15
+ )
@@ -1,94 +1,162 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, ClassVar, Protocol
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING
4
5
 
5
6
  import duckdb
6
- import narwhals as nw
7
+ import narwhals.stable.v1 as nw
7
8
  import pandas as pd
8
9
  from sqlalchemy import inspect, text
9
10
  from sqlalchemy.sql import sqltypes
10
11
 
11
12
  if TYPE_CHECKING:
13
+ from narwhals.stable.v1.typing import IntoFrame
12
14
  from sqlalchemy.engine import Connection, Engine
13
15
 
14
16
 
15
- class DataSource(Protocol):
16
- db_engine: ClassVar[str]
17
+ class DataSource(ABC):
18
+ """
19
+ An abstract class defining the interface for data sources used by QueryChat.
20
+
21
+ Attributes
22
+ ----------
23
+ table_name
24
+ Name of the table to be used in SQL queries.
25
+
26
+ """
27
+
28
+ table_name: str
17
29
 
18
- def get_schema(self, *, categorical_threshold) -> str:
30
+ @abstractmethod
31
+ def get_db_type(self) -> str:
32
+ """Name for the database behind the SQL execution."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ def get_schema(self, *, categorical_threshold: int) -> str:
19
37
  """
20
38
  Return schema information about the table as a string.
21
39
 
22
- Args:
23
- categorical_threshold: Maximum number of unique values for a text
24
- column to be considered categorical
40
+ Parameters
41
+ ----------
42
+ categorical_threshold
43
+ Maximum number of unique values for a text column to be considered
44
+ categorical
25
45
 
26
- Returns:
46
+ Returns
47
+ -------
48
+ :
27
49
  A string containing the schema information in a format suitable for
28
50
  prompting an LLM about the data structure
29
51
 
30
52
  """
31
53
  ...
32
54
 
55
+ @abstractmethod
33
56
  def execute_query(self, query: str) -> pd.DataFrame:
34
57
  """
35
58
  Execute SQL query and return results as DataFrame.
36
59
 
37
- Args:
38
- query: SQL query to execute
60
+ Parameters
61
+ ----------
62
+ query
63
+ SQL query to execute
39
64
 
40
- Returns:
65
+ Returns
66
+ -------
67
+ :
41
68
  Query results as a pandas DataFrame
42
69
 
43
70
  """
44
71
  ...
45
72
 
73
+ @abstractmethod
46
74
  def get_data(self) -> pd.DataFrame:
47
75
  """
48
76
  Return the unfiltered data as a DataFrame.
49
77
 
50
- Returns:
78
+ Returns
79
+ -------
80
+ :
51
81
  The complete dataset as a pandas DataFrame
52
82
 
53
83
  """
54
84
  ...
55
85
 
86
+ @abstractmethod
87
+ def cleanup(self) -> None:
88
+ """
89
+ Clean up resources associated with the data source.
90
+
91
+ This method should clean up any connections or resources used by the
92
+ data source.
93
+
94
+ Returns
95
+ -------
96
+ None
97
+
98
+ """
56
99
 
57
- class DataFrameSource:
100
+
101
+ class DataFrameSource(DataSource):
58
102
  """A DataSource implementation that wraps a pandas DataFrame using DuckDB."""
59
103
 
60
- db_engine: ClassVar[str] = "DuckDB"
104
+ _df: nw.DataFrame | nw.LazyFrame
61
105
 
62
- def __init__(self, df: pd.DataFrame, table_name: str):
106
+ def __init__(self, df: IntoFrame, table_name: str):
63
107
  """
64
108
  Initialize with a pandas DataFrame.
65
109
 
66
- Args:
67
- df: The DataFrame to wrap
68
- table_name: Name of the table in SQL queries
110
+ Parameters
111
+ ----------
112
+ df
113
+ The DataFrame to wrap
114
+ table_name
115
+ Name of the table in SQL queries
69
116
 
70
117
  """
71
118
  self._conn = duckdb.connect(database=":memory:")
72
- self._df = df
73
- self._table_name = table_name
74
- self._conn.register(table_name, df)
119
+ self._df = nw.from_native(df)
120
+ self.table_name = table_name
121
+ # TODO(@gadenbuie): If the data frame is already SQL-backed, maybe we shouldn't be making a new copy here.
122
+ self._conn.register(table_name, self._df.lazy().collect().to_pandas())
123
+
124
+ def get_db_type(self) -> str:
125
+ """
126
+ Get the database type.
127
+
128
+ Returns
129
+ -------
130
+ :
131
+ The string "DuckDB"
132
+
133
+ """
134
+ return "DuckDB"
75
135
 
76
136
  def get_schema(self, *, categorical_threshold: int) -> str:
77
137
  """
78
138
  Generate schema information from DataFrame.
79
139
 
80
- Args:
81
- table_name: Name to use for the table in schema description
82
- categorical_threshold: Maximum number of unique values for a text column
83
- to be considered categorical
140
+ Parameters
141
+ ----------
142
+ categorical_threshold
143
+ Maximum number of unique values for a text column to be considered
144
+ categorical
84
145
 
85
- Returns:
146
+ Returns
147
+ -------
148
+ :
86
149
  String describing the schema
87
150
 
88
151
  """
89
- ndf = nw.from_native(self._df)
152
+ schema = [f"Table: {self.table_name}", "Columns:"]
90
153
 
91
- schema = [f"Table: {self._table_name}", "Columns:"]
154
+ # Ensure we're working with a DataFrame, not a LazyFrame
155
+ ndf = (
156
+ self._df.head(10).collect()
157
+ if isinstance(self._df, nw.LazyFrame)
158
+ else self._df
159
+ )
92
160
 
93
161
  for column in ndf.columns:
94
162
  # Map pandas dtypes to SQL-like types
@@ -132,10 +200,14 @@ class DataFrameSource:
132
200
  """
133
201
  Execute query using DuckDB.
134
202
 
135
- Args:
136
- query: SQL query to execute
203
+ Parameters
204
+ ----------
205
+ query
206
+ SQL query to execute
137
207
 
138
- Returns:
208
+ Returns
209
+ -------
210
+ :
139
211
  Query results as pandas DataFrame
140
212
 
141
213
  """
@@ -145,39 +217,66 @@ class DataFrameSource:
145
217
  """
146
218
  Return the unfiltered data as a DataFrame.
147
219
 
148
- Returns:
220
+ Returns
221
+ -------
222
+ :
149
223
  The complete dataset as a pandas DataFrame
150
224
 
151
225
  """
152
- return self._df.copy()
226
+ # TODO(@gadenbuie): This should just return `self._df` and not a pandas DataFrame
227
+ return self._df.lazy().collect().to_pandas()
153
228
 
229
+ def cleanup(self) -> None:
230
+ """
231
+ Close the DuckDB connection.
154
232
 
155
- class SQLAlchemySource:
156
- """
157
- A DataSource implementation that supports multiple SQL databases via SQLAlchemy.
233
+ Returns
234
+ -------
235
+ None
158
236
 
159
- Supports various databases including PostgreSQL, MySQL, SQLite, Snowflake, and Databricks.
237
+ """
238
+ if self._conn:
239
+ self._conn.close()
240
+
241
+
242
+ class SQLAlchemySource(DataSource):
160
243
  """
244
+ A DataSource implementation that supports multiple SQL databases via
245
+ SQLAlchemy.
161
246
 
162
- db_engine: ClassVar[str] = "SQLAlchemy"
247
+ Supports various databases including PostgreSQL, MySQL, SQLite, Snowflake,
248
+ and Databricks.
249
+ """
163
250
 
164
251
  def __init__(self, engine: Engine, table_name: str):
165
252
  """
166
253
  Initialize with a SQLAlchemy engine.
167
254
 
168
- Args:
169
- engine: SQLAlchemy engine
170
- table_name: Name of the table to query
255
+ Parameters
256
+ ----------
257
+ engine
258
+ SQLAlchemy engine
259
+ table_name
260
+ Name of the table to query
171
261
 
172
262
  """
173
263
  self._engine = engine
174
- self._table_name = table_name
264
+ self.table_name = table_name
175
265
 
176
266
  # Validate table exists
177
267
  inspector = inspect(self._engine)
178
268
  if not inspector.has_table(table_name):
179
269
  raise ValueError(f"Table '{table_name}' not found in database")
180
270
 
271
+ def get_db_type(self) -> str:
272
+ """
273
+ Get the database type.
274
+
275
+ Returns the specific database type (e.g., POSTGRESQL, MYSQL, SQLITE) by
276
+ inspecting the SQLAlchemy engine. Removes " SQL" suffix if present.
277
+ """
278
+ return self._engine.dialect.name.upper().replace(" SQL", "")
279
+
181
280
  def get_schema(self, *, categorical_threshold: int) -> str: # noqa: PLR0912
182
281
  """
183
282
  Generate schema information from database table.
@@ -187,9 +286,9 @@ class SQLAlchemySource:
187
286
 
188
287
  """
189
288
  inspector = inspect(self._engine)
190
- columns = inspector.get_columns(self._table_name)
289
+ columns = inspector.get_columns(self.table_name)
191
290
 
192
- schema = [f"Table: {self._table_name}", "Columns:"]
291
+ schema = [f"Table: {self.table_name}", "Columns:"]
193
292
 
194
293
  # Build a single query to get all column statistics
195
294
  select_parts = []
@@ -236,13 +335,13 @@ class SQLAlchemySource:
236
335
  if select_parts:
237
336
  try:
238
337
  stats_query = text(
239
- f"SELECT {', '.join(select_parts)} FROM {self._table_name}",
338
+ f"SELECT {', '.join(select_parts)} FROM {self.table_name}",
240
339
  )
241
340
  with self._get_connection() as conn:
242
341
  result = conn.execute(stats_query).fetchone()
243
342
  if result:
244
343
  # Convert result to dict for easier access
245
- column_stats = dict(zip(result._fields, result))
344
+ column_stats = dict(zip(result._fields, result, strict=False))
246
345
  except Exception: # noqa: S110
247
346
  pass # Fall back to no statistics if query fails
248
347
 
@@ -264,7 +363,7 @@ class SQLAlchemySource:
264
363
  # Build UNION query for all categorical columns
265
364
  union_parts = [
266
365
  f"SELECT '{col_name}' as column_name, {col_name} as value "
267
- f"FROM {self._table_name} WHERE {col_name} IS NOT NULL "
366
+ f"FROM {self.table_name} WHERE {col_name} IS NOT NULL "
268
367
  f"GROUP BY {col_name}"
269
368
  for col_name in text_cols_to_query
270
369
  ]
@@ -317,10 +416,14 @@ class SQLAlchemySource:
317
416
  """
318
417
  Execute SQL query and return results as DataFrame.
319
418
 
320
- Args:
321
- query: SQL query to execute
419
+ Parameters
420
+ ----------
421
+ query
422
+ SQL query to execute
322
423
 
323
- Returns:
424
+ Returns
425
+ -------
426
+ :
324
427
  Query results as pandas DataFrame
325
428
 
326
429
  """
@@ -331,11 +434,13 @@ class SQLAlchemySource:
331
434
  """
332
435
  Return the unfiltered data as a DataFrame.
333
436
 
334
- Returns:
437
+ Returns
438
+ -------
439
+ :
335
440
  The complete dataset as a pandas DataFrame
336
441
 
337
442
  """
338
- return self.execute_query(f"SELECT * FROM {self._table_name}")
443
+ return self.execute_query(f"SELECT * FROM {self.table_name}")
339
444
 
340
445
  def _get_sql_type_name(self, type_: sqltypes.TypeEngine) -> str: # noqa: PLR0911
341
446
  """Convert SQLAlchemy type to SQL type name."""
@@ -361,3 +466,15 @@ class SQLAlchemySource:
361
466
  def _get_connection(self) -> Connection:
362
467
  """Get a connection to use for queries."""
363
468
  return self._engine.connect()
469
+
470
+ def cleanup(self) -> None:
471
+ """
472
+ Dispose of the SQLAlchemy engine.
473
+
474
+ Returns
475
+ -------
476
+ None
477
+
478
+ """
479
+ if self._engine:
480
+ self._engine.dispose()
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional, Union
4
+
5
+ from shiny import Inputs, Outputs, Session, module, ui
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+ import chatlas
11
+ import sqlalchemy
12
+ from narwhals.stable.v1.typing import IntoFrame
13
+
14
+ from ._datasource import DataSource
15
+
16
+
17
+ def init(
18
+ data_source: IntoFrame | sqlalchemy.Engine,
19
+ table_name: str,
20
+ *,
21
+ greeting: Optional[str | Path] = None,
22
+ data_description: Optional[str | Path] = None,
23
+ extra_instructions: Optional[str | Path] = None,
24
+ prompt_template: Optional[str | Path] = None,
25
+ system_prompt_override: Optional[str] = None,
26
+ client: Optional[Union[chatlas.Chat, str]] = None,
27
+ ):
28
+ """
29
+ Initialize querychat with any compliant data source.
30
+
31
+ **Deprecated.** Use `QueryChat()` instead.
32
+ """
33
+ raise RuntimeError("init() is deprecated. Use QueryChat() instead.")
34
+
35
+
36
+ @module.ui
37
+ def mod_ui(**kwargs) -> ui.TagList:
38
+ """
39
+ Create the UI for the querychat component.
40
+
41
+ **Deprecated.** Use `QueryChat.ui()` instead.
42
+ """
43
+ raise RuntimeError("mod_ui() is deprecated. Use QueryChat.ui() instead.")
44
+
45
+
46
+ @module.server
47
+ def mod_server(
48
+ input: Inputs,
49
+ output: Outputs,
50
+ session: Session,
51
+ querychat_config: Any,
52
+ ):
53
+ """
54
+ Initialize the querychat server.
55
+
56
+ **Deprecated.** Use `QueryChat.server()` instead.
57
+ """
58
+ raise RuntimeError("mod_server() is deprecated. Use QueryChat.server() instead.")
59
+
60
+
61
+ def sidebar(
62
+ id: str,
63
+ width: int = 400,
64
+ height: str = "100%",
65
+ **kwargs,
66
+ ) -> ui.Sidebar:
67
+ """
68
+ Create a sidebar containing the querychat UI.
69
+
70
+ **Deprecated.** Use `QueryChat.sidebar()` instead.
71
+ """
72
+ raise RuntimeError("sidebar() is deprecated. Use QueryChat.sidebar() instead.")
73
+
74
+
75
+ def system_prompt(
76
+ data_source: DataSource,
77
+ *,
78
+ data_description: Optional[str | Path] = None,
79
+ extra_instructions: Optional[str | Path] = None,
80
+ categorical_threshold: int = 20,
81
+ prompt_template: Optional[str | Path] = None,
82
+ ) -> str:
83
+ """
84
+ Create a system prompt for the chat model based on a data source's schema
85
+ and optional additional context and instructions.
86
+
87
+ **Deprecated.** Use `QueryChat.set_system_prompt()` instead.
88
+ """
89
+ raise RuntimeError(
90
+ "system_prompt() is deprecated. Use QueryChat.set_system_prompt() instead."
91
+ )
92
+
93
+
94
+ def greeting(
95
+ querychat_config,
96
+ *,
97
+ generate: bool = True,
98
+ stream: bool = False,
99
+ **kwargs,
100
+ ) -> str | None:
101
+ """
102
+ Generate or retrieve a greeting message.
103
+
104
+ **Deprecated.** Use `QueryChat.generate_greeting()` instead.
105
+ """
106
+ raise RuntimeError(
107
+ "greeting() is deprecated. Use QueryChat.generate_greeting() instead."
108
+ )
@@ -0,0 +1,20 @@
1
+ from typing import Literal
2
+
3
+ from shiny import ui
4
+
5
+ ICON_NAMES = Literal["arrow-counterclockwise", "funnel-fill", "terminal-fill", "table"]
6
+
7
+
8
+ def bs_icon(name: ICON_NAMES) -> ui.HTML:
9
+ """Get Bootstrap icon SVG by name."""
10
+ if name not in BS_ICONS:
11
+ raise ValueError(f"Unknown Bootstrap icon: {name}")
12
+ return ui.HTML(BS_ICONS[name])
13
+
14
+
15
+ BS_ICONS = {
16
+ "arrow-counterclockwise": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="bi bi-arrow-counterclockwise" style="height:1em;width:1em;fill:currentColor;vertical-align:-0.125em;" aria-hidden="true" role="img"><path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"></path><path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"></path></svg>',
17
+ "funnel-fill": '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel-fill" viewBox="0 0 16 16"><path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5z"/></svg>',
18
+ "terminal-fill": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="bi bi-terminal-fill " style="height:1em;width:1em;fill:currentColor;vertical-align:-0.125em;" aria-hidden="true" role="img" ><path d="M0 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3zm9.5 5.5h-3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm-6.354-.354a.5.5 0 1 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2a.5.5 0 1 0-.708.708L4.793 6.5 3.146 8.146z"></path></svg>',
19
+ "table": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="bi bi-table " style="height:1em;width:1em;fill:currentColor;vertical-align:-0.125em;" aria-hidden="true" role="img" ><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"></path></svg>',
20
+ }