@roll-agent/octopus-agent 0.0.1

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.
@@ -0,0 +1,391 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import asdict, is_dataclass
6
+ from typing import Any, Callable
7
+
8
+ from . import __version__
9
+ from .agent import SpongeAgent
10
+ from .config import AppConfig, load_config_from_env
11
+ from .schema_cache import SchemaCacheError
12
+
13
+ SERVER_NAME = "octopus-agent"
14
+ SERVER_VERSION = __version__
15
+ PROTOCOL_VERSION = "2024-11-05"
16
+
17
+ Json = dict[str, Any]
18
+ AgentFactory = Callable[[], SpongeAgent]
19
+ SamplerFactory = Callable[[str, str], str]
20
+
21
+
22
+ def main() -> None:
23
+ serve_stdio()
24
+
25
+
26
+ def serve_stdio() -> None:
27
+ session = StdioSession(sys.stdin.buffer, sys.stdout.buffer)
28
+ while True:
29
+ message = session.read_message()
30
+ if message is None:
31
+ return
32
+ response = handle_request(message, sampler=session.sample)
33
+ if response is not None:
34
+ session.write_message(response)
35
+
36
+
37
+ def handle_request(
38
+ message: Json,
39
+ agent_factory: AgentFactory | None = None,
40
+ sampler: SamplerFactory | None = None,
41
+ ) -> Json | None:
42
+ method = message.get("method")
43
+ if method is None:
44
+ return _error_response(message.get("id"), -32600, "Invalid request")
45
+ if "id" not in message:
46
+ return None
47
+
48
+ request_id = message["id"]
49
+ try:
50
+ if method == "initialize":
51
+ return _result_response(request_id, initialize_result())
52
+ if method == "ping":
53
+ return _result_response(request_id, {})
54
+ if method == "tools/list":
55
+ return _result_response(request_id, {"tools": tool_definitions()})
56
+ if method == "tools/call":
57
+ params = message.get("params") or {}
58
+ return _result_response(request_id, call_tool(params, agent_factory=agent_factory, sampler=sampler))
59
+ return _error_response(request_id, -32601, f"Method not found: {method}")
60
+ except Exception as exc:
61
+ return _error_response(request_id, -32603, str(exc), {"type": type(exc).__name__})
62
+
63
+
64
+ def initialize_result() -> Json:
65
+ return {
66
+ "protocolVersion": PROTOCOL_VERSION,
67
+ "capabilities": {"tools": {}},
68
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
69
+ }
70
+
71
+
72
+ def tool_definitions() -> list[Json]:
73
+ return [
74
+ {
75
+ "name": "diagnostic_status",
76
+ "description": "检查 Octopus Sponge Agent 配置、缓存和本地持久化状态",
77
+ "inputSchema": {
78
+ "type": "object",
79
+ "properties": {},
80
+ "additionalProperties": False,
81
+ },
82
+ },
83
+ {
84
+ "name": "refresh_schema",
85
+ "description": "调用 Sponge MCP Server 刷新本地 Schema Cache(表结构缓存)",
86
+ "inputSchema": {
87
+ "type": "object",
88
+ "required": ["traceId"],
89
+ "properties": {
90
+ "traceId": {
91
+ "type": "string",
92
+ "description": "调用链路 ID,必填,会原样传给 Sponge get_schema",
93
+ },
94
+ "refreshReason": {
95
+ "type": "string",
96
+ "description": "刷新原因,默认 manual(人工触发)",
97
+ }
98
+ },
99
+ "additionalProperties": False,
100
+ },
101
+ },
102
+ {
103
+ "name": "query_sponge",
104
+ "description": "把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行",
105
+ "inputSchema": {
106
+ "type": "object",
107
+ "required": ["question", "userId"],
108
+ "properties": {
109
+ "question": {"type": "string", "description": "用户自然语言问题"},
110
+ "userId": {"type": "integer", "description": "当前登录用户对应的 Duliri userId"},
111
+ "sessionId": {"type": "string", "description": "可选会话 ID"},
112
+ "deviceId": {"type": "string", "description": "可选设备 ID"},
113
+ "resultFormat": {
114
+ "type": "string",
115
+ "enum": ["text", "yaml", "md", "markdown", "html"],
116
+ "description": "结果输出格式;不传则根据问题自动识别",
117
+ },
118
+ "includeSql": {
119
+ "type": "boolean",
120
+ "description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
121
+ },
122
+ },
123
+ "additionalProperties": False,
124
+ },
125
+ },
126
+ ]
127
+
128
+
129
+ def call_tool(
130
+ params: Json,
131
+ agent_factory: AgentFactory | None = None,
132
+ sampler: SamplerFactory | None = None,
133
+ ) -> Json:
134
+ tool_name = params.get("name")
135
+ arguments = params.get("arguments") or {}
136
+ try:
137
+ if tool_name == "diagnostic_status":
138
+ return _tool_json(_diagnostic_status(_load_config()))
139
+ if tool_name == "refresh_schema":
140
+ agent = _make_agent(agent_factory)
141
+ return _tool_json(
142
+ _refresh_schema(
143
+ agent,
144
+ trace_id=_required_string(arguments, "traceId"),
145
+ refresh_reason=str(arguments.get("refreshReason") or "manual"),
146
+ )
147
+ )
148
+ if tool_name == "query_sponge":
149
+ agent = _make_agent(agent_factory)
150
+ answer_kwargs = {
151
+ "question": _required_string(arguments, "question"),
152
+ "user_id": _required_int(arguments, "userId"),
153
+ "session_id": arguments.get("sessionId"),
154
+ "device_id": arguments.get("deviceId"),
155
+ "result_format": arguments.get("resultFormat"),
156
+ }
157
+ if sampler is not None:
158
+ answer_kwargs["sampler"] = sampler
159
+ query_result = agent.query(**answer_kwargs)
160
+ if arguments.get("includeSql") is True:
161
+ return _tool_json(
162
+ {
163
+ "answer": query_result.answer,
164
+ "generatedSql": query_result.generated_sql,
165
+ "normalizedSql": query_result.normalized_sql,
166
+ "repairedSql": query_result.repaired_sql,
167
+ "validation": query_result.validation,
168
+ }
169
+ )
170
+ return _tool_text(query_result.answer)
171
+ return _tool_error(f"Unknown tool: {tool_name}", code="unknown_tool")
172
+ except Exception as exc:
173
+ return _tool_error(str(exc), code=type(exc).__name__)
174
+
175
+
176
+ def _diagnostic_status(config: AppConfig) -> Json:
177
+ cache_exists = config.schema.cache_path.exists()
178
+ schema_version: str | None = None
179
+ cache_error: str | None = None
180
+ try:
181
+ schema_version = SpongeAgent.from_config(config).schema_cache.version()
182
+ except SchemaCacheError as exc:
183
+ cache_error = str(exc)
184
+
185
+ return {
186
+ "server": {"name": SERVER_NAME, "version": SERVER_VERSION},
187
+ "spongeMcp": {
188
+ "baseUrl": config.sponge_mcp.base_url,
189
+ "accessTokenConfigured": bool(config.sponge_mcp.access_token),
190
+ "timeoutMs": config.sponge_mcp.timeout_ms,
191
+ },
192
+ "schema": {
193
+ "cachePath": str(config.schema.cache_path),
194
+ "cacheExists": cache_exists,
195
+ "schemaVersion": schema_version,
196
+ "cacheError": cache_error,
197
+ },
198
+ "audit": {
199
+ "enabled": config.audit.enabled,
200
+ "logPath": str(config.audit.log_path),
201
+ },
202
+ }
203
+
204
+
205
+ def _refresh_schema(agent: SpongeAgent, *, trace_id: str, refresh_reason: str) -> Json:
206
+ cached_schema = agent.schema_cache.load()
207
+ current_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
208
+ schema = agent.mcp_client.get_schema(
209
+ trace_id=trace_id,
210
+ schema_version=current_version,
211
+ refresh_reason=refresh_reason,
212
+ )
213
+ response_changed = schema.get("changed")
214
+ if schema.get("changed") is False and cached_schema is not None:
215
+ schema = cached_schema
216
+ elif schema.get("changed") is False and cached_schema is None:
217
+ schema = agent.mcp_client.get_schema(
218
+ trace_id=trace_id,
219
+ schema_version=None,
220
+ refresh_reason=refresh_reason,
221
+ )
222
+ if "tables" in schema:
223
+ agent.schema_cache.save(schema)
224
+ schema_source = "cache" if response_changed is False and cached_schema is not None else "response"
225
+ agent.audit_logger.log(
226
+ {
227
+ "traceId": trace_id,
228
+ "tool": "get_schema",
229
+ "schemaVersion": schema.get("schemaVersion"),
230
+ "refreshReason": refresh_reason,
231
+ "changed": response_changed,
232
+ }
233
+ )
234
+ return {
235
+ "changed": response_changed,
236
+ "request": {
237
+ "traceId": trace_id,
238
+ "schemaVersion": current_version,
239
+ "refreshReason": refresh_reason,
240
+ },
241
+ "schema": {
242
+ "schemaVersion": schema.get("schemaVersion"),
243
+ "generatedAt": schema.get("generatedAt"),
244
+ "source": schema_source,
245
+ },
246
+ "cachePath": str(agent.schema_cache.path),
247
+ }
248
+
249
+
250
+ def _load_config() -> AppConfig:
251
+ return load_config_from_env()
252
+
253
+
254
+ def _make_agent(agent_factory: AgentFactory | None) -> SpongeAgent:
255
+ if agent_factory is not None:
256
+ return agent_factory()
257
+ return SpongeAgent.from_config(_load_config())
258
+
259
+
260
+ def _required_string(arguments: Json, key: str) -> str:
261
+ value = arguments.get(key)
262
+ if not isinstance(value, str) or not value:
263
+ raise ValueError(f"{key} is required")
264
+ return value
265
+
266
+
267
+ def _required_int(arguments: Json, key: str) -> int:
268
+ value = arguments.get(key)
269
+ if not isinstance(value, int):
270
+ raise ValueError(f"{key} is required")
271
+ return value
272
+
273
+
274
+ def _tool_text(text: str) -> Json:
275
+ return {"content": [{"type": "text", "text": text}]}
276
+
277
+
278
+ def _tool_json(data: Any) -> Json:
279
+ return {
280
+ "content": [
281
+ {
282
+ "type": "text",
283
+ "text": json.dumps(_jsonable(data), ensure_ascii=False, sort_keys=True),
284
+ }
285
+ ]
286
+ }
287
+
288
+
289
+ def _tool_error(message: str, *, code: str) -> Json:
290
+ return {
291
+ "isError": True,
292
+ "content": [
293
+ {
294
+ "type": "text",
295
+ "text": json.dumps({"code": code, "message": message}, ensure_ascii=False, sort_keys=True),
296
+ }
297
+ ],
298
+ }
299
+
300
+
301
+ def _jsonable(value: Any) -> Any:
302
+ if is_dataclass(value):
303
+ return _jsonable(asdict(value))
304
+ if isinstance(value, dict):
305
+ return {str(key): _jsonable(item) for key, item in value.items()}
306
+ if isinstance(value, list):
307
+ return [_jsonable(item) for item in value]
308
+ return value
309
+
310
+
311
+ def _result_response(request_id: Any, result: Json) -> Json:
312
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
313
+
314
+
315
+ def _error_response(request_id: Any, code: int, message: str, data: Json | None = None) -> Json:
316
+ error: Json = {"code": code, "message": message}
317
+ if data is not None:
318
+ error["data"] = data
319
+ return {"jsonrpc": "2.0", "id": request_id, "error": error}
320
+
321
+
322
+ class StdioSession:
323
+ def __init__(self, input_stream: Any, output_stream: Any) -> None:
324
+ self.input_stream = input_stream
325
+ self.output_stream = output_stream
326
+ self.next_request_id = 1
327
+
328
+ def read_message(self) -> Json | None:
329
+ return _read_message(self.input_stream)
330
+
331
+ def write_message(self, message: Json) -> None:
332
+ _write_message(self.output_stream, message)
333
+
334
+ def sample(self, system_prompt: str, prompt: str) -> str:
335
+ request_id = f"sampling_{self.next_request_id}"
336
+ self.next_request_id += 1
337
+ self.write_message(
338
+ {
339
+ "jsonrpc": "2.0",
340
+ "id": request_id,
341
+ "method": "sampling/createMessage",
342
+ "params": {
343
+ "systemPrompt": system_prompt,
344
+ "messages": [{"role": "user", "content": {"type": "text", "text": prompt}}],
345
+ "temperature": 0,
346
+ "maxTokens": 1600,
347
+ "includeContext": "none",
348
+ },
349
+ }
350
+ )
351
+ while True:
352
+ message = self.read_message()
353
+ if message is None:
354
+ raise RuntimeError("Sampling failed: client closed stdio")
355
+ if message.get("id") == request_id:
356
+ if "error" in message:
357
+ raise RuntimeError(f"Sampling failed: {message['error']}")
358
+ return _extract_sampling_text(message.get("result") or {})
359
+ response = handle_request(message, sampler=self.sample)
360
+ if response is not None:
361
+ self.write_message(response)
362
+
363
+
364
+ def _extract_sampling_text(result: Json) -> str:
365
+ content = result.get("content")
366
+ if isinstance(content, dict) and content.get("type") == "text":
367
+ return str(content.get("text") or "")
368
+ if isinstance(content, list):
369
+ return "\n".join(
370
+ str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"
371
+ )
372
+ raise RuntimeError("Sampling response did not contain text content")
373
+
374
+
375
+ def _read_message(stream: Any) -> Json | None:
376
+ while True:
377
+ line = stream.readline()
378
+ if line == b"":
379
+ return None
380
+ if line.strip():
381
+ return json.loads(line.decode("utf-8"))
382
+
383
+
384
+ def _write_message(stream: Any, message: Json) -> None:
385
+ body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
386
+ stream.write(body + b"\n")
387
+ stream.flush()
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class SchemaCacheError(RuntimeError):
10
+ """Raised when the persisted schema cache cannot be used."""
11
+
12
+
13
+ @dataclass
14
+ class SchemaCache:
15
+ path: Path
16
+
17
+ def load(self) -> dict[str, Any] | None:
18
+ if not self.path.exists():
19
+ return None
20
+ try:
21
+ data = json.loads(self.path.read_text(encoding="utf-8"))
22
+ except json.JSONDecodeError as exc:
23
+ raise SchemaCacheError(f"Invalid schema cache JSON: {self.path}") from exc
24
+ if not isinstance(data, dict):
25
+ raise SchemaCacheError(f"Schema cache must be a JSON object: {self.path}")
26
+ return data
27
+
28
+ def save(self, schema: dict[str, Any]) -> None:
29
+ if not isinstance(schema, dict):
30
+ raise TypeError("schema must be a dict")
31
+ self.path.parent.mkdir(parents=True, exist_ok=True)
32
+ self.path.write_text(
33
+ json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
34
+ encoding="utf-8",
35
+ )
36
+
37
+ def version(self) -> str | None:
38
+ schema = self.load()
39
+ if schema is None:
40
+ return None
41
+ version = schema.get("schemaVersion")
42
+ return str(version) if version is not None else None
43
+
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class SchemaContext:
9
+ tables: list[dict[str, Any]]
10
+ joins: list[dict[str, Any]]
11
+ glossary: list[dict[str, Any]]
12
+ metrics: list[dict[str, Any]]
13
+ examples: list[dict[str, Any]]
14
+
15
+
16
+ def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int = 8) -> SchemaContext:
17
+ glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
18
+ metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
19
+ examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
20
+ table_names_from_context = _target_table_names(glossary + metrics + examples)
21
+ tables = _rank_tables(schema.get("tables", []), question, table_names_from_context)[:max_tables]
22
+ selected_table_names = {_table_name(table) for table in tables}
23
+ joins = _filter_joins(schema.get("joins", []), selected_table_names)
24
+ return SchemaContext(tables=tables, joins=joins, glossary=glossary, metrics=metrics, examples=examples)
25
+
26
+
27
+ def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[dict[str, Any]]:
28
+ if not isinstance(tables, list):
29
+ return []
30
+ ranked: list[tuple[int, dict[str, Any]]] = []
31
+ for table in tables:
32
+ if not isinstance(table, dict):
33
+ continue
34
+ name = _table_name(table)
35
+ score = 5 if name in preferred_names else 0
36
+ text = " ".join(
37
+ [
38
+ name,
39
+ str(table.get("comment") or ""),
40
+ " ".join(_column_text(column) for column in table.get("columns", []) if isinstance(column, dict)),
41
+ ]
42
+ ).lower()
43
+ for token in _question_tokens(question):
44
+ if token and token in text:
45
+ score += 1
46
+ if score > 0 or not preferred_names:
47
+ ranked.append((score, table))
48
+ ranked.sort(key=lambda item: item[0], reverse=True)
49
+ return [table for _, table in ranked]
50
+
51
+
52
+ def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list[dict[str, Any]]:
53
+ if not isinstance(items, list):
54
+ return []
55
+ tokens = _question_tokens(question)
56
+ matches: list[dict[str, Any]] = []
57
+ for item in items:
58
+ if not isinstance(item, dict):
59
+ continue
60
+ text = " ".join(str(item.get(key) or "") for key in keys).lower()
61
+ if any(token and token in text for token in tokens):
62
+ matches.append(item)
63
+ return matches[:8]
64
+
65
+
66
+ def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
67
+ names: set[str] = set()
68
+ for item in items:
69
+ for key in ("tables", "targetTables"):
70
+ value = item.get(key)
71
+ if isinstance(value, list):
72
+ names.update(str(name) for name in value)
73
+ targets = item.get("targets")
74
+ if isinstance(targets, list):
75
+ for target in targets:
76
+ if isinstance(target, str) and "." in target:
77
+ names.add(target.split(".", 1)[0])
78
+ return names
79
+
80
+
81
+ def _filter_joins(joins: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
82
+ if not isinstance(joins, list):
83
+ return []
84
+ result: list[dict[str, Any]] = []
85
+ for join in joins:
86
+ if not isinstance(join, dict):
87
+ continue
88
+ join_tables = {
89
+ str(join.get("leftTable") or join.get("from") or ""),
90
+ str(join.get("rightTable") or join.get("to") or ""),
91
+ }
92
+ if join_tables & selected_table_names:
93
+ result.append(join)
94
+ return result[:12]
95
+
96
+
97
+ def _table_name(table: dict[str, Any]) -> str:
98
+ return str(table.get("tableName") or table.get("name") or "")
99
+
100
+
101
+ def _column_text(column: dict[str, Any]) -> str:
102
+ return " ".join(
103
+ str(column.get(key) or "") for key in ("columnName", "name", "dataType", "type", "comment")
104
+ )
105
+
106
+
107
+ def _question_tokens(question: str) -> list[str]:
108
+ lowered = question.lower()
109
+ tokens = [lowered]
110
+ tokens.extend(part for part in lowered.replace("_", " ").replace("-", " ").split() if part)
111
+ for word in ("品牌", "项目", "岗位", "门店", "招聘", "在招", "数量", "列表"):
112
+ if word in question:
113
+ tokens.append(word)
114
+ return list(dict.fromkeys(tokens))
115
+
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ class SqlGenerationError(RuntimeError):
9
+ pass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class GeneratedSql:
14
+ sql: str
15
+ tables: list[str]
16
+ placeholders: list[str]
17
+
18
+
19
+ @dataclass
20
+ class RuleBasedSqlGenerator:
21
+ default_limit: int
22
+ max_limit: int
23
+
24
+ def generate(
25
+ self,
26
+ *,
27
+ question: str,
28
+ schema: dict[str, Any],
29
+ scope_hints: dict[str, Any],
30
+ ) -> GeneratedSql:
31
+ table_names = _table_names(schema)
32
+ lowered = question.lower()
33
+
34
+ if ("品牌" in question or "brand" in lowered) and {"brand", "brand_project"}.issubset(table_names):
35
+ sql = (
36
+ "SELECT b.id AS brand_id, b.name AS brand_name "
37
+ "FROM brand b "
38
+ "JOIN brand_project bp ON bp.brand_id = b.id "
39
+ "WHERE bp.project_id IN (:scope.project_ids) "
40
+ "AND b.id IN (:scope.brand_ids) "
41
+ f"LIMIT {self.default_limit}"
42
+ )
43
+ return GeneratedSql(sql, ["brand", "brand_project"], _placeholders(scope_hints))
44
+
45
+ if ("项目" in question or "project" in lowered) and "sponge_project" in table_names:
46
+ sql = (
47
+ "SELECT p.id AS project_id, p.name AS project_name "
48
+ "FROM sponge_project p "
49
+ "WHERE p.id IN (:scope.project_ids) "
50
+ f"LIMIT {self.default_limit}"
51
+ )
52
+ return GeneratedSql(sql, ["sponge_project"], [":scope.project_ids"])
53
+
54
+ raise SqlGenerationError("No safe SQL template matched the question")
55
+
56
+
57
+ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
58
+ normalized = sql.strip().lower()
59
+ if not normalized.startswith("select "):
60
+ raise SqlGenerationError("SQL must be SELECT only")
61
+ if ";" in normalized.rstrip(";"):
62
+ raise SqlGenerationError("SQL must be a single statement")
63
+ if re.search(r"\b(insert|update|delete|drop|alter|create|truncate)\b", normalized):
64
+ raise SqlGenerationError("SQL contains a forbidden keyword")
65
+ if re.search(r"\b(password|token|secret|credential|private_key)\b", normalized):
66
+ raise SqlGenerationError("SQL contains a sensitive field")
67
+ limit = re.search(r"\blimit\s+(\d+)\b", normalized)
68
+ if limit is None:
69
+ raise SqlGenerationError("SQL must contain LIMIT")
70
+ if int(limit.group(1)) > max_limit:
71
+ raise SqlGenerationError(f"SQL LIMIT must not exceed {max_limit}")
72
+
73
+
74
+ def _table_names(schema: dict[str, Any]) -> set[str]:
75
+ tables = schema.get("tables", [])
76
+ if not isinstance(tables, list):
77
+ return set()
78
+ names: set[str] = set()
79
+ for table in tables:
80
+ if not isinstance(table, dict):
81
+ continue
82
+ name = table.get("name") or table.get("tableName")
83
+ if name:
84
+ names.add(str(name))
85
+ return names
86
+
87
+
88
+ def _placeholders(scope_hints: dict[str, Any]) -> list[str]:
89
+ placeholders: list[str] = []
90
+ for value in scope_hints.values():
91
+ if isinstance(value, dict) and value.get("placeholder"):
92
+ placeholders.append(str(value["placeholder"]))
93
+ return placeholders