This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8872-ed724ebede6dba46f778e2adfd471ee07dcc251e in repository https://gitbox.apache.org/repos/asf/texera.git
commit 61d435d25109d29385d8d7ce6d9a03f099c18c2e Author: Prateek Ganigi <[email protected]> AuthorDate: Wed Oct 14 23:49:01 2026 +0000 fix(workflow-operator): type-check provider values in the audio response parse (#8872) ### What changes were proposed in this PR? `AudioTaskCodegen.parsePython` used whatever the provider returned without checking its type, so a malformed but valid-JSON 200 produced a corrupt or non-string value in the result column instead of falling back to the body. A null `b64_json` became the literal `data:audio/mpeg;base64,None`; a numeric `url` was handed to `_url_to_data_url`; a numeric `text` was written to the cell as a number. Each value is now type-checked before use: `b64_json` must be a non-empty string, `url` must be a string starting with `http` — the guard the sibling `output` branch already applied, `data` must be a list before being indexed, and `text` / `generated_text` must be strings. Anything else falls through to `json.dumps(body)`, which is what the branch already did for unrecognised shapes. Reading `text` and `generated_text` independently also fixes a smaller bug: a null `text` previously masked a valid `generated_text`. Not addressed: a `b64_json` that is a string but not valid base64 still yields an unplayable data URL. Detecting that requires decoding the value on every row, and corrupt-but-well-formed base64 would pass regardless, so the browser's rejection is the same either way. ### Any related issues? Closes #8869 ### How was this PR tested? Written test-first: four tests were added to `AudioTaskCodegenSpec` for the `b64_json`, `url`, `data` and `text` guards, and they fail against the current parse. The suite is at 158 tests, `PythonCodeRawInvalidTextSpec` py-compiles all 117 operators, and `scalafmtCheck` and `scalafix --check` are clean. One existing assertion was updated where it pinned the old indexing form. The generated parser was also run against each malformed shape: every case in the issue now returns the serialized body, valid URL and base64 envelopes still resolve as before, and a null `text` alongside a valid `generated_text` now returns the latter. ### Was this PR authored or co-authored using generative AI tooling? Yes, this PR was co-authored with Claude in compliance with ASF policy. Co-authored-by: Xuan Gu <[email protected]> --- .../huggingFace/codegen/AudioTaskCodegen.scala | 36 ++++++++++++++-------- .../huggingFace/codegen/AudioTaskCodegenSpec.scala | 35 +++++++++++++++++++-- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegen.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegen.scala index 560244962a..2b8e260a46 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegen.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegen.scala @@ -46,6 +46,10 @@ object AudioTaskCodegen extends TaskCodegen { override def parsePython(ctx: CodegenContext): String = """ if task == "text-to-speech": + | # Every value below comes from the provider, so each is type-checked + | # before use: an unchecked one lands in the result column as a data + | # URL nothing can play (e.g. "data:audio/mpeg;base64,None") or as a + | # non-string cell, instead of falling back to the body. | if isinstance(body, dict): | if "output" in body: | out = body["output"] @@ -55,24 +59,30 @@ object AudioTaskCodegen extends TaskCodegen { | if "audio" in body: | audio = body["audio"] | if isinstance(audio, dict): - | if "url" in audio: - | return self._url_to_data_url(audio["url"]) - | if "b64_json" in audio: - | return f"data:audio/mpeg;base64,{audio['b64_json']}" + | url = audio.get("url") + | if isinstance(url, str) and url.startswith("http"): + | return self._url_to_data_url(url) + | b64 = audio.get("b64_json") + | if isinstance(b64, str) and b64: + | return f"data:audio/mpeg;base64,{b64}" | if "data" in body: | data = body["data"] - | if data and isinstance(data[0], dict): - | if "url" in data[0]: - | return self._url_to_data_url(data[0]["url"]) - | if "b64_json" in data[0]: - | return f"data:audio/mpeg;base64,{data[0]['b64_json']}" + | if isinstance(data, list) and data and isinstance(data[0], dict): + | url = data[0].get("url") + | if isinstance(url, str) and url.startswith("http"): + | return self._url_to_data_url(url) + | b64 = data[0].get("b64_json") + | if isinstance(b64, str) and b64: + | return f"data:audio/mpeg;base64,{b64}" | return json.dumps(body) | elif task == "automatic-speech-recognition": | if isinstance(body, dict): - | if "text" in body: - | return body["text"] - | if "generated_text" in body: - | return body["generated_text"] + | text = body.get("text") + | if isinstance(text, str): + | return text + | generated = body.get("generated_text") + | if isinstance(generated, str): + | return generated | return json.dumps(body) | elif task == "audio-classification": | return json.dumps(body)""".stripMargin diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegenSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegenSpec.scala index e7aa4f09ab..c707bc5ac0 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegenSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/AudioTaskCodegenSpec.scala @@ -96,11 +96,42 @@ class AudioTaskCodegenSpec extends AnyFlatSpec with Matchers { it should "pull recognised text from the ASR response before falling back to JSON" in { val out = AudioTaskCodegen.parsePython(makeCtx()) - out should include("""body["text"]""") - out should include("""body["generated_text"]""") + out should include("""body.get("text")""") + out should include("""body.get("generated_text")""") out should include("return json.dumps(body)") } + // #8869: the parse trusted whatever the provider returned, so a malformed 200 + // produced a corrupt or non-string cell rather than falling back to the body — + // e.g. a null b64_json became the literal "data:audio/mpeg;base64,None". + it should "require b64_json to be a string before building a data URL" in { + val out = AudioTaskCodegen.parsePython(makeCtx()) + out should include("""isinstance(b64, str)""") + out should not include ("""f"data:audio/mpeg;base64,{audio['b64_json']}"""") + out should not include ("""f"data:audio/mpeg;base64,{data[0]['b64_json']}"""") + } + + it should "guard the url branches the way the output branch already does" in { + // The "output" branch checked isinstance(url, str) while the sibling "audio" + // and "data" branches handed whatever they found to _url_to_data_url. + val out = AudioTaskCodegen.parsePython(makeCtx()) + out.split("""isinstance\(url, str\)""").length - 1 should be >= 3 + out should not include ("""return self._url_to_data_url(audio["url"])""") + out should not include ("""return self._url_to_data_url(data[0]["url"])""") + } + + it should "check data is a list before indexing it" in { + val out = AudioTaskCodegen.parsePython(makeCtx()) + out should include("""isinstance(data, list)""") + } + + it should "return recognised text only when it is a string" in { + val out = AudioTaskCodegen.parsePython(makeCtx()) + out should include("""isinstance(text, str)""") + out should not include ("""return body["text"]""") + out should not include ("""return body["generated_text"]""") + } + "AudioTaskCodegen snippets" should "never inline raw CodegenContext string values" in { // The snippets reference only self.* attributes and shared local names; the // base class decodes user-supplied strings safely at runtime. Sentinel
