feat(data-process): 支持单项生成五十条数据

This commit is contained in:
caoxiaozhu
2026-07-27 09:11:51 +08:00
parent 25d75f40c7
commit 9428c6b785
7 changed files with 400 additions and 86 deletions

View File

@@ -34,6 +34,8 @@ from openpyxl.utils.cell import range_boundaries
from pptx import Presentation from pptx import Presentation
from pypdf import PdfReader from pypdf import PdfReader
from app.modules.data_process.constants import MAX_QA_PAIRS_PER_ITEM
TextFormat = Literal[ TextFormat = Literal[
"json", "json",
"jsonl", "jsonl",
@@ -2659,8 +2661,10 @@ def generate_standard_records(
LLM 响应解析后的统一落库步骤。 LLM 响应解析后的统一落库步骤。
""" """
if not 1 <= qa_pairs_per_item <= 5: if not 1 <= qa_pairs_per_item <= MAX_QA_PAIRS_PER_ITEM:
raise ValueError("qa_pairs_per_item must be in [1, 5]") raise ValueError(
f"qa_pairs_per_item must be in [1, {MAX_QA_PAIRS_PER_ITEM}]"
)
prefixes = ( prefixes = (
"请结合实际情况说明:", "请结合实际情况说明:",
"请用通俗易懂的方式说明:", "请用通俗易懂的方式说明:",
@@ -2677,7 +2681,13 @@ def generate_standard_records(
variant_instruction = instruction variant_instruction = instruction
if variant_index: if variant_index:
if semantic_enrichment: if semantic_enrichment:
variant_instruction = f"{prefixes[variant_index]}{instruction}" prefix = prefixes[variant_index % len(prefixes)]
if variant_index >= len(prefixes):
prefix = (
f"{prefix.removesuffix('')}"
f"(问法 {variant_index + 1}"
)
variant_instruction = f"{prefix}{instruction}"
else: else:
variant_instruction = f"{instruction}(问法 {variant_index + 1})" variant_instruction = f"{instruction}(问法 {variant_index + 1})"
raw_id = f"{preview_id}:{variant_index + 1}" raw_id = f"{preview_id}:{variant_index + 1}"

View File

@@ -0,0 +1,7 @@
"""数据处理模块的共享限制。"""
MAX_QA_PAIRS_PER_ITEM = 50
MODEL_GENERATION_BATCH_SIZE = 10
__all__ = ["MAX_QA_PAIRS_PER_ITEM", "MODEL_GENERATION_BATCH_SIZE"]

View File

@@ -12,6 +12,10 @@ from urllib.parse import urlsplit, urlunsplit
import httpx import httpx
from app.modules.data_process.algorithms import normalize_text, stable_split_assignments from app.modules.data_process.algorithms import normalize_text, stable_split_assignments
from app.modules.data_process.constants import (
MAX_QA_PAIRS_PER_ITEM,
MODEL_GENERATION_BATCH_SIZE,
)
class ModelGenerationError(ValueError): class ModelGenerationError(ValueError):
@@ -96,10 +100,20 @@ def _result_items(payload: Any) -> list[Mapping[str, Any]]:
return items return items
def _prompt_messages(prompt: str, content: str, count: int) -> list[dict[str, str]]: def _prompt_messages(
prompt: str,
content: str,
count: int,
*,
start_index: int,
total_count: int,
) -> list[dict[str, str]]:
end_index = start_index + count - 1
schema_instruction = ( schema_instruction = (
f"必须只返回 JSON 对象,格式为 {{\"items\":[{{\"instruction\":\"...\"," f"必须只返回 JSON 对象,格式为 {{\"items\":[{{\"instruction\":\"...\","
f"\"input\":\"...\",\"output\":\"...\"}}]}}items 必须包含 {count} 条。" f"\"input\":\"...\",\"output\":\"...\"}}]}}items 必须包含 {count} 条。"
f"这是总计 {total_count} 条中的第 {start_index}-{end_index} 条,"
"各条必须使用不同的提问角度和表述,避免重复。"
"instruction 和 output 不得为空,不要输出 Markdown 代码围栏或分析过程。" "instruction 和 output 不得为空,不要输出 Markdown 代码围栏或分析过程。"
) )
base_prompt = ( base_prompt = (
@@ -131,11 +145,14 @@ def generate_model_records(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""调用 OpenAI 兼容接口,将预览切片生成标准训练记录。 """调用 OpenAI 兼容接口,将预览切片生成标准训练记录。
单条调用失败会产生可人工修复的 invalid 结果,不会丢弃整批任务。 每个切片按安全批次调用模型;失败批次会产生一条可人工修复的
invalid 结果,已经成功的批次不会丢失。
""" """
if not 1 <= qa_pairs_per_item <= 5: if not 1 <= qa_pairs_per_item <= MAX_QA_PAIRS_PER_ITEM:
raise ModelGenerationError("qa_pairs_per_item must be in [1, 5]") raise ModelGenerationError(
f"qa_pairs_per_item must be in [1, {MAX_QA_PAIRS_PER_ITEM}]"
)
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:
@@ -161,12 +178,21 @@ def generate_model_records(
content = normalize_text( content = normalize_text(
str(item.get("edited_content") or item.get("original_content") or "") str(item.get("edited_content") or item.get("original_content") or "")
) )
for batch_offset in range(0, qa_pairs_per_item, MODEL_GENERATION_BATCH_SIZE):
batch_count = min(
MODEL_GENERATION_BATCH_SIZE,
qa_pairs_per_item - batch_offset,
)
batch_start = batch_offset + 1
batch_end = batch_offset + batch_count
request_payload: dict[str, Any] = { request_payload: dict[str, Any] = {
"model": model_name, "model": model_name,
"messages": _prompt_messages( "messages": _prompt_messages(
str(config.get("generation_prompt") or ""), str(config.get("generation_prompt") or ""),
content, content,
qa_pairs_per_item, batch_count,
start_index=batch_start,
total_count=qa_pairs_per_item,
), ),
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens, "max_tokens": max_tokens,
@@ -178,27 +204,51 @@ def generate_model_records(
generated_items: list[Mapping[str, Any]] | None = None generated_items: list[Mapping[str, Any]] | None = None
for _ in range(retries + 1): for _ in range(retries + 1):
try: try:
response = http_client.post(endpoint, headers=headers, json=request_payload) response = http_client.post(
endpoint,
headers=headers,
json=request_payload,
)
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("model response body must be a JSON object") raise ModelGenerationError(
generated_items = _result_items(_json_payload(_message_content(body))) "model response body must be a JSON object"
)
candidate_items = _result_items(
_json_payload(_message_content(body))
)
if len(candidate_items) < batch_count:
raise ModelGenerationError(
"model response contains fewer result objects than requested: "
f"expected {batch_count}, got {len(candidate_items)}"
)
generated_items = candidate_items
break break
except (httpx.HTTPError, json.JSONDecodeError, ModelGenerationError) as exc: except (
httpx.HTTPError,
json.JSONDecodeError,
ModelGenerationError,
) as exc:
last_error = exc last_error = exc
if generated_items is None: if generated_items is None:
error_message = str(last_error or "model generation failed")[:2000] error_message = str(last_error or "model generation failed")[:2000]
result_id = f"result_{hashlib.sha256(f'{preview_id}:error'.encode()).hexdigest()[:16]}" failure_instruction = (
f"模型生成失败,请人工补充(第 {batch_start}-{batch_end} 条)"
)
result_id = (
"result_"
f"{hashlib.sha256(f'{preview_id}:error:{batch_start}'.encode()).hexdigest()[:16]}"
)
results.append( results.append(
{ {
"id": result_id, "id": result_id,
"preview_item_id": preview_id, "preview_item_id": preview_id,
"instruction": "模型生成失败,请人工补充", "instruction": failure_instruction,
"input": content, "input": content,
"output": "", "output": "",
"original_instruction": "模型生成失败,请人工补充", "original_instruction": failure_instruction,
"original_input": content, "original_input": content,
"original_output": "", "original_output": "",
"status": "invalid", "status": "invalid",
@@ -206,13 +256,16 @@ def generate_model_records(
"split": "train", "split": "train",
} }
) )
if on_progress:
on_progress(item_index + 1, total_items)
continue continue
for variant_index, value in enumerate(generated_items[:qa_pairs_per_item]): for batch_index, value in enumerate(generated_items[:batch_count]):
instruction = normalize_text(str(value.get("instruction") or value.get("question") or "")) variant_index = batch_offset + batch_index
input_text = normalize_text(str(value.get("input") or value.get("context") or "")) instruction = normalize_text(
str(value.get("instruction") or value.get("question") or "")
)
input_text = normalize_text(
str(value.get("input") or value.get("context") or "")
)
output = normalize_text( output = normalize_text(
str( str(
value.get("output") value.get("output")
@@ -222,7 +275,9 @@ def generate_model_records(
) )
) )
raw_id = f"{preview_id}:{variant_index + 1}:{instruction}:{output}" raw_id = f"{preview_id}:{variant_index + 1}:{instruction}:{output}"
result_id = f"result_{hashlib.sha256(raw_id.encode()).hexdigest()[:16]}" result_id = (
f"result_{hashlib.sha256(raw_id.encode()).hexdigest()[:16]}"
)
valid = bool(instruction and output) valid = bool(instruction and output)
results.append( results.append(
{ {
@@ -235,7 +290,11 @@ 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": None if valid else "model result is missing instruction or output", "error": (
None
if valid
else "model result is missing instruction or output"
),
"split": "train", "split": "train",
} }
) )

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.modules.data_process.constants import MAX_QA_PAIRS_PER_ITEM
def _config_value(config: dict[str, Any], snake_name: str, camel_name: str, default: Any) -> Any: def _config_value(config: dict[str, Any], snake_name: str, camel_name: str, default: Any) -> Any:
if snake_name in config: if snake_name in config:
@@ -89,8 +91,14 @@ def _validate_process_config(config: dict[str, Any]) -> None:
pairs = _config_value(config, snake_name, camel_name, None) pairs = _config_value(config, snake_name, camel_name, None)
if pairs is None: if pairs is None:
continue continue
if isinstance(pairs, bool) or not isinstance(pairs, int) or not 1 <= pairs <= 5: if (
raise ValueError(f"{snake_name} must be an integer in [1, 5]") isinstance(pairs, bool)
or not isinstance(pairs, int)
or not 1 <= pairs <= MAX_QA_PAIRS_PER_ITEM
):
raise ValueError(
f"{snake_name} must be an integer in [1, {MAX_QA_PAIRS_PER_ITEM}]"
)
class DataProcessStatus(StrEnum): class DataProcessStatus(StrEnum):

View File

@@ -21,7 +21,6 @@ from app.modules.data_process.algorithms import (
detect_document_structure, detect_document_structure,
detect_pdf_document_noise, detect_pdf_document_noise,
detect_text_format, detect_text_format,
estimate_token_count,
extract_pdf_page_texts, extract_pdf_page_texts,
extract_structured_records, extract_structured_records,
generate_standard_records, generate_standard_records,
@@ -748,3 +747,26 @@ def test_generate_standard_records_supports_json_qa_and_stable_variants() -> Non
split={"train": 100, "validation": 0, "test": 0}, split={"train": 100, "validation": 0, "test": 0},
split_seed="task-1", split_seed="task-1",
) )
def test_generate_standard_records_supports_fifty_unique_semantic_variants() -> None:
records = generate_standard_records(
[{"id": "preview-50", "edited_content": "问:如何操作?\n答:按步骤操作。"}],
qa_pairs_per_item=50,
semantic_enrichment=True,
split={"train": 100, "validation": 0, "test": 0},
split_seed="task-50",
)
assert len(records) == 50
assert len({record["id"] for record in records}) == 50
assert len({record["instruction"] for record in records}) == 50
assert all(record["status"] == "valid" for record in records)
@pytest.mark.parametrize("qa_pairs_per_item", [0, 51])
def test_generate_standard_records_rejects_out_of_range_count(
qa_pairs_per_item: int,
) -> None:
with pytest.raises(ValueError, match=r"\[1, 50\]"):
generate_standard_records([], qa_pairs_per_item=qa_pairs_per_item)

View File

@@ -945,6 +945,47 @@ def test_config_validation_and_stop_state(tmp_path: Path) -> None:
assert stopped.json()["data"]["status"] == "stopped" assert stopped.json()["data"]["status"] == "stopped"
@pytest.mark.parametrize("config_key", ["qa_pairs_per_row", "qa_pairs_per_chunk"])
@pytest.mark.parametrize("count", [1, 50])
def test_qa_pair_config_accepts_supported_boundaries(
tmp_path: Path,
config_key: str,
count: int,
) -> None:
client, _, _ = make_client(tmp_path)
response = client.post(
"/modelTF/data-process",
json={
"name": "问答数量边界",
"process_type": "unstructured",
"config": {config_key: count},
},
)
assert response.status_code == 200
@pytest.mark.parametrize("config_key", ["qa_pairs_per_row", "qa_pairs_per_chunk"])
@pytest.mark.parametrize("count", [0, 51])
def test_qa_pair_config_rejects_out_of_range_boundaries(
tmp_path: Path,
config_key: str,
count: int,
) -> None:
client, _, _ = make_client(tmp_path)
response = client.post(
"/modelTF/data-process",
json={
"name": "问答数量越界",
"process_type": "unstructured",
"config": {config_key: count},
},
)
assert response.status_code == 422
assert "[1, 50]" in response.text
def test_upload_batch_is_atomic_and_empty_files_are_rejected(tmp_path: Path) -> None: def test_upload_batch_is_atomic_and_empty_files_are_rejected(tmp_path: Path) -> None:
client, store, storage = make_client(tmp_path) client, store, storage = make_client(tmp_path)
task_id = client.post( task_id = client.post(

View File

@@ -3,8 +3,13 @@ from __future__ import annotations
import json import json
import httpx import httpx
import pytest
from app.modules.data_process.generation import chat_completions_url, generate_model_records from app.modules.data_process.generation import (
ModelGenerationError,
chat_completions_url,
generate_model_records,
)
def test_chat_completions_url_accepts_host_base_and_complete_url() -> None: def test_chat_completions_url_accepts_host_base_and_complete_url() -> None:
@@ -100,3 +105,165 @@ def test_generate_model_records_keeps_partial_failure_for_manual_repair() -> Non
assert len(records) == 1 assert len(records) == 1
assert records[0]["status"] == "invalid" assert records[0]["status"] == "invalid"
assert records[0]["error"] assert records[0]["error"]
def test_generate_model_records_batches_fifty_results_with_unique_ids() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
batch_start = (len(requests) - 1) * 10 + 1
batch_end = batch_start + 9
payload = json.loads(request.content)
system_prompt = payload["messages"][0]["content"]
assert "items 必须包含 10 条" in system_prompt
assert f"{batch_start}-{batch_end}" in system_prompt
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"content": json.dumps(
{
"items": [
{
"instruction": "同一问题",
"input": "来源正文",
"output": "同一答案",
}
for _ in range(batch_start, batch_end + 1)
]
},
ensure_ascii=False,
)
}
}
]
},
)
records = generate_model_records(
[{"id": "preview-50", "edited_content": "来源正文"}],
model={"name": "model", "api_url": "https://model.example/v1"},
config={},
task_id="task-50",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=50,
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
assert len(requests) == 5
assert len(records) == 50
assert len({record["id"] for record in records}) == 50
assert {record["instruction"] for record in records} == {"同一问题"}
assert all(record["status"] == "valid" for record in records)
def test_generate_model_records_preserves_successful_batches_when_one_fails() -> None:
request_count = 0
def handler(_: httpx.Request) -> httpx.Response:
nonlocal request_count
request_count += 1
if request_count == 2:
return httpx.Response(500)
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"content": json.dumps(
{
"items": [
{
"instruction": f"问题 {index}",
"output": f"答案 {index}",
}
for index in range(1, 11)
]
},
ensure_ascii=False,
)
}
}
]
},
)
records = generate_model_records(
[{"id": "preview-partial", "edited_content": "来源正文"}],
model={"name": "model", "api_url": "https://model.example/v1"},
config={"generation_retries": 0},
task_id="task-partial",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=20,
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
assert len(records) == 11
assert sum(record["status"] == "valid" for record in records) == 10
failed = next(record for record in records if record["status"] == "invalid")
assert "第 11-20 条" in failed["instruction"]
assert len({record["id"] for record in records}) == len(records)
def test_generate_model_records_retries_short_batch_then_marks_it_invalid() -> None:
request_count = 0
def handler(_: httpx.Request) -> httpx.Response:
nonlocal request_count
request_count += 1
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"content": json.dumps(
{
"items": [
{
"instruction": "只有一条",
"output": "不足本批要求数量",
}
]
},
ensure_ascii=False,
)
}
}
]
},
)
records = generate_model_records(
[{"id": "preview-short", "edited_content": "来源正文"}],
model={"name": "model", "api_url": "https://model.example/v1"},
config={"generation_retries": 1},
task_id="task-short",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=10,
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
assert request_count == 2
assert len(records) == 1
assert records[0]["status"] == "invalid"
assert "expected 10, got 1" in records[0]["error"]
@pytest.mark.parametrize("qa_pairs_per_item", [0, 51])
def test_generate_model_records_rejects_out_of_range_count(
qa_pairs_per_item: int,
) -> None:
with pytest.raises(ModelGenerationError, match=r"\[1, 50\]"):
generate_model_records(
[],
model={"name": "model", "api_url": "https://model.example/v1"},
config={},
task_id="task-invalid",
split={"train": 100, "validation": 0, "test": 0},
qa_pairs_per_item=qa_pairs_per_item,
)