feat(data-process): 支持思维链输出类型

This commit is contained in:
caoxiaozhu
2026-07-27 13:08:44 +08:00
parent ecafb7eb13
commit de2e8952b5
14 changed files with 443 additions and 46 deletions

View File

@@ -556,12 +556,16 @@ def _run_generation(
if task["process_type"] == "unstructured" if task["process_type"] == "unstructured"
else _value(config, "qa_pairs_per_row", "qaPairsPerRow", 1) else _value(config, "qa_pairs_per_row", "qaPairsPerRow", 1)
) )
output_type = str(
_value(config, "output_type", "outputType", "standard")
).strip().lower()
if generation_model: if generation_model:
runtime_config = { runtime_config = {
**config, **config,
"generation_prompt": _value( "generation_prompt": _value(
config, "generation_prompt", "generationPrompt", "" config, "generation_prompt", "generationPrompt", ""
), ),
"output_type": output_type,
"max_tokens": _value(config, "max_tokens", "maxTokens", 1024), "max_tokens": _value(config, "max_tokens", "maxTokens", 1024),
"json_mode": _value(config, "json_mode", "jsonMode", False), "json_mode": _value(config, "json_mode", "jsonMode", False),
} }
@@ -583,6 +587,8 @@ def _run_generation(
qa_pairs_per_item=int(pairs or 1), qa_pairs_per_item=int(pairs or 1),
on_progress=report_progress, on_progress=report_progress,
) )
elif output_type == "reasoning":
raise InvalidStateError("思维链输出必须配置可用的数据生成模型")
else: else:
generated = generate_standard_records( generated = generate_standard_records(
preview_items, preview_items,

View File

@@ -22,6 +22,11 @@ class ModelGenerationError(ValueError):
"""模型配置、响应或调用失败。""" """模型配置、响应或调用失败。"""
OUTPUT_TYPE_STANDARD = "standard"
OUTPUT_TYPE_REASONING = "reasoning"
SUPPORTED_OUTPUT_TYPES = {OUTPUT_TYPE_STANDARD, OUTPUT_TYPE_REASONING}
def chat_completions_url(value: str) -> str: def chat_completions_url(value: str) -> str:
"""把域名、基础 URL 或完整地址统一为 chat completions 地址。""" """把域名、基础 URL 或完整地址统一为 chat completions 地址。"""
@@ -52,7 +57,9 @@ def _message_content(payload: Mapping[str, Any]) -> str:
try: try:
content = payload["choices"][0]["message"]["content"] content = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc: except (KeyError, IndexError, TypeError) as exc:
raise ModelGenerationError("model response does not contain choices[0].message.content") from exc raise ModelGenerationError(
"model response does not contain choices[0].message.content"
) from exc
if isinstance(content, str): if isinstance(content, str):
return content return content
if isinstance(content, list): if isinstance(content, list):
@@ -67,7 +74,14 @@ def _message_content(payload: Mapping[str, Any]) -> str:
def _json_payload(content: str) -> Any: def _json_payload(content: str) -> Any:
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", content, flags=re.IGNORECASE).strip() # 只移除模型在 JSON 之前自行输出的思考过程,不能破坏 JSON 字段中的训练内容。
cleaned = re.sub(
r"^\s*<think>[\s\S]*?</think>\s*",
"",
content,
count=1,
flags=re.IGNORECASE,
).strip()
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, flags=re.IGNORECASE) fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, flags=re.IGNORECASE)
if fenced: if fenced:
cleaned = fenced.group(1).strip() cleaned = fenced.group(1).strip()
@@ -107,19 +121,27 @@ def _prompt_messages(
*, *,
start_index: int, start_index: int,
total_count: int, total_count: int,
output_type: str,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
end_index = start_index + count - 1 end_index = start_index + count - 1
if output_type == OUTPUT_TYPE_REASONING:
schema = '{"items":[{"instruction":"...","input":"...","reasoning":"...","answer":"..."}]}'
output_rule = (
"instruction、reasoning 和 answer 均不得为空reasoning 必须是基于来源内容、"
"可核对且简洁的推理步骤answer 只写最终答案。"
"这是思维链输出模式,即使其他提示语要求省略分析,也不得省略 reasoning。"
"不要自行添加 <think> 标签,系统会在保存时统一组装。"
)
else:
schema = '{"items":[{"instruction":"...","input":"...","output":"..."}]}'
output_rule = "instruction 和 output 不得为空,不要输出分析过程。"
schema_instruction = ( schema_instruction = (
f"必须只返回 JSON 对象,格式为 {{\"items\":[{{\"instruction\":\"...\"," f"必须只返回 JSON 对象,格式为 {schema}items 必须包含 {count} 条。"
f"\"input\":\"...\",\"output\":\"...\"}}]}}items 必须包含 {count} 条。"
f"这是总计 {total_count} 条中的第 {start_index}-{end_index} 条," f"这是总计 {total_count} 条中的第 {start_index}-{end_index} 条,"
"各条必须使用不同的提问角度和表述,避免重复。" "各条必须使用不同的提问角度和表述,避免重复。"
"instruction 和 output 不得为空,不要输出 Markdown 代码围栏或分析过程" f"{output_rule}不要输出 Markdown 代码围栏或 JSON 之外的说明"
)
base_prompt = (
normalize_text(prompt)
or "请根据来源内容生成可用于监督微调的问答数据。"
) )
base_prompt = normalize_text(prompt) or "请根据来源内容生成可用于监督微调的问答数据。"
if "{{ content }}" in base_prompt: if "{{ content }}" in base_prompt:
user_prompt = base_prompt.replace("{{ content }}", content) user_prompt = base_prompt.replace("{{ content }}", content)
return [ return [
@@ -150,9 +172,10 @@ def generate_model_records(
""" """
if not 1 <= qa_pairs_per_item <= MAX_QA_PAIRS_PER_ITEM: if not 1 <= qa_pairs_per_item <= MAX_QA_PAIRS_PER_ITEM:
raise ModelGenerationError( raise ModelGenerationError(f"qa_pairs_per_item must be in [1, {MAX_QA_PAIRS_PER_ITEM}]")
f"qa_pairs_per_item must be in [1, {MAX_QA_PAIRS_PER_ITEM}]" output_type = str(config.get("output_type") or OUTPUT_TYPE_STANDARD).strip().lower()
) if output_type not in SUPPORTED_OUTPUT_TYPES:
raise ModelGenerationError(f"output_type must be one of {sorted(SUPPORTED_OUTPUT_TYPES)}")
endpoint = chat_completions_url(str(model.get("api_url") or "")) endpoint = chat_completions_url(str(model.get("api_url") or ""))
model_name = str(model.get("online_model_name") or model.get("name") or "").strip() model_name = str(model.get("online_model_name") or model.get("name") or "").strip()
if not model_name: if not model_name:
@@ -193,6 +216,7 @@ def generate_model_records(
batch_count, batch_count,
start_index=batch_start, start_index=batch_start,
total_count=qa_pairs_per_item, total_count=qa_pairs_per_item,
output_type=output_type,
), ),
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens, "max_tokens": max_tokens,
@@ -212,12 +236,8 @@ def generate_model_records(
response.raise_for_status() response.raise_for_status()
body = response.json() body = response.json()
if not isinstance(body, Mapping): if not isinstance(body, Mapping):
raise ModelGenerationError( raise ModelGenerationError("model response body must be a JSON object")
"model response body must be a JSON object" candidate_items = _result_items(_json_payload(_message_content(body)))
)
candidate_items = _result_items(
_json_payload(_message_content(body))
)
if len(candidate_items) < batch_count: if len(candidate_items) < batch_count:
raise ModelGenerationError( raise ModelGenerationError(
"model response contains fewer result objects than requested: " "model response contains fewer result objects than requested: "
@@ -266,19 +286,56 @@ def generate_model_records(
input_text = normalize_text( input_text = normalize_text(
str(value.get("input") or value.get("context") or "") str(value.get("input") or value.get("context") or "")
) )
output = normalize_text( if output_type == OUTPUT_TYPE_REASONING:
str( reasoning = normalize_text(
value.get("output") re.sub(
or value.get("answer") r"</?think>",
or value.get("response") "",
or "" str(value.get("reasoning") or value.get("analysis") or ""),
flags=re.IGNORECASE,
)
) )
) answer = normalize_text(
re.sub(
r"</?think>",
"",
str(
value.get("answer")
or value.get("final_answer")
or value.get("output")
or ""
),
flags=re.IGNORECASE,
)
)
output = (
f"<think>\n{reasoning}\n</think>\n{answer}"
if reasoning and answer
else answer or (f"<think>\n{reasoning}\n</think>" if reasoning else "")
)
valid = bool(instruction and reasoning and answer)
missing_error = "model result is missing instruction, reasoning or answer"
else:
output = normalize_text(
str(
value.get("output")
or value.get("answer")
or value.get("response")
or ""
)
)
output = normalize_text(
re.sub(
r"<think>[\s\S]*?(?:</think>|$)",
"",
output,
flags=re.IGNORECASE,
)
)
valid = bool(instruction and output)
missing_error = "model result is missing instruction or output"
raw_id = f"{preview_id}:{variant_index + 1}:{instruction}:{output}" raw_id = f"{preview_id}:{variant_index + 1}:{instruction}:{output}"
result_id = ( result_id = f"result_{hashlib.sha256(raw_id.encode()).hexdigest()[:16]}"
f"result_{hashlib.sha256(raw_id.encode()).hexdigest()[:16]}"
)
valid = bool(instruction and output)
results.append( results.append(
{ {
"id": result_id, "id": result_id,
@@ -290,11 +347,7 @@ def generate_model_records(
"original_input": input_text, "original_input": input_text,
"original_output": output, "original_output": output,
"status": "valid" if valid else "invalid", "status": "valid" if valid else "invalid",
"error": ( "error": (None if valid else missing_error),
None
if valid
else "model result is missing instruction or output"
),
"split": "train", "split": "train",
} }
) )

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
import uuid import uuid
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
@@ -87,6 +88,31 @@ def _json_value(value: Any, default: Any) -> Any:
return default return default
def _task_output_type(task: dict[str, Any]) -> str:
config = _json_value(task.get("config"), {})
if not isinstance(config, dict):
return "standard"
return str(config.get("output_type") or config.get("outputType") or "standard")
def _reasoning_output_is_valid(value: Any) -> bool:
match = re.fullmatch(
r"\s*<think>\s*(?P<reasoning>[\s\S]*?)\s*</think>\s*(?P<answer>[\s\S]+?)\s*",
str(value or ""),
flags=re.IGNORECASE,
)
return bool(
match
and match.group("reasoning").strip()
and match.group("answer").strip()
and all(
tag not in part.lower()
for tag in ("<think", "</think")
for part in (match.group("reasoning"), match.group("answer"))
)
)
def _preview_config_value(config: dict[str, Any], key: str, default: Any) -> Any: def _preview_config_value(config: dict[str, Any], key: str, default: Any) -> Any:
if key in config: if key in config:
return config[key] return config[key]
@@ -1565,10 +1591,13 @@ class DataProcessStore:
raise ConflictError("data process result was modified by another request") raise ConflictError("data process result was modified by another request")
merged = {**current, **values} merged = {**current, **values}
quality = payload.get("quality_score") or {} quality = payload.get("quality_score") or {}
hard_valid = bool( instruction_valid = bool(str(merged.get("instruction") or "").strip())
str(merged.get("instruction") or "").strip() output_valid = bool(str(merged.get("output") or "").strip())
and str(merged.get("output") or "").strip() reasoning_valid = (
_task_output_type(task) != "reasoning"
or _reasoning_output_is_valid(merged.get("output"))
) )
hard_valid = instruction_valid and output_valid and reasoning_valid
quality_valid = bool(quality.get("is_valid", hard_valid)) quality_valid = bool(quality.get("is_valid", hard_valid))
changed = any( changed = any(
str(merged.get(field) or "") str(merged.get(field) or "")
@@ -1580,8 +1609,16 @@ class DataProcessStore:
) )
values["status"] = status values["status"] = status
flags = quality.get("flags") if isinstance(quality, dict) else None flags = quality.get("flags") if isinstance(quality, dict) else None
format_error = (
"思维链输出必须包含非空的 <think>...</think> 推理过程和最终答案"
if instruction_valid and output_valid and not reasoning_valid
else "Instruction 和 Output 不能为空"
if not instruction_valid or not output_valid
else None
)
values["error"] = ", ".join(str(flag) for flag in flags or []) or ( values["error"] = ", ".join(str(flag) for flag in flags or []) or (
"quality validation failed" if status == "invalid" else None format_error
or ("quality validation failed" if status == "invalid" else None)
) )
values["updated_at"] = utcnow() values["updated_at"] = utcnow()
assignments = ", ".join(f"{key}=%s" for key in values) assignments = ", ".join(f"{key}=%s" for key in values)
@@ -1671,6 +1708,10 @@ class DataProcessStore:
if row["status"] == "invalid" if row["status"] == "invalid"
or not str(row.get("instruction") or "").strip() or not str(row.get("instruction") or "").strip()
or not str(row.get("output") or "").strip() or not str(row.get("output") or "").strip()
or (
_task_output_type(task) == "reasoning"
and not _reasoning_output_is_valid(row.get("output"))
)
) )
if invalid_count: if invalid_count:
raise InvalidStateError(f"task contains {invalid_count} invalid results") raise InvalidStateError(f"task contains {invalid_count} invalid results")
@@ -1731,6 +1772,7 @@ class DataProcessStore:
"source": "data_process", "source": "data_process",
"storage_backend": "database", "storage_backend": "database",
"source_task_id": task_id, "source_task_id": task_id,
"output_type": _task_output_type(task),
"source_file_ids": [item["id"] for item in self._source_ids(conn, task_id)], "source_file_ids": [item["id"] for item in self._source_ids(conn, task_id)],
"source_result_ids": source_result_ids, "source_result_ids": source_result_ids,
"format": payload.get("format") or "alpaca_jsonl", "format": payload.get("format") or "alpaca_jsonl",

View File

@@ -1405,6 +1405,40 @@ def test_stale_generation_worker_cannot_overwrite_new_run(monkeypatch: Any) -> N
assert store.tasks[task_id]["status"] == "running" assert store.tasks[task_id]["status"] == "running"
def test_reasoning_output_requires_generation_model() -> None:
store = FakeDataProcessStore()
task = store.create_task(
{
"name": "思维链模型校验",
"process_type": "structured",
"config": {"output_type": "reasoning"},
}
)
task_id = task["id"]
store.replace_preview_items(
task_id,
[
{
"source_file_id": None,
"original_content": "需要推理的来源内容",
"edited_content": "需要推理的来源内容",
"status": "manual",
}
],
)
started = store.start_generation(task_id, replace_existing=True)
data_process_endpoint._run_generation(
store,
task_id,
started["generation_run_id"],
)
failed = store.get_task(task_id)
assert failed["status"] == "failed"
assert failed["failure_reason"] == "思维链输出必须配置可用的数据生成模型"
def test_result_status_cannot_be_forged_by_client(tmp_path: Path) -> None: def test_result_status_cannot_be_forged_by_client(tmp_path: Path) -> None:
client, _, _ = make_client(tmp_path) client, _, _ = make_client(tmp_path)
task_id = client.post( task_id = client.post(

View File

@@ -86,6 +86,119 @@ def test_generate_model_records_uses_prompt_auth_and_stable_split() -> None:
assert progress_updates == [(1, 1)] assert progress_updates == [(1, 1)]
def test_generate_model_records_builds_reasoning_output_with_think_tags() -> None:
def handler(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
system_prompt = payload["messages"][0]["content"]
assert '"reasoning":"...","answer":"..."' in system_prompt
assert "系统会在保存时统一组装" in system_prompt
content = "<think>模型接口自己的分析</think>" + json.dumps(
{
"items": [
{
"instruction": "计算两项费用合计",
"input": "交通费 30 元,餐费 20 元",
"reasoning": "先识别两项费用,再计算 30 + 20。",
"answer": "合计 50 元。",
}
]
},
ensure_ascii=False,
)
return httpx.Response(
200,
json={"choices": [{"message": {"content": content}}]},
)
records = generate_model_records(
[{"id": "preview-reasoning", "edited_content": "交通费 30 元,餐费 20 元"}],
model={"name": "model", "api_url": "https://model.example/v1"},
config={"output_type": "reasoning"},
task_id="task-reasoning",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=1,
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
assert records[0]["status"] == "valid"
assert records[0]["output"] == (
"<think>\n先识别两项费用,再计算 30 + 20。\n</think>\n合计 50 元。"
)
def test_generate_model_records_marks_reasoning_without_reasoning_field_invalid() -> None:
response = {
"choices": [
{
"message": {
"content": json.dumps(
{
"items": [
{
"instruction": "问题",
"answer": "只有最终答案",
}
]
},
ensure_ascii=False,
)
}
}
]
}
client = httpx.Client(
transport=httpx.MockTransport(lambda _: httpx.Response(200, json=response))
)
records = generate_model_records(
[{"id": "preview-missing-reasoning", "edited_content": "来源正文"}],
model={"name": "model", "api_url": "https://model.example/v1"},
config={"output_type": "reasoning"},
task_id="task-missing-reasoning",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=1,
client=client,
)
assert records[0]["status"] == "invalid"
assert records[0]["output"] == "只有最终答案"
assert "reasoning" in records[0]["error"]
def test_standard_output_removes_model_think_block() -> None:
content = json.dumps(
{
"items": [
{
"instruction": "问题",
"output": "<think>不应保存的分析</think>最终答案",
}
]
},
ensure_ascii=False,
)
client = httpx.Client(
transport=httpx.MockTransport(
lambda _: httpx.Response(
200,
json={"choices": [{"message": {"content": content}}]},
)
)
)
records = generate_model_records(
[{"id": "preview-standard", "edited_content": "来源正文"}],
model={"name": "model", "api_url": "https://model.example/v1"},
config={"output_type": "standard"},
task_id="task-standard",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=1,
client=client,
)
assert records[0]["output"] == "最终答案"
def test_generate_model_records_keeps_partial_failure_for_manual_repair() -> None: def test_generate_model_records_keeps_partial_failure_for_manual_repair() -> None:
client = httpx.Client( client = httpx.Client(
transport=httpx.MockTransport( transport=httpx.MockTransport(
@@ -267,3 +380,15 @@ def test_generate_model_records_rejects_out_of_range_count(
split={"train": 100, "validation": 0, "test": 0}, split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=qa_pairs_per_item, qa_pairs_per_item=qa_pairs_per_item,
) )
def test_generate_model_records_rejects_unknown_output_type() -> None:
with pytest.raises(ModelGenerationError, match="output_type"):
generate_model_records(
[],
model={"name": "model", "api_url": "https://model.example/v1"},
config={"output_type": "unknown"},
task_id="task-invalid-output",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=1,
)

View File

@@ -15,6 +15,7 @@ from app.modules.data_process.store import (
InvalidStateError, InvalidStateError,
_decode_row, _decode_row,
_preview_config_changed, _preview_config_changed,
_reasoning_output_is_valid,
_source_storage_descriptor, _source_storage_descriptor,
) )
@@ -114,14 +115,25 @@ class _PublishConnection:
) )
if normalized.startswith("INSERT INTO dataset_records"): if normalized.startswith("INSERT INTO dataset_records"):
self.records.append( self.records.append(
{"dataset_id": params[1], "line_no": params[4], "split": params[5]} {
"dataset_id": params[1],
"line_no": params[4],
"split": params[5],
"output": params[8],
"raw": json.loads(params[9]),
}
) )
return _Result() return _Result()
class _PublishStore(DataProcessStore): class _PublishStore(DataProcessStore):
def __init__(self, conn: _PublishConnection): def __init__(
self,
conn: _PublishConnection,
task_config: dict[str, Any] | None = None,
):
self._conn = conn self._conn = conn
self._task_config = task_config or {}
@contextmanager @contextmanager
def connect(self) -> Iterator[_PublishConnection]: def connect(self) -> Iterator[_PublishConnection]:
@@ -135,7 +147,7 @@ class _PublishStore(DataProcessStore):
"id": task_id, "id": task_id,
"status": "completed", "status": "completed",
"description": "", "description": "",
"config": {}, "config": self._task_config,
"output_dataset_id": train_dataset and train_dataset["id"], "output_dataset_id": train_dataset and train_dataset["id"],
} }
@@ -967,7 +979,11 @@ def test_publish_creates_three_independent_datasets_with_exact_counts() -> None:
"status": "valid", "status": "valid",
"instruction": f"问题 {index}", "instruction": f"问题 {index}",
"input": "", "input": "",
"output": f"答案 {index}", "output": (
"<think>\n先读取制度条款。\n</think>\n答案 0"
if index == 0
else f"答案 {index}"
),
"preview_item_id": f"preview-{index}", "preview_item_id": f"preview-{index}",
} }
for index in range(28) for index in range(28)
@@ -993,6 +1009,10 @@ def test_publish_creates_three_independent_datasets_with_exact_counts() -> None:
item["id"] for item in conn.datasets item["id"] for item in conn.datasets
} }
assert len(conn.records) == 28 assert len(conn.records) == 28
reasoning_record = next(
item for item in conn.records if item["output"].startswith("<think>")
)
assert reasoning_record["raw"]["output"] == reasoning_record["output"]
assert published["dataset"]["type"] == "train" assert published["dataset"]["type"] == "train"
assert len(published["datasets"]) == 3 assert len(published["datasets"]) == 3
assert published["split_counts"] == {"train": 22, "validation": 3, "test": 3} assert published["split_counts"] == {"train": 22, "validation": 3, "test": 3}
@@ -1078,6 +1098,49 @@ def test_publish_keeps_all_three_datasets_when_a_small_split_is_empty() -> None:
assert len(conn.files) == 3 assert len(conn.files) == 3
@pytest.mark.parametrize(
("output", "expected"),
[
("<think>\n推理步骤\n</think>\n最终答案", True),
("<think></think>\n最终答案", False),
("<think>只有推理</think>", False),
("没有标签的最终答案", False),
("<think>外层<think>嵌套</think></think>答案", False),
],
)
def test_reasoning_output_validator_requires_one_complete_pair(
output: str,
expected: bool,
) -> None:
assert _reasoning_output_is_valid(output) is expected
def test_publish_rejects_invalid_reasoning_output_format() -> None:
conn = _PublishConnection(
[
{
"id": "result-reasoning-invalid",
"status": "valid",
"instruction": "需要推理的问题",
"input": "",
"output": "只有最终答案",
"preview_item_id": "preview-reasoning-invalid",
}
]
)
with pytest.raises(InvalidStateError, match="1 invalid results"):
_PublishStore(conn, {"output_type": "reasoning"}).publish(
"task-reasoning-invalid",
{
"dataset_name": "无效思维链",
"storage_type": "local",
"format": "alpaca_jsonl",
"split": {"train": 80, "validation": 10, "test": 10},
},
)
def test_source_storage_descriptor_accepts_owned_local_and_legacy_db_references() -> None: def test_source_storage_descriptor_accepts_owned_local_and_legacy_db_references() -> None:
task_id = "dpt_task" task_id = "dpt_task"
source_file_id = "dpsf_source" source_file_id = "dpsf_source"

View File

@@ -86,6 +86,8 @@ assert.doesNotMatch(detailSource, /^\s*max-width:\s*\d+px/m, '详情页不应使
assert.match(detailSource, /!\/\(\?:password\|secret\|token\|api_key\)\/i\.test\(key\)/, '处理配置没有过滤敏感凭据字段') assert.match(detailSource, /!\/\(\?:password\|secret\|token\|api_key\)\/i\.test\(key\)/, '处理配置没有过滤敏感凭据字段')
assert.match(detailSource, /key !== 'generation_model_snapshot'/, '处理配置仍直接展示内部模型快照') assert.match(detailSource, /key !== 'generation_model_snapshot'/, '处理配置仍直接展示内部模型快照')
assert.match(detailSource, /preprocessOptionLabelMap/, '处理配置没有把预处理内部枚举转换为中文') assert.match(detailSource, /preprocessOptionLabelMap/, '处理配置没有把预处理内部枚举转换为中文')
assert.match(detailSource, /output_type:\s*'输出类型'/, '处理配置没有显示输出类型名称')
assert.match(detailSource, /value === 'reasoning' \? '思维链回答' : '标准回答'/, '处理配置没有转换输出类型枚举')
assert.match(detailSource, /const parsed = typeof value === 'number' \? value : Number\(value\)/, '详情页不兼容 PostgreSQL 数字字符串') assert.match(detailSource, /const parsed = typeof value === 'number' \? value : Number\(value\)/, '详情页不兼容 PostgreSQL 数字字符串')
assert.match(detailSource, /new Date\(startTime\.value\)\.getTime\(\)/, '详情页没有在后端耗时缺失时按开始、完成时间回算') assert.match(detailSource, /new Date\(startTime\.value\)\.getTime\(\)/, '详情页没有在后端耗时缺失时按开始、完成时间回算')
assert.match(detailSource, /outputCount \+ numeric\(detail\.value\?\.filtered_count\)/, '结果保留率没有使用输出和过滤结果的同口径分母') assert.match(detailSource, /outputCount \+ numeric\(detail\.value\?\.filtered_count\)/, '结果保留率没有使用输出和过滤结果的同口径分母')

View File

@@ -452,6 +452,7 @@ assert.match(apiSource, /regenerateDataProcessTask[\s\S]*?\/regenerate`/, '重
for (const field of [ for (const field of [
'generationModelId', 'generationModelId',
'generationPrompt', 'generationPrompt',
'outputType',
'qualityFilterEnabled', 'qualityFilterEnabled',
'filterLowQuality', 'filterLowQuality',
'filterShortContent', 'filterShortContent',
@@ -472,11 +473,14 @@ assert.match(modelSelectionSource, /class="form-section"/, '大模型选择步
assert.doesNotMatch(modelSelectionSource, /max-width:\s*980px/, '大模型选择步骤不应使用比第一步更窄的固定内容宽度') assert.doesNotMatch(modelSelectionSource, /max-width:\s*980px/, '大模型选择步骤不应使用比第一步更窄的固定内容宽度')
assert.match(taskSetupFeatureSource, /section="quality"/, '质量筛选没有保留在生成选项分类中') assert.match(taskSetupFeatureSource, /section="quality"/, '质量筛选没有保留在生成选项分类中')
assert.doesNotMatch(generationControlSource, /<h4>大模型<\/h4>/, '大模型不应继续作为生成选项内部子分类') assert.doesNotMatch(generationControlSource, /<h4>大模型<\/h4>/, '大模型不应继续作为生成选项内部子分类')
for (const label of ['大模型', '数据生成模型', '默认提示语', '质量筛选', '过滤低质量内容', '过滤过短内容', '最少字数']) { for (const label of ['大模型', '数据生成模型', '默认提示语', '输出类型', '标准回答', '思维链回答', '质量筛选', '过滤低质量内容', '过滤过短内容', '最少字数']) {
assert.ok(generationControlSource.includes(label), `生成控制界面缺少:${label}`) assert.ok(generationControlSource.includes(label), `生成控制界面缺少:${label}`)
} }
assert.match(generationControlSource, /filterable/, '数据生成模型下拉必须支持搜索') assert.match(generationControlSource, /filterable/, '数据生成模型下拉必须支持搜索')
assert.match(generationControlSource, /maxlength="500"/, '默认提示语缺少合理的长度限制') assert.match(generationControlSource, /maxlength="500"/, '默认提示语缺少合理的长度限制')
assert.match(generationControlSource, /aria-label="输出类型"/, '输出类型选项缺少可访问名称')
assert.match(generationControlSource, /&lt;think&gt;推理过程&lt;\/think&gt;/, '思维链选项没有说明最终保存格式')
assert.match(generationControlSource, /\.output-type-options[\s\S]*?min-height:\s*44px/, '输出类型选项的点击区域不足 44px')
assert.match(generationControlSource, /\.model-field\s*\{[\s\S]*?display:\s*flex[\s\S]*?flex-direction:\s*column/, '大模型字段没有使用稳定的纵向表单布局') assert.match(generationControlSource, /\.model-field\s*\{[\s\S]*?display:\s*flex[\s\S]*?flex-direction:\s*column/, '大模型字段没有使用稳定的纵向表单布局')
assert.match(generationControlSource, /\.generation-config-group\s*\{[\s\S]*?border:\s*1px solid #e2e5ec/, '大模型配置没有保留统一配置面板边框') assert.match(generationControlSource, /\.generation-config-group\s*\{[\s\S]*?border:\s*1px solid #e2e5ec/, '大模型配置没有保留统一配置面板边框')
assert.match(generationControlSource, /\.model-config-group\s*\{[\s\S]*?padding:\s*0[\s\S]*?border:\s*0/, '独立大模型步骤仍存在嵌套卡片挤压') assert.match(generationControlSource, /\.model-config-group\s*\{[\s\S]*?padding:\s*0[\s\S]*?border:\s*0/, '独立大模型步骤仍存在嵌套卡片挤压')
@@ -487,6 +491,7 @@ assert.match(
) )
assert.match(stateSource, /const DEFAULT_GENERATION_PROMPT\s*=\s*['"][^'"]{40,}['"]/, '大模型配置缺少可直接使用的默认提示语') assert.match(stateSource, /const DEFAULT_GENERATION_PROMPT\s*=\s*['"][^'"]{40,}['"]/, '大模型配置缺少可直接使用的默认提示语')
assert.equal((stateSource.match(/generationPrompt:\s*DEFAULT_GENERATION_PROMPT/g) || []).length, 2, '结构化与非结构化任务必须共用默认提示语') assert.equal((stateSource.match(/generationPrompt:\s*DEFAULT_GENERATION_PROMPT/g) || []).length, 2, '结构化与非结构化任务必须共用默认提示语')
assert.equal((stateSource.match(/outputType:\s*'standard'/g) || []).length, 2, '结构化与非结构化任务应默认生成标准回答')
assert.match(generationControlSource, /v-if="options\.qualityFilterEnabled"/, '质量规则没有随总开关渐进显示') assert.match(generationControlSource, /v-if="options\.qualityFilterEnabled"/, '质量规则没有随总开关渐进显示')
assert.match(generationControlSource, /v-if="options\.filterShortContent"/, '最少字数没有随短内容规则显示') assert.match(generationControlSource, /v-if="options\.filterShortContent"/, '最少字数没有随短内容规则显示')
assert.match(generationControlSource, /:min="1"[\s\S]*:max="1000"/, '最少字数缺少 1 到 1000 的边界限制') assert.match(generationControlSource, /:min="1"[\s\S]*:max="1000"/, '最少字数缺少 1 到 1000 的边界限制')
@@ -659,6 +664,7 @@ for (const [backendField, frontendField] of [
['semantic_enrichment', 'semanticEnrichment'], ['semantic_enrichment', 'semanticEnrichment'],
['generation_model_id', 'generationModelId'], ['generation_model_id', 'generationModelId'],
['generation_prompt', 'generationPrompt'], ['generation_prompt', 'generationPrompt'],
['output_type', 'outputType'],
['temperature', 'temperature'], ['temperature', 'temperature'],
['max_tokens', 'maxTokens'], ['max_tokens', 'maxTokens'],
['json_mode', 'jsonMode'], ['json_mode', 'jsonMode'],
@@ -702,6 +708,7 @@ assert.match(stateSource, /Object\.prototype\.hasOwnProperty\.call\(config, key\
assert.match(stateSource, /Number\.isFinite\(value\) \? value : fallback/, '配置反向映射没有保留合法数字 0') assert.match(stateSource, /Number\.isFinite\(value\) \? value : fallback/, '配置反向映射没有保留合法数字 0')
assert.match(stateSource, /qaPairsPerRow:\s*normalizeQaPairsGenerationCount\([\s\S]*?qa_pairs_per_row[\s\S]*?defaults\.qaPairsPerRow/, '结构化生成数量回填没有按 1 到 50 归一化') assert.match(stateSource, /qaPairsPerRow:\s*normalizeQaPairsGenerationCount\([\s\S]*?qa_pairs_per_row[\s\S]*?defaults\.qaPairsPerRow/, '结构化生成数量回填没有按 1 到 50 归一化')
assert.match(stateSource, /qaPairsPerChunk:\s*normalizeQaPairsGenerationCount\([\s\S]*?qa_pairs_per_chunk[\s\S]*?defaults\.qaPairsPerChunk/, '非结构化生成数量回填没有按 1 到 50 归一化') assert.match(stateSource, /qaPairsPerChunk:\s*normalizeQaPairsGenerationCount\([\s\S]*?qa_pairs_per_chunk[\s\S]*?defaults\.qaPairsPerChunk/, '非结构化生成数量回填没有按 1 到 50 归一化')
assert.match(stateSource, /outputType:\s*configValue\(config, 'output_type', defaults\.outputType\) === 'reasoning'[\s\S]*?\? 'reasoning'[\s\S]*?: 'standard'/, '输出类型没有从任务配置安全回填')
assert.match(stateSource, /createStructuredOptionsFromConfig/, '结构化配置缺少后端到表单的反向映射') assert.match(stateSource, /createStructuredOptionsFromConfig/, '结构化配置缺少后端到表单的反向映射')
assert.match(stateSource, /createUnstructuredOptionsFromConfig/, '非结构化配置缺少后端到表单的反向映射') assert.match(stateSource, /createUnstructuredOptionsFromConfig/, '非结构化配置缺少后端到表单的反向映射')
assert.match(stateSource, /configValue<unknown>\(config, 'preprocess_options', \[\]\)/, '历史任务缺少预处理配置时必须按后端空列表语义回填') assert.match(stateSource, /configValue<unknown>\(config, 'preprocess_options', \[\]\)/, '历史任务缺少预处理配置时必须按后端空列表语义回填')
@@ -778,6 +785,7 @@ for (const field of [
'datasetSplit', 'datasetSplit',
'generationModelId', 'generationModelId',
'generationPrompt', 'generationPrompt',
'outputType',
'qualityFilterEnabled', 'qualityFilterEnabled',
'filterLowQuality', 'filterLowQuality',
'filterShortContent', 'filterShortContent',

View File

@@ -4,6 +4,7 @@ export type DataProcessStatus = 'pending' | 'running' | 'completed' | 'failed' |
export type DataProcessType = 'structured' | 'unstructured' | 'external' export type DataProcessType = 'structured' | 'unstructured' | 'external'
export type DataProcessResultStatus = 'valid' | 'modified' | 'invalid' export type DataProcessResultStatus = 'valid' | 'modified' | 'invalid'
export type DataProcessSplit = 'train' | 'validation' | 'test' export type DataProcessSplit = 'train' | 'validation' | 'test'
export type DataProcessOutputType = 'standard' | 'reasoning'
export interface DataProcessPage<T> { export interface DataProcessPage<T> {
items: T[] items: T[]
@@ -20,6 +21,7 @@ export interface DataProcessDatasetSplit {
export type DataProcessConfig = Record<string, unknown> & { export type DataProcessConfig = Record<string, unknown> & {
dataset_split?: DataProcessDatasetSplit dataset_split?: DataProcessDatasetSplit
output_type?: DataProcessOutputType
} }
export interface DataProcessTask { export interface DataProcessTask {

View File

@@ -195,13 +195,13 @@ function toBackendConfig(): DataProcessConfig {
const options = processType.value === 'unstructured' const options = processType.value === 'unstructured'
? unstructuredOptions.value ? unstructuredOptions.value
: structuredOptions.value : structuredOptions.value
const common = { const common = {
preprocess_options: [...options.preprocessOptions], preprocess_options: [...options.preprocessOptions],
semantic_enrichment: options.semanticEnrichment, semantic_enrichment: options.semanticEnrichment,
dataset_split: { ...options.datasetSplit }, dataset_split: { ...options.datasetSplit },
generation_model_id: options.generationModelId, generation_model_id: options.generationModelId,
generation_prompt: options.generationPrompt, generation_prompt: options.generationPrompt,
output_type: options.outputType,
temperature: options.temperature, temperature: options.temperature,
max_tokens: options.maxTokens, max_tokens: options.maxTokens,
json_mode: options.jsonMode, json_mode: options.jsonMode,

View File

@@ -70,6 +70,7 @@ const configLabelMap: Record<string, string> = {
dataset_split: '数据集划分', dataset_split: '数据集划分',
generation_model_id: '数据生成模型', generation_model_id: '数据生成模型',
generation_prompt: '生成提示语', generation_prompt: '生成提示语',
output_type: '输出类型',
temperature: '生成温度', temperature: '生成温度',
max_tokens: '最大输出长度', max_tokens: '最大输出长度',
json_mode: 'JSON 输出', json_mode: 'JSON 输出',
@@ -245,6 +246,9 @@ function formatConfigValue(key: string, value: unknown) {
if (key === 'chunk_method' && typeof value === 'string') { if (key === 'chunk_method' && typeof value === 'string') {
return chunkMethodLabelMap[value] || value return chunkMethodLabelMap[value] || value
} }
if (key === 'output_type') {
return value === 'reasoning' ? '思维链回答' : '标准回答'
}
if (key === 'dataset_split' && value && typeof value === 'object') { if (key === 'dataset_split' && value && typeof value === 'object') {
const split = value as Partial<DataProcessDatasetSplit> const split = value as Partial<DataProcessDatasetSplit>
return `训练集 ${split.train ?? 0}% / 验证集 ${split.validation ?? 0}% / 测试集 ${split.test ?? 0}%` return `训练集 ${split.train ?? 0}% / 验证集 ${split.validation ?? 0}% / 测试集 ${split.test ?? 0}%`

View File

@@ -30,6 +30,10 @@ function updateQualityRules(value: Array<string | number>) {
}) })
} }
function updateOutputType(value: string | number | boolean | undefined) {
updateField('outputType', value === 'reasoning' ? 'reasoning' : 'standard')
}
const selectedQualityRules = () => [ const selectedQualityRules = () => [
props.options.filterLowQuality ? 'low_quality' : '', props.options.filterLowQuality ? 'low_quality' : '',
props.options.filterShortContent ? 'short_content' : '', props.options.filterShortContent ? 'short_content' : '',
@@ -106,6 +110,28 @@ function modelMeta(model: ModelItem) {
</div> </div>
</div> </div>
<div class="model-field output-type-field">
<div class="field-copy">
<strong>输出类型</strong>
<small>控制答案是否包含可用于推理模型训练的思维链内容</small>
</div>
<el-radio-group
class="output-type-options"
:model-value="options.outputType"
aria-label="输出类型"
@update:model-value="updateOutputType"
>
<el-radio-button value="standard">标准回答</el-radio-button>
<el-radio-button value="reasoning">思维链回答</el-radio-button>
</el-radio-group>
<p class="output-type-hint">
<template v-if="options.outputType === 'reasoning'">
生成结果将按 <code>&lt;think&gt;推理过程&lt;/think&gt;</code>
</template>
<template v-else>仅保存最终答案不包含推理过程</template>
</p>
</div>
<div class="advanced-settings-grid"> <div class="advanced-settings-grid">
<label class="config-field"> <label class="config-field">
<span class="config-field-label">生成温度 (Temperature)</span> <span class="config-field-label">生成温度 (Temperature)</span>
@@ -304,6 +330,31 @@ function modelMeta(model: ModelItem) {
} }
} }
.output-type-options {
align-self: flex-start;
:deep(.el-radio-button__inner) {
min-height: 44px;
padding: 13px 22px;
}
}
.output-type-hint {
min-height: 20px;
margin: 0;
color: #667085;
font-size: 12px;
line-height: 1.6;
code {
padding: 2px 5px;
color: #5b50f2;
background: #f0f0ff;
border-radius: 4px;
font-family: inherit;
}
}
.advanced-settings-grid { .advanced-settings-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -9,7 +9,7 @@ import type {
} from './types' } from './types'
import { normalizeQaPairsGenerationCount } from './types' import { normalizeQaPairsGenerationCount } from './types'
export const DEFAULT_GENERATION_PROMPT = '你是一名专业的数据生成助手。请根据输入内容生成准确、完整、可直接用于模型训练的问答数据。仅输出符合目标格式的内容,答案应事实清晰、语言自然,不要添加分析过程、说明或无关内容。' export const DEFAULT_GENERATION_PROMPT = '你是一名专业的数据生成助手。请根据输入内容生成准确、完整、可直接用于模型训练的问答数据。仅输出符合所选输出类型和目标格式的内容,答案应事实清晰、语言自然,不要添加无关说明。'
export function createDefaultStructuredOptions(): StructuredProcessOptions { export function createDefaultStructuredOptions(): StructuredProcessOptions {
return { return {
@@ -19,6 +19,7 @@ export function createDefaultStructuredOptions(): StructuredProcessOptions {
datasetSplit: { train: 80, validation: 10, test: 10 }, datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: '', generationModelId: '',
generationPrompt: DEFAULT_GENERATION_PROMPT, generationPrompt: DEFAULT_GENERATION_PROMPT,
outputType: 'standard',
temperature: 0.7, temperature: 0.7,
maxTokens: 1024, maxTokens: 1024,
jsonMode: false, jsonMode: false,
@@ -52,6 +53,7 @@ export function createDefaultUnstructuredOptions(): UnstructuredProcessOptions {
datasetSplit: { train: 80, validation: 10, test: 10 }, datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: '', generationModelId: '',
generationPrompt: DEFAULT_GENERATION_PROMPT, generationPrompt: DEFAULT_GENERATION_PROMPT,
outputType: 'standard',
temperature: 0.7, temperature: 0.7,
maxTokens: 1024, maxTokens: 1024,
jsonMode: false, jsonMode: false,
@@ -93,6 +95,9 @@ function generationOptionsFromConfig(
return { return {
generationModelId: configValue(config, 'generation_model_id', defaults.generationModelId), generationModelId: configValue(config, 'generation_model_id', defaults.generationModelId),
generationPrompt: String(configValue(config, 'generation_prompt', defaults.generationPrompt)), generationPrompt: String(configValue(config, 'generation_prompt', defaults.generationPrompt)),
outputType: configValue(config, 'output_type', defaults.outputType) === 'reasoning'
? 'reasoning'
: 'standard',
temperature: numberValue(config, 'temperature', defaults.temperature), temperature: numberValue(config, 'temperature', defaults.temperature),
maxTokens: numberValue(config, 'max_tokens', defaults.maxTokens), maxTokens: numberValue(config, 'max_tokens', defaults.maxTokens),
jsonMode: Boolean(configValue(config, 'json_mode', defaults.jsonMode)), jsonMode: Boolean(configValue(config, 'json_mode', defaults.jsonMode)),
@@ -218,6 +223,7 @@ export function generationAffectingOptionsFor(
datasetSplit: options.datasetSplit, datasetSplit: options.datasetSplit,
generationModelId: options.generationModelId, generationModelId: options.generationModelId,
generationPrompt: options.generationPrompt, generationPrompt: options.generationPrompt,
outputType: options.outputType,
temperature: options.temperature, temperature: options.temperature,
maxTokens: options.maxTokens, maxTokens: options.maxTokens,
jsonMode: options.jsonMode, jsonMode: options.jsonMode,

View File

@@ -1,4 +1,4 @@
import type { DataProcessPreviewFileStatus } from '@/types/dataProcess' import type { DataProcessOutputType, DataProcessPreviewFileStatus } from '@/types/dataProcess'
export type ProcessType = 'structured' | 'unstructured' | 'external' export type ProcessType = 'structured' | 'unstructured' | 'external'
@@ -32,6 +32,7 @@ export interface DatasetSplitOptions {
export interface GenerationControlOptions { export interface GenerationControlOptions {
generationModelId: string | number | '' generationModelId: string | number | ''
generationPrompt: string generationPrompt: string
outputType: DataProcessOutputType
temperature: number temperature: number
maxTokens: number maxTokens: number
jsonMode: boolean jsonMode: boolean