feat(data-process): 支持思维链输出类型
This commit is contained in:
@@ -1405,6 +1405,40 @@ def test_stale_generation_worker_cannot_overwrite_new_run(monkeypatch: Any) -> N
|
||||
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:
|
||||
client, _, _ = make_client(tmp_path)
|
||||
task_id = client.post(
|
||||
|
||||
@@ -86,6 +86,119 @@ def test_generate_model_records_uses_prompt_auth_and_stable_split() -> None:
|
||||
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:
|
||||
client = httpx.Client(
|
||||
transport=httpx.MockTransport(
|
||||
@@ -267,3 +380,15 @@ def test_generate_model_records_rejects_out_of_range_count(
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.modules.data_process.store import (
|
||||
InvalidStateError,
|
||||
_decode_row,
|
||||
_preview_config_changed,
|
||||
_reasoning_output_is_valid,
|
||||
_source_storage_descriptor,
|
||||
)
|
||||
|
||||
@@ -114,14 +115,25 @@ class _PublishConnection:
|
||||
)
|
||||
if normalized.startswith("INSERT INTO dataset_records"):
|
||||
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()
|
||||
|
||||
|
||||
class _PublishStore(DataProcessStore):
|
||||
def __init__(self, conn: _PublishConnection):
|
||||
def __init__(
|
||||
self,
|
||||
conn: _PublishConnection,
|
||||
task_config: dict[str, Any] | None = None,
|
||||
):
|
||||
self._conn = conn
|
||||
self._task_config = task_config or {}
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[_PublishConnection]:
|
||||
@@ -135,7 +147,7 @@ class _PublishStore(DataProcessStore):
|
||||
"id": task_id,
|
||||
"status": "completed",
|
||||
"description": "",
|
||||
"config": {},
|
||||
"config": self._task_config,
|
||||
"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",
|
||||
"instruction": f"问题 {index}",
|
||||
"input": "",
|
||||
"output": f"答案 {index}",
|
||||
"output": (
|
||||
"<think>\n先读取制度条款。\n</think>\n答案 0"
|
||||
if index == 0
|
||||
else f"答案 {index}"
|
||||
),
|
||||
"preview_item_id": f"preview-{index}",
|
||||
}
|
||||
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
|
||||
}
|
||||
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 len(published["datasets"]) == 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
|
||||
|
||||
|
||||
@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:
|
||||
task_id = "dpt_task"
|
||||
source_file_id = "dpsf_source"
|
||||
|
||||
Reference in New Issue
Block a user