downshift 0.1.0__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.
downshift/evalgen.py ADDED
@@ -0,0 +1,220 @@
1
+ """Generate eval sets with any configured model (the no-Bob path).
2
+
3
+ `generate_eval_set` asks a model for test cases for one call site, then keeps
4
+ only the cases that pass the same checks as `downshift check-evals`. Cases are
5
+ renumbered, so the model never has to get ids right. The CLI command
6
+ `evalgen` is a thin wrapper that writes one `<slug>.jsonl` per call site.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from downshift.evals import (
19
+ EvalCase,
20
+ EvalSet,
21
+ allowed_values,
22
+ expression_placeholders,
23
+ graded_fields,
24
+ site_placeholders,
25
+ validate_eval_set,
26
+ )
27
+ from downshift.llm import LLMClient
28
+ from downshift.schema import CallSite
29
+
30
+ _FENCE = re.compile(r"^```(?:json)?\s*|\s*```$")
31
+
32
+ SYSTEM_PROMPT = (
33
+ "You write test cases for one LLM feature in a software product. "
34
+ "Every expected answer must be certainly correct. Reply with JSON only."
35
+ )
36
+
37
+
38
+ class EvalGenSkip(Exception):
39
+ """Raised when a call site cannot get generated evals; the message says why."""
40
+
41
+
42
+ @dataclass
43
+ class GenerationResult:
44
+ site_id: str
45
+ grading: str
46
+ cases: list[EvalCase] = field(default_factory=list)
47
+ dropped: list[str] = field(default_factory=list)
48
+ attempts: int = 0
49
+
50
+
51
+ def grading_for(site: CallSite) -> str:
52
+ """The audit's grading, or a safe default for an unaudited call site."""
53
+ if site.grading:
54
+ return site.grading
55
+ return "json_fields" if site.output_format == "json" else "judge"
56
+
57
+
58
+ def generate_eval_set(
59
+ client: LLMClient,
60
+ model: str,
61
+ site: CallSite,
62
+ *,
63
+ count: int = 20,
64
+ shared: dict[str, str] | None = None,
65
+ max_attempts: int = 3,
66
+ temperature: float = 0.7,
67
+ ) -> GenerationResult:
68
+ """Ask `model` for cases until `count` valid ones are kept or attempts run out."""
69
+ _check_site(site)
70
+ shared = {k: v for k, v in (shared or {}).items() if k in site_placeholders(site)}
71
+ grading = grading_for(site)
72
+ result = GenerationResult(site_id=site.id, grading=grading)
73
+ seen_inputs: set[str] = set()
74
+
75
+ while len(result.cases) < count and result.attempts < max_attempts:
76
+ result.attempts += 1
77
+ wanted = count - len(result.cases)
78
+ messages = build_messages(site, grading, wanted, shared)
79
+ completion = client.complete(model, messages, temperature=temperature, json_mode=True)
80
+ try:
81
+ raw_cases = parse_cases(completion.text)
82
+ except ValueError as exc:
83
+ result.dropped.append(f"attempt {result.attempts}: {exc}")
84
+ continue
85
+ for raw in raw_cases:
86
+ if len(result.cases) >= count:
87
+ break
88
+ case, problem = _to_case(raw, site, grading, shared, len(result.cases) + 1)
89
+ if case is None:
90
+ result.dropped.append(problem)
91
+ continue
92
+ key = json.dumps(case.inputs, sort_keys=True, ensure_ascii=False)
93
+ if key in seen_inputs:
94
+ result.dropped.append(f"duplicate inputs: {case.notes}")
95
+ continue
96
+ seen_inputs.add(key)
97
+ result.cases.append(case)
98
+ return result
99
+
100
+
101
+ def build_messages(
102
+ site: CallSite, grading: str, count: int, shared: dict[str, str]
103
+ ) -> list[dict[str, str]]:
104
+ inputs = [name for name in site_placeholders(site) if name not in shared]
105
+ prompt_lines = [f"[{m.role}]\n{m.content}" for m in site.messages or []]
106
+ parts = [
107
+ f"Feature: {site.purpose or site.function}",
108
+ f"Output contract: {site.output_contract or 'not documented'}",
109
+ "Prompt template sent to the model ({name} is filled per case):",
110
+ "\n\n".join(prompt_lines),
111
+ ]
112
+ if shared:
113
+ parts.append("Fixed inputs, the same for every case (do not repeat them in cases):")
114
+ parts.extend(f"--- {k} ---\n{v}" for k, v in shared.items())
115
+ parts.append(f"Write {count} diverse, realistic test cases.")
116
+ parts.append(
117
+ f"Each case has keys: inputs (an object with exactly these keys: "
118
+ f"{', '.join(inputs)}), expected, notes (what the case tests)."
119
+ )
120
+ parts.append(_expected_rule(site, grading))
121
+ parts.append(
122
+ "Include edge cases: very short text, other languages, typos, "
123
+ "missing details. Skip any case whose answer could be argued."
124
+ )
125
+ parts.append('Reply with: {"cases": [ ... ]}')
126
+ return [
127
+ {"role": "system", "content": SYSTEM_PROMPT},
128
+ {"role": "user", "content": "\n\n".join(parts)},
129
+ ]
130
+
131
+
132
+ def parse_cases(text: str) -> list[Any]:
133
+ """Pull the case list out of a model reply. Raises ValueError if there is none."""
134
+ cleaned = _FENCE.sub("", text.strip())
135
+ try:
136
+ data = json.loads(cleaned)
137
+ except json.JSONDecodeError as exc:
138
+ raise ValueError(f"reply is not valid JSON ({exc.msg})") from exc
139
+ if isinstance(data, dict):
140
+ data = data.get("cases")
141
+ if not isinstance(data, list):
142
+ raise ValueError('reply has no "cases" list')
143
+ return data
144
+
145
+
146
+ def write_eval_set(
147
+ path: Path, cases: list[EvalCase], shared_files: dict[str, Path] | None = None
148
+ ) -> None:
149
+ """Write cases as JSONL, with a `shared` first line pointing at files if given."""
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ lines = []
152
+ if shared_files:
153
+ header = {
154
+ key: {"file": Path(os.path.relpath(target, path.parent)).as_posix()}
155
+ for key, target in shared_files.items()
156
+ }
157
+ lines.append(json.dumps({"shared": header}, ensure_ascii=False))
158
+ for case in cases:
159
+ record = {
160
+ "id": case.id,
161
+ "inputs": case.inputs,
162
+ "expected": case.expected,
163
+ "grading": case.grading,
164
+ "notes": case.notes,
165
+ }
166
+ lines.append(json.dumps(record, ensure_ascii=False))
167
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
168
+
169
+
170
+ # --- helpers ------------------------------------------------------------------
171
+
172
+
173
+ def _check_site(site: CallSite) -> None:
174
+ if not site.prompt_resolved:
175
+ raise EvalGenSkip("prompt is not resolved; run the Bob auditor first")
176
+ expressions = expression_placeholders(site)
177
+ if expressions:
178
+ names = ", ".join("{" + e + "}" for e in expressions)
179
+ raise EvalGenSkip(f"prompt has expression placeholders {names}; run the Bob auditor first")
180
+
181
+
182
+ def _expected_rule(site: CallSite, grading: str) -> str:
183
+ if grading == "exact":
184
+ labels = allowed_values(site)
185
+ if labels:
186
+ return f"expected: exactly one of {', '.join(labels)} (a string)."
187
+ return "expected: the exact short answer as a string."
188
+ if grading == "json_fields":
189
+ fields = graded_fields(site)
190
+ if fields:
191
+ return f"expected: an object with exactly these keys: {', '.join(fields)}."
192
+ return "expected: the JSON object the feature should return."
193
+ return (
194
+ "expected: a rubric string listing what a correct reply must and must not do, "
195
+ "point by point."
196
+ )
197
+
198
+
199
+ def _to_case(
200
+ raw: Any, site: CallSite, grading: str, shared: dict[str, str], number: int
201
+ ) -> tuple[EvalCase | None, str]:
202
+ if not isinstance(raw, dict):
203
+ return None, f"not an object: {str(raw)[:60]}"
204
+ notes = raw.get("notes")
205
+ notes = notes if isinstance(notes, str) and notes.strip() else "generated"
206
+ inputs = raw.get("inputs")
207
+ if not isinstance(inputs, dict):
208
+ return None, f"inputs is not an object: {notes}"
209
+ case = EvalCase(
210
+ id=f"{site.function}-{number:02d}",
211
+ inputs=inputs,
212
+ expected=raw.get("expected"),
213
+ grading=grading,
214
+ notes=notes,
215
+ )
216
+ check = validate_eval_set(EvalSet(path=Path("-"), cases=[case], shared=shared), site)
217
+ if check.errors:
218
+ reason = check.errors[0].split(": ", 1)[-1]
219
+ return None, f"{reason}: {notes}"
220
+ return case, ""
downshift/evals.py ADDED
@@ -0,0 +1,395 @@
1
+ """Eval sets: one JSONL file of test cases per call site.
2
+
3
+ File layout (``<slug>.jsonl``, slug from `slug_for`)::
4
+
5
+ {"shared": {"policy": {"file": "../data/refund_policy.md"}}} <- optional first line
6
+ {"id": "c01", "inputs": {...}, "expected": ..., "grading": "exact", "notes": "..."}
7
+ ...
8
+
9
+ `shared` inputs are merged under every case's own inputs, so long fixed
10
+ values (a policy document) are written once. A shared value is a JSON value
11
+ or ``{"file": "relative/path"}``, resolved against the eval file's folder.
12
+
13
+ `validate_eval_set` checks a file against its call site: the input keys must
14
+ match the prompt placeholders, and `expected` must fit the grading type.
15
+ The CLI command `check-evals` is a thin wrapper around `check_eval_dir`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ import json
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from downshift.schema import GRADINGS, CallSite
28
+
29
+ EVAL_SUFFIX = ".jsonl"
30
+ CASE_FIELDS = frozenset({"id", "inputs", "expected", "grading", "notes"})
31
+ MIN_CASES = 20
32
+
33
+ _BRACED = re.compile(r"\{([^{}\n]+)\}")
34
+ _GRADED_FIELDS = re.compile(r"Graded fields?:\s*([^\n]+)", re.IGNORECASE)
35
+ _VALUE_SET = re.compile(r"from the set \{([^{}]+)\}", re.IGNORECASE)
36
+
37
+
38
+ class EvalError(ValueError):
39
+ """Raised when an eval file cannot be parsed at all."""
40
+
41
+
42
+ # --- call site helpers --------------------------------------------------------
43
+
44
+
45
+ def slug_for(site_id: str) -> str:
46
+ """`supportdesk/triage.py::classify_category` -> `supportdesk.triage__classify_category`."""
47
+ path, _, function = site_id.partition("::")
48
+ module = path[:-3] if path.endswith(".py") else path
49
+ module = module.replace("\\", "/").strip("/").replace("/", ".")
50
+ return f"{module}__{function}" if function else module
51
+
52
+
53
+ def placeholders(template: str) -> list[str]:
54
+ """Placeholders in a prompt as the scanner renders it, in order, without duplicates.
55
+
56
+ `{name}` and `{ticket['body']}` both count. Braces around text that is not
57
+ a Python expression (literal JSON such as `{"a": 1}`) are ignored.
58
+ """
59
+ found: list[str] = []
60
+ for match in _BRACED.finditer(template):
61
+ inner = match.group(1).strip()
62
+ if not inner or not _is_expression(inner):
63
+ continue
64
+ if inner not in found:
65
+ found.append(inner)
66
+ return found
67
+
68
+
69
+ def site_placeholders(site: CallSite) -> list[str]:
70
+ found: list[str] = []
71
+ for message in site.messages or []:
72
+ for name in placeholders(message.content):
73
+ if name not in found:
74
+ found.append(name)
75
+ return found
76
+
77
+
78
+ def expression_placeholders(site: CallSite) -> list[str]:
79
+ """Placeholders that are not plain names; eval inputs cannot fill them."""
80
+ return [name for name in site_placeholders(site) if not name.isidentifier()]
81
+
82
+
83
+ def graded_fields(site: CallSite) -> list[str] | None:
84
+ """Field names after 'Graded fields:' in the output contract, or None if absent."""
85
+ if not site.output_contract:
86
+ return None
87
+ match = _GRADED_FIELDS.search(site.output_contract)
88
+ if not match:
89
+ return None
90
+ names: list[str] = []
91
+ for part in match.group(1).split(","):
92
+ token = part.strip().rstrip(".;").strip("`'\"")
93
+ if not token.isidentifier():
94
+ break
95
+ names.append(token)
96
+ return names or None
97
+
98
+
99
+ def allowed_values(site: CallSite) -> list[str] | None:
100
+ """Labels from 'from the set {a, b, c}' in the output contract, or None if absent."""
101
+ if not site.output_contract:
102
+ return None
103
+ match = _VALUE_SET.search(site.output_contract)
104
+ if not match:
105
+ return None
106
+ values = [v.strip().strip("`'\"") for v in match.group(1).split(",")]
107
+ return [v for v in values if v] or None
108
+
109
+
110
+ def render_messages(site: CallSite, inputs: dict[str, Any]) -> list[dict[str, str]]:
111
+ """Fill a call site's prompt with one case's inputs (for Phase 5 runs)."""
112
+ rendered = []
113
+ for message in site.messages or []:
114
+ content = message.content
115
+ for name in placeholders(content):
116
+ if name in inputs:
117
+ content = content.replace("{" + name + "}", _as_text(inputs[name]))
118
+ rendered.append({"role": message.role, "content": content})
119
+ return rendered
120
+
121
+
122
+ # --- loading ------------------------------------------------------------------
123
+
124
+
125
+ @dataclass
126
+ class EvalCase:
127
+ id: str
128
+ inputs: dict[str, Any]
129
+ expected: Any
130
+ grading: str
131
+ notes: str | None = None
132
+ line: int = 0
133
+
134
+
135
+ @dataclass
136
+ class EvalSet:
137
+ path: Path
138
+ cases: list[EvalCase]
139
+ shared: dict[str, Any] = field(default_factory=dict)
140
+ problems: list[str] = field(default_factory=list)
141
+
142
+ @property
143
+ def slug(self) -> str:
144
+ return self.path.name[: -len(EVAL_SUFFIX)]
145
+
146
+ def inputs_for(self, case: EvalCase) -> dict[str, Any]:
147
+ return {**self.shared, **case.inputs}
148
+
149
+
150
+ def load_eval_set(path: Path) -> EvalSet:
151
+ """Parse a JSONL eval file. Bad lines become `problems`; unreadable files raise."""
152
+ try:
153
+ text = path.read_text(encoding="utf-8")
154
+ except FileNotFoundError:
155
+ raise EvalError(f"eval file not found: {path}") from None
156
+ except (OSError, UnicodeDecodeError) as exc:
157
+ raise EvalError(f"{path}: could not read ({exc})") from exc
158
+
159
+ eval_set = EvalSet(path=path, cases=[])
160
+ first_record = True
161
+ for number, raw in enumerate(text.splitlines(), start=1):
162
+ if not raw.strip():
163
+ continue
164
+ where = f"line {number}"
165
+ try:
166
+ record = json.loads(raw)
167
+ except json.JSONDecodeError as exc:
168
+ eval_set.problems.append(f"{where}: invalid JSON ({exc.msg})")
169
+ first_record = False
170
+ continue
171
+ if not isinstance(record, dict):
172
+ eval_set.problems.append(f"{where}: expected a JSON object")
173
+ first_record = False
174
+ continue
175
+
176
+ if "shared" in record:
177
+ if not first_record:
178
+ eval_set.problems.append(f"{where}: 'shared' is only allowed on the first line")
179
+ else:
180
+ eval_set.shared = _load_shared(record, path, where, eval_set.problems)
181
+ first_record = False
182
+ continue
183
+ first_record = False
184
+
185
+ case = _case_from_record(record, number, eval_set.problems)
186
+ if case is not None:
187
+ eval_set.cases.append(case)
188
+ return eval_set
189
+
190
+
191
+ def _load_shared(record: dict[str, Any], path: Path, where: str, problems: list[str]) -> dict:
192
+ extra = sorted(set(record) - {"shared"})
193
+ if extra:
194
+ problems.append(f"{where}: unknown field(s) next to 'shared': {', '.join(extra)}")
195
+ shared = record["shared"]
196
+ if not isinstance(shared, dict):
197
+ problems.append(f"{where}: 'shared' must be an object")
198
+ return {}
199
+ values: dict[str, Any] = {}
200
+ for key, value in shared.items():
201
+ if isinstance(value, dict) and set(value) == {"file"}:
202
+ target = path.parent / str(value["file"])
203
+ try:
204
+ values[key] = target.read_text(encoding="utf-8")
205
+ except OSError:
206
+ problems.append(f"{where}: shared input {key!r}: file not found: {value['file']}")
207
+ else:
208
+ values[key] = value
209
+ return values
210
+
211
+
212
+ def _case_from_record(record: dict[str, Any], number: int, problems: list[str]) -> EvalCase | None:
213
+ where = f"line {number}"
214
+ unknown = sorted(set(record) - CASE_FIELDS)
215
+ if unknown:
216
+ problems.append(f"{where}: unknown field(s) {', '.join(unknown)}")
217
+ missing = [name for name in ("id", "inputs", "expected", "grading") if name not in record]
218
+ if missing:
219
+ problems.append(f"{where}: missing {', '.join(missing)}")
220
+ return None
221
+
222
+ case_id, inputs, grading = record["id"], record["inputs"], record["grading"]
223
+ notes = record.get("notes")
224
+ ok = True
225
+ if not isinstance(case_id, str) or not case_id.strip():
226
+ problems.append(f"{where}: id must be a non-empty string")
227
+ ok = False
228
+ if not isinstance(inputs, dict):
229
+ problems.append(f"{where}: inputs must be an object")
230
+ ok = False
231
+ if grading not in GRADINGS:
232
+ problems.append(f"{where}: grading must be one of {', '.join(sorted(GRADINGS))}")
233
+ ok = False
234
+ if notes is not None and not isinstance(notes, str):
235
+ problems.append(f"{where}: notes must be a string")
236
+ ok = False
237
+ if not ok:
238
+ return None
239
+ return EvalCase(
240
+ id=case_id,
241
+ inputs=inputs,
242
+ expected=record["expected"],
243
+ grading=grading,
244
+ notes=notes,
245
+ line=number,
246
+ )
247
+
248
+
249
+ # --- validation ---------------------------------------------------------------
250
+
251
+
252
+ @dataclass
253
+ class EvalReport:
254
+ path: Path
255
+ site_id: str | None = None
256
+ cases: int = 0
257
+ grading: str | None = None
258
+ errors: list[str] = field(default_factory=list)
259
+ warnings: list[str] = field(default_factory=list)
260
+
261
+ @property
262
+ def ok(self) -> bool:
263
+ return not self.errors
264
+
265
+
266
+ def validate_eval_set(eval_set: EvalSet, site: CallSite) -> EvalReport:
267
+ report = EvalReport(
268
+ path=eval_set.path, site_id=site.id, cases=len(eval_set.cases), grading=site.grading
269
+ )
270
+ report.errors.extend(eval_set.problems)
271
+
272
+ names = site_placeholders(site)
273
+ expressions = [name for name in names if not name.isidentifier()]
274
+ if site.messages is None or not site.prompt_resolved:
275
+ report.errors.append("call site prompt is not resolved; re-run the Bob auditor")
276
+ if expressions:
277
+ report.errors.append(
278
+ "prompt has expression placeholders "
279
+ + ", ".join("{" + e + "}" for e in expressions)
280
+ + "; rename them to plain names in the audit"
281
+ )
282
+ wanted = set(names) - set(expressions)
283
+
284
+ fields = graded_fields(site)
285
+ labels = allowed_values(site)
286
+ if site.grading == "json_fields" and fields is None:
287
+ report.warnings.append("output_contract has no 'Graded fields:' list")
288
+
289
+ seen: set[str] = set()
290
+ for case in eval_set.cases:
291
+ where = f"line {case.line} ({case.id})"
292
+ if case.id in seen:
293
+ report.errors.append(f"{where}: duplicate id")
294
+ seen.add(case.id)
295
+
296
+ if site.grading is not None and case.grading != site.grading:
297
+ report.errors.append(
298
+ f"{where}: grading {case.grading} does not match call site grading {site.grading}"
299
+ )
300
+
301
+ given = set(eval_set.inputs_for(case))
302
+ if not expressions:
303
+ missing = sorted(wanted - given)
304
+ extra = sorted(given - wanted)
305
+ if missing:
306
+ report.errors.append(f"{where}: inputs missing {', '.join(missing)}")
307
+ if extra:
308
+ report.errors.append(f"{where}: inputs not in the prompt: {', '.join(extra)}")
309
+
310
+ report.errors.extend(f"{where}: {p}" for p in _check_expected(case, fields, labels))
311
+
312
+ if len(eval_set.cases) < MIN_CASES:
313
+ report.warnings.append(f"only {len(eval_set.cases)} cases (aim for {MIN_CASES} or more)")
314
+ return report
315
+
316
+
317
+ def _check_expected(case: EvalCase, fields: list[str] | None, labels: list[str] | None) -> list:
318
+ expected = case.expected
319
+ if case.grading == "exact":
320
+ if not isinstance(expected, str) or not expected.strip():
321
+ return ["exact grading needs expected to be a non-empty string"]
322
+ if labels is not None and expected not in labels:
323
+ return [f"expected {expected!r} is not one of {', '.join(labels)}"]
324
+ return []
325
+ if case.grading == "json_fields":
326
+ if not isinstance(expected, dict) or not expected:
327
+ return ["json_fields grading needs expected to be a non-empty object"]
328
+ if fields is None:
329
+ return []
330
+ problems = []
331
+ missing = [name for name in fields if name not in expected]
332
+ extra = sorted(set(expected) - set(fields))
333
+ if missing:
334
+ problems.append(f"expected missing graded field(s) {', '.join(missing)}")
335
+ if extra:
336
+ problems.append(f"expected has fields that are not graded: {', '.join(extra)}")
337
+ return problems
338
+ if not isinstance(expected, str) or not expected.strip():
339
+ return ["judge grading needs expected to be a reference answer or rubric string"]
340
+ return []
341
+
342
+
343
+ def check_eval_dir(directory: Path, sites: list[CallSite]) -> list[EvalReport]:
344
+ """Validate every `*.jsonl` in a folder against the call sites it is named after.
345
+
346
+ Returns one report per eval file, plus one per call site that has no file.
347
+ """
348
+ by_slug = {slug_for(site.id): site for site in sites}
349
+ reports: list[EvalReport] = []
350
+ covered: set[str] = set()
351
+
352
+ for path in sorted(directory.glob(f"*{EVAL_SUFFIX}")):
353
+ slug = path.name[: -len(EVAL_SUFFIX)]
354
+ site = by_slug.get(slug)
355
+ if site is None:
356
+ reports.append(
357
+ EvalReport(path=path, errors=[f"no call site with slug {slug!r} in the audit"])
358
+ )
359
+ continue
360
+ covered.add(slug)
361
+ try:
362
+ eval_set = load_eval_set(path)
363
+ except EvalError as exc:
364
+ reports.append(EvalReport(path=path, site_id=site.id, errors=[str(exc)]))
365
+ continue
366
+ reports.append(validate_eval_set(eval_set, site))
367
+
368
+ for slug, site in by_slug.items():
369
+ if slug not in covered:
370
+ reports.append(
371
+ EvalReport(
372
+ path=directory / f"{slug}{EVAL_SUFFIX}",
373
+ site_id=site.id,
374
+ grading=site.grading,
375
+ warnings=["no eval file"],
376
+ )
377
+ )
378
+ return reports
379
+
380
+
381
+ # --- helpers ------------------------------------------------------------------
382
+
383
+
384
+ def _is_expression(text: str) -> bool:
385
+ if text[0] in "\"'":
386
+ return False
387
+ try:
388
+ node = ast.parse(text, mode="eval").body
389
+ except SyntaxError:
390
+ return False
391
+ return not isinstance(node, ast.Constant)
392
+
393
+
394
+ def _as_text(value: Any) -> str:
395
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)