This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 2113455e3f fix(workflow-operator): carry structured task context into
chat fallback payloads (#7798)
2113455e3f is described below
commit 2113455e3f384912d721893147e3dfd329df1126
Author: Prateek Ganigi <[email protected]>
AuthorDate: Mon Aug 24 00:35:45 2026 +0000
fix(workflow-operator): carry structured task context into chat fallback
payloads (#7798)
### What changes were proposed in this PR?
The five structured Hugging Face tasks (`question-answering`,
`table-question-answering`, `zero-shot-classification`,
`sentence-similarity`, `text-ranking`) build a native HF pipeline
payload that only `hf-inference` understands. When the operator fell
back to a third-party chat-completions provider, all three chat branches
sent just the prompt cell, dropping the
context, table, candidate labels, or sentence list, so the model
answered without the input that defines the task.
This adds a `_chat_content_for_task` helper to the generated operator
that reformulates each of the five tasks into a chat prompt carrying its
full context, and routes all three chat branches (zai-org,
OpenAI-compatible, unknown-provider fallback) through it. Other tasks
pass through unchanged. `QaRankingCodegen.parsePython` is also extended
to read `choices[0].message.content` from chat responses, keeping the
native `{"answer": ...}` shape as the primary path, the same idiom
`ImageTaskCodegen` already uses.
### Any related issues?
Closes #7195
### How was this PR tested?
131 tests pass in the `WorkflowOperator` Hugging Face suites,
`PythonCodeRawInvalidTextSpec` py-compiles the generated Python for all
117 operators, and `scalafmtCheck` is clean for main and test sources. A
new spec test asserts the helper is emitted and used by every chat
branch, with one assertion per task reformulation; two existing tests
were updated where they pinned the old parse expression.
### Was this PR authored or co-authored using generative AI tooling?
Yes, this PR was co-authored with Claude in compliance with ASF policy.
---
.../codegen/HuggingFaceCodegenBase.scala | 73 +++++++++++++++++++++-
.../huggingFace/codegen/QaRankingCodegen.scala | 16 ++++-
.../HuggingFaceInferenceOpDescSpec.scala | 28 +++++++--
.../huggingFace/codegen/QaRankingCodegenSpec.scala | 5 +-
4 files changed, 110 insertions(+), 12 deletions(-)
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/HuggingFaceCodegenBase.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/HuggingFaceCodegenBase.scala
index 043e713363..a40d4aeac7 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/HuggingFaceCodegenBase.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/HuggingFaceCodegenBase.scala
@@ -260,6 +260,73 @@ object HuggingFaceCodegenBase {
| summary = "; ".join(errors) if errors else "no providers
available"
| return last_resp, summary
|
+ | def _chat_content_for_task(self, pipeline_payload, prompt_value):
+ | '''Reformulate a structured task (question-answering,
+ | table-question-answering, zero-shot-classification,
+ | sentence-similarity, text-ranking) as a chat prompt so
third-party
+ | chat-completions providers receive the full context. The native
+ | pipeline_payload (question+context, table, labels, sentences)
is
+ | only understood by hf-inference; chat providers otherwise get
only
+ | prompt_value and hallucinate. Non-structured tasks are
unchanged.
+ | '''
+ | task = self.TASK
+ | inputs = pipeline_payload.get("inputs") if
isinstance(pipeline_payload, dict) else None
+ | if task == "question-answering" and isinstance(inputs, dict):
+ | question = inputs.get("question", prompt_value)
+ | context = inputs.get("context", "")
+ | if context:
+ | return (
+ | "Answer the question using only the context below.
"
+ | "If the answer is not in the context, say so.\n\n"
+ | f"Context:\n{context}\n\nQuestion: {question}"
+ | )
+ | return question or prompt_value
+ | if task == "table-question-answering" and isinstance(inputs,
dict):
+ | query = inputs.get("query", prompt_value)
+ | table = inputs.get("table")
+ | if table:
+ | return (
+ | "Answer the question using the table below, given
as JSON "
+ | "mapping each column to its list of cell
values.\n\n"
+ | f"Table:\n{json.dumps(table)}\n\nQuestion: {query}"
+ | )
+ | return query or prompt_value
+ | if task == "zero-shot-classification":
+ | params = pipeline_payload.get("parameters") if
isinstance(pipeline_payload, dict) else None
+ | labels = params.get("candidate_labels", []) if
isinstance(params, dict) else []
+ | text = inputs if isinstance(inputs, str) else prompt_value
+ | if labels:
+ | return (
+ | "Classify the text into exactly one of these
labels: "
+ | f"{', '.join(str(l) for l in labels)}. Respond
with only the chosen label.\n\n"
+ | f"Text: {text}"
+ | )
+ | return text or prompt_value
+ | if task == "sentence-similarity" and isinstance(inputs, dict):
+ | source = inputs.get("source_sentence", prompt_value)
+ | sentences = inputs.get("sentences") or []
+ | if sentences:
+ | numbered = "\n".join(f"{i}. {s}" for i, s in
enumerate(sentences, 1))
+ | return (
+ | "Rate how semantically similar the source sentence
is to "
+ | "each candidate below, from 0.0 (unrelated) to 1.0
"
+ | "(identical meaning). Give one score per
candidate.\n\n"
+ | f"Source: {source}\n\nCandidates:\n{numbered}"
+ | )
+ | return source or prompt_value
+ | if task == "text-ranking" and isinstance(inputs, dict):
+ | query = inputs.get("query", prompt_value)
+ | texts = inputs.get("texts") or []
+ | if texts:
+ | numbered = "\n".join(f"{i}. {t}" for i, t in
enumerate(texts, 1))
+ | return (
+ | "Rank the passages below by relevance to the
query, most "
+ | "relevant first, and return the ranking.\n\n"
+ | f"Query: {query}\n\nPassages:\n{numbered}"
+ | )
+ | return query or prompt_value
+ | return prompt_value
+ |
| def _call_provider(self, provider_name, provider_id, json_headers,
raw_binary_headers, pipeline_payload, use_raw_binary_body, prompt_value):
| '''Route to a third-party provider using its native API format.
| Handles OpenAI-compatible chat providers for text-gen,
zai-org's
@@ -297,7 +364,7 @@ object HuggingFaceCodegenBase {
| file_data = f"data:image/png;base64,{img_b64}" if
img_b64 else ""
| return requests.post(url, headers=zai_headers,
json={"model": provider_id, "file": file_data}, timeout=120)
| url = f"{base}/api/paas/v4/chat/completions"
- | messages = [{"role": "user", "content": prompt_value}]
+ | messages = [{"role": "user", "content":
self._chat_content_for_task(pipeline_payload, prompt_value)}]
| if img_b64:
| messages = [{"role": "user", "content": [
| {"type": "image_url", "image_url": {"url":
f"data:image/png;base64,{img_b64}"}},
@@ -439,7 +506,7 @@ object HuggingFaceCodegenBase {
| url = f"{base}/v1/audio/speech"
| return requests.post(url, headers=json_headers,
json={"model": provider_id, "input": prompt_value}, timeout=120)
| url = f"{base}/{self.CHAT_ROUTES.get(provider_name,
'v1/chat/completions')}"
- | messages = [{"role": "user", "content": prompt_value}]
+ | messages = [{"role": "user", "content":
self._chat_content_for_task(pipeline_payload, prompt_value)}]
| if img_b64:
| messages = [{"role": "user", "content": [
| {"type": "image_url", "image_url": {"url":
f"data:image/png;base64,{img_b64}"}},
@@ -460,7 +527,7 @@ object HuggingFaceCodegenBase {
| resp = requests.post(url, headers=json_headers,
json=pipeline_payload, timeout=120)
| if resp.status_code in (400, 404, 422):
| url = f"{base}/v1/chat/completions"
- | messages = [{"role": "user", "content": prompt_value}]
+ | messages = [{"role": "user", "content":
self._chat_content_for_task(pipeline_payload, prompt_value)}]
| if img_b64:
| messages = [{"role": "user", "content": [
| {"type": "image_url", "image_url": {"url":
f"data:image/png;base64,{img_b64}"}},
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegen.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegen.scala
index 18c6897499..bdc266080a 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegen.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegen.scala
@@ -76,9 +76,21 @@ object QaRankingCodegen extends TaskCodegen {
override def parsePython(ctx: CodegenContext): String =
""" if task == "question-answering":
- | return body.get("answer", json.dumps(body)) if
isinstance(body, dict) else json.dumps(body)
+ | if isinstance(body, dict):
+ | # Third-party chat providers answer via
choices[0].message;
+ | # hf-inference returns the native {"answer": ...}
shape.
+ | if "choices" in body:
+ | return body["choices"][0]["message"]["content"]
+ | return body.get("answer", json.dumps(body))
+ | return json.dumps(body)
| elif task == "table-question-answering":
- | return body.get("answer", json.dumps(body)) if
isinstance(body, dict) else json.dumps(body)
+ | if isinstance(body, dict):
+ | if "choices" in body:
+ | return body["choices"][0]["message"]["content"]
+ | return body.get("answer", json.dumps(body))
+ | return json.dumps(body)
| elif task in ("zero-shot-classification",
"sentence-similarity", "text-ranking"):
+ | if isinstance(body, dict) and "choices" in body:
+ | return body["choices"][0]["message"]["content"]
| return json.dumps(body)""".stripMargin
}
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
index e23a9141fd..56b4b65ebf 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
@@ -567,9 +567,8 @@ class HuggingFaceInferenceOpDescSpec extends AnyFlatSpec
with Matchers {
code should include("ctx_col = self.CONTEXT_COLUMN")
code should include("Context column")
code should include("""payload = {"inputs": {"question": prompt_value,
"context": ctx_val}}""")
- code should include(
- """return body.get("answer", json.dumps(body)) if isinstance(body, dict)
else json.dumps(body)"""
- )
+ code should include("""body.get("answer", json.dumps(body))""")
+ code should include("""body["choices"][0]["message"]["content"]""")
}
it should "route table-question-answering with a precomputed table payload"
in {
@@ -577,9 +576,8 @@ class HuggingFaceInferenceOpDescSpec extends AnyFlatSpec
with Matchers {
code should include("""if task == "table-question-answering":""")
code should include("table_dict = {}")
code should include("""payload = {"inputs": {"query": prompt_value,
"table": table_dict}}""")
- code should include(
- """return body.get("answer", json.dumps(body)) if isinstance(body, dict)
else json.dumps(body)"""
- )
+ code should include("""body.get("answer", json.dumps(body))""")
+ code should include("""body["choices"][0]["message"]["content"]""")
}
it should "route zero-shot-classification with candidate labels" in {
@@ -630,6 +628,24 @@ class HuggingFaceInferenceOpDescSpec extends AnyFlatSpec
with Matchers {
}
}
+ it should
+ "reformulate structured tasks into a chat message on fallback providers
(#7195)" in {
+ // On third-party chat providers the structured pipeline_payload (context /
+ // table / labels / sentences) is inlined into the chat message via the
+ // helper, instead of sending only prompt_value and dropping the rest.
+ val code = makeDesc(task = "question-answering", contextColumn =
"context").generatePythonCode()
+ code should include("def _chat_content_for_task(")
+ // Every chat branch (zai-org, OpenAI-compatible, unknown-fallback) uses
it.
+ code should include("self._chat_content_for_task(pipeline_payload,
prompt_value)")
+ code should not include ("""messages = [{"role": "user", "content":
prompt_value}]""")
+ // Per-task reformulations are present.
+ code should include("Answer the question using only the context below.")
+ code should include("Answer the question using the table below")
+ code should include("Classify the text into exactly one of these labels:")
+ code should include("Rate how semantically similar the source sentence")
+ code should include("Rank the passages below by relevance to the query")
+ }
+
"getOutputSchemas" should "add the result column as a STRING to the
inherited schema" in {
val desc = makeDesc(resultColumn = "answer")
val inputSchema = Schema().add("prompt", AttributeType.STRING)
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegenSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegenSpec.scala
index 0e21824b88..c7c3c18a91 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegenSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/codegen/QaRankingCodegenSpec.scala
@@ -110,11 +110,14 @@ class QaRankingCodegenSpec extends AnyFlatSpec with
Matchers {
out should include("""payload = {"inputs": prompt_value}""")
}
- "QaRankingCodegen.parsePython" should "extract the answer field for both QA
variants" in {
+ "QaRankingCodegen.parsePython" should "extract the answer, including from
chat-provider responses" in {
val out = QaRankingCodegen.parsePython(makeCtx())
out should include("""if task == "question-answering":""")
out should include("""elif task == "table-question-answering":""")
out should include("""body.get("answer"""")
+ // #7195: chat-completions responses (third-party providers) are read from
+ // choices[0].message.content, not the native {"answer": ...} shape.
+ out should include("""body["choices"][0]["message"]["content"]""")
}
it should "return the raw JSON body for the ranking-style tasks" in {