diff --git a/backend/app/api/v1/endpoints/data_process.py b/backend/app/api/v1/endpoints/data_process.py index 032619b..98a52dc 100644 --- a/backend/app/api/v1/endpoints/data_process.py +++ b/backend/app/api/v1/endpoints/data_process.py @@ -566,6 +566,9 @@ def _run_generation( config, "generation_prompt", "generationPrompt", "" ), "output_type": output_type, + "reasoning_detail": _value( + config, "reasoning_detail", "reasoningDetail", "normal" + ), "max_tokens": _value(config, "max_tokens", "maxTokens", 1024), "json_mode": _value(config, "json_mode", "jsonMode", False), } diff --git a/backend/app/modules/data_process/generation.py b/backend/app/modules/data_process/generation.py index cc6ce7e..e51c29b 100644 --- a/backend/app/modules/data_process/generation.py +++ b/backend/app/modules/data_process/generation.py @@ -25,6 +25,12 @@ class ModelGenerationError(ValueError): OUTPUT_TYPE_STANDARD = "standard" OUTPUT_TYPE_REASONING = "reasoning" SUPPORTED_OUTPUT_TYPES = {OUTPUT_TYPE_STANDARD, OUTPUT_TYPE_REASONING} +REASONING_DETAIL_NORMAL = "normal" +REASONING_DETAIL_DETAILED = "detailed" +SUPPORTED_REASONING_DETAILS = { + REASONING_DETAIL_NORMAL, + REASONING_DETAIL_DETAILED, +} def chat_completions_url(value: str) -> str: @@ -122,19 +128,32 @@ def _prompt_messages( start_index: int, total_count: int, output_type: str, + reasoning_detail: str, ) -> list[dict[str, str]]: end_index = start_index + count - 1 if output_type == OUTPUT_TYPE_REASONING: schema = '{"items":[{"instruction":"...","input":"...","reasoning":"...","answer":"..."}]}' + detail_rule = ( + "推理详细程度为“详细”:完整展开问题条件、来源依据、中间计算或推导," + "并在得出答案前核对结论;每一步都必须能从来源内容中验证。" + if reasoning_detail == REASONING_DETAIL_DETAILED + else + "推理详细程度为“普通”:只保留得出答案所需的关键依据和必要步骤," + "避免冗长复述、套话和无依据扩展。" + ) output_rule = ( + "你正在生成用于训练推理模型的思维链数据,而不是普通问答数据。" "instruction、reasoning 和 answer 均不得为空;reasoning 必须是基于来源内容、" - "可核对且简洁的推理步骤,answer 只写最终答案。" + f"可核对的推理过程,answer 只写最终答案。{detail_rule}" "这是思维链输出模式,即使其他提示语要求省略分析,也不得省略 reasoning。" "不要自行添加 标签,系统会在保存时统一组装。" ) else: schema = '{"items":[{"instruction":"...","input":"...","output":"..."}]}' - output_rule = "instruction 和 output 不得为空,不要输出分析过程。" + output_rule = ( + "你正在生成标准监督微调问答数据。instruction 和 output 不得为空;" + "output 只写最终答案,禁止输出分析、推理过程或 标签。" + ) schema_instruction = ( f"必须只返回 JSON 对象,格式为 {schema};items 必须包含 {count} 条。" f"这是总计 {total_count} 条中的第 {start_index}-{end_index} 条," @@ -176,6 +195,13 @@ def generate_model_records( 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)}") + reasoning_detail = str( + config.get("reasoning_detail") or REASONING_DETAIL_NORMAL + ).strip().lower() + if reasoning_detail not in SUPPORTED_REASONING_DETAILS: + raise ModelGenerationError( + f"reasoning_detail must be one of {sorted(SUPPORTED_REASONING_DETAILS)}" + ) endpoint = chat_completions_url(str(model.get("api_url") or "")) model_name = str(model.get("online_model_name") or model.get("name") or "").strip() if not model_name: @@ -217,6 +243,7 @@ def generate_model_records( start_index=batch_start, total_count=qa_pairs_per_item, output_type=output_type, + reasoning_detail=reasoning_detail, ), "temperature": temperature, "max_tokens": max_tokens, diff --git a/backend/app/modules/data_process/store.py b/backend/app/modules/data_process/store.py index b6d584a..3e469ce 100644 --- a/backend/app/modules/data_process/store.py +++ b/backend/app/modules/data_process/store.py @@ -95,6 +95,17 @@ def _task_output_type(task: dict[str, Any]) -> str: return str(config.get("output_type") or config.get("outputType") or "standard") +def _task_reasoning_detail(task: dict[str, Any]) -> str: + config = _json_value(task.get("config"), {}) + if not isinstance(config, dict): + return "normal" + return str( + config.get("reasoning_detail") + or config.get("reasoningDetail") + or "normal" + ) + + def _reasoning_output_is_valid(value: Any) -> bool: match = re.fullmatch( r"\s*\s*(?P[\s\S]*?)\s*\s*(?P[\s\S]+?)\s*", @@ -1773,6 +1784,7 @@ class DataProcessStore: "storage_backend": "database", "source_task_id": task_id, "output_type": _task_output_type(task), + "reasoning_detail": _task_reasoning_detail(task), "source_file_ids": [item["id"] for item in self._source_ids(conn, task_id)], "source_result_ids": source_result_ids, "format": payload.get("format") or "alpaca_jsonl", diff --git a/backend/tests/test_data_process_generation.py b/backend/tests/test_data_process_generation.py index 303fdca..fa109c3 100644 --- a/backend/tests/test_data_process_generation.py +++ b/backend/tests/test_data_process_generation.py @@ -33,6 +33,8 @@ def test_generate_model_records_uses_prompt_auth_and_stable_split() -> None: assert payload["model"] == "qwen-plus" assert payload["response_format"] == {"type": "json_object"} assert "客户反馈页面加载慢" in payload["messages"][1]["content"] + assert "你正在生成标准监督微调问答数据" in payload["messages"][0]["content"] + assert "禁止输出分析、推理过程" in payload["messages"][0]["content"] return httpx.Response( 200, json={ @@ -91,6 +93,8 @@ def test_generate_model_records_builds_reasoning_output_with_think_tags() -> Non payload = json.loads(request.content) system_prompt = payload["messages"][0]["content"] assert '"reasoning":"...","answer":"..."' in system_prompt + assert "你正在生成用于训练推理模型的思维链数据" in system_prompt + assert "推理详细程度为“普通”" in system_prompt assert "系统会在保存时统一组装" in system_prompt content = "模型接口自己的分析" + json.dumps( { @@ -126,6 +130,49 @@ def test_generate_model_records_builds_reasoning_output_with_think_tags() -> Non ) +def test_generate_model_records_uses_detailed_reasoning_instruction() -> None: + def handler(request: httpx.Request) -> httpx.Response: + system_prompt = json.loads(request.content)["messages"][0]["content"] + assert "推理详细程度为“详细”" in system_prompt + assert "完整展开问题条件、来源依据、中间计算或推导" in system_prompt + return httpx.Response( + 200, + json={ + "choices": [ + { + "message": { + "content": json.dumps( + { + "items": [ + { + "instruction": "计算报销总额", + "reasoning": "条件为交通费 30 元和餐费 20 元。分别核对后相加,30 + 20 = 50。", + "answer": "报销总额为 50 元。", + } + ] + }, + ensure_ascii=False, + ) + } + } + ] + }, + ) + + records = generate_model_records( + [{"id": "preview-detailed", "edited_content": "交通费 30 元,餐费 20 元"}], + model={"name": "model", "api_url": "https://model.example/v1"}, + config={"output_type": "reasoning", "reasoning_detail": "detailed"}, + task_id="task-detailed", + 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 "分别核对后相加" in records[0]["output"] + + def test_generate_model_records_marks_reasoning_without_reasoning_field_invalid() -> None: response = { "choices": [ @@ -392,3 +439,15 @@ def test_generate_model_records_rejects_unknown_output_type() -> None: split={"train": 100, "validation": 0, "test": 0}, qa_pairs_per_item=1, ) + + +def test_generate_model_records_rejects_unknown_reasoning_detail() -> None: + with pytest.raises(ModelGenerationError, match="reasoning_detail"): + generate_model_records( + [], + model={"name": "model", "api_url": "https://model.example/v1"}, + config={"output_type": "reasoning", "reasoning_detail": "verbose"}, + task_id="task-invalid-reasoning-detail", + split={"train": 100, "validation": 0, "test": 0}, + qa_pairs_per_item=1, + ) diff --git a/frontend/scripts/regression-data-process-detail.mjs b/frontend/scripts/regression-data-process-detail.mjs index be872d4..4258dd1 100644 --- a/frontend/scripts/regression-data-process-detail.mjs +++ b/frontend/scripts/regression-data-process-detail.mjs @@ -88,6 +88,9 @@ assert.match(detailSource, /key !== 'generation_model_snapshot'/, '处理配置 assert.match(detailSource, /preprocessOptionLabelMap/, '处理配置没有把预处理内部枚举转换为中文') assert.match(detailSource, /output_type:\s*'输出类型'/, '处理配置没有显示输出类型名称') assert.match(detailSource, /value === 'reasoning' \? '思维链回答' : '标准回答'/, '处理配置没有转换输出类型枚举') +assert.match(detailSource, /reasoning_detail:\s*'推理详细程度'/, '处理配置没有显示推理详细程度名称') +assert.match(detailSource, /value === 'detailed' \? '详细推理' : '普通推理'/, '处理配置没有转换推理详细程度枚举') +assert.match(detailSource, /key !== 'reasoning_detail' \|\| config\.output_type === 'reasoning'/, '标准回答任务不应展示无关的推理详细程度') assert.match(detailSource, /const parsed = typeof value === 'number' \? value : Number\(value\)/, '详情页不兼容 PostgreSQL 数字字符串') assert.match(detailSource, /new Date\(startTime\.value\)\.getTime\(\)/, '详情页没有在后端耗时缺失时按开始、完成时间回算') assert.match(detailSource, /outputCount \+ numeric\(detail\.value\?\.filtered_count\)/, '结果保留率没有使用输出和过滤结果的同口径分母') diff --git a/frontend/scripts/regression-data-process-wizard.mjs b/frontend/scripts/regression-data-process-wizard.mjs index 450aa2b..0df8ae9 100644 --- a/frontend/scripts/regression-data-process-wizard.mjs +++ b/frontend/scripts/regression-data-process-wizard.mjs @@ -461,6 +461,7 @@ for (const field of [ 'generationModelId', 'generationPrompt', 'outputType', + 'reasoningDetail', 'qualityFilterEnabled', 'filterLowQuality', 'filterShortContent', @@ -481,12 +482,14 @@ assert.match(modelSelectionSource, /class="form-section"/, '大模型选择步 assert.doesNotMatch(modelSelectionSource, /max-width:\s*980px/, '大模型选择步骤不应使用比第一步更窄的固定内容宽度') assert.match(taskSetupFeatureSource, /section="quality"/, '质量筛选没有保留在生成选项分类中') assert.doesNotMatch(generationControlSource, /

大模型<\/h4>/, '大模型不应继续作为生成选项内部子分类') -for (const label of ['大模型', '数据生成模型', '默认提示语', '输出类型', '标准回答', '思维链回答', '质量筛选', '过滤低质量内容', '过滤过短内容', '最少字数']) { +for (const label of ['大模型', '数据生成模型', '默认提示语', '输出类型', '标准回答', '思维链回答', '推理详细程度', '普通推理(推荐)', '详细推理', '质量筛选', '过滤低质量内容', '过滤过短内容', '最少字数']) { assert.ok(generationControlSource.includes(label), `生成控制界面缺少:${label}`) } assert.match(generationControlSource, /filterable/, '数据生成模型下拉必须支持搜索') assert.match(generationControlSource, /maxlength="500"/, '默认提示语缺少合理的长度限制') assert.match(generationControlSource, /aria-label="输出类型"/, '输出类型选项缺少可访问名称') +assert.match(generationControlSource, /v-if="options\.outputType === 'reasoning'" class="output-type-row"/, '推理详细程度没有按思维链模式渐进显示') +assert.match(generationControlSource, /aria-label="推理详细程度"/, '推理详细程度缺少可访问名称') assert.match(generationControlSource, /<think>推理过程<\/think>/, '思维链选项没有说明最终保存格式') assert.match(generationControlSource, /\(config, 'preprocess_options', \[\]\)/, '历史任务缺少预处理配置时必须按后端空列表语义回填') @@ -808,6 +817,7 @@ for (const field of [ 'generationModelId', 'generationPrompt', 'outputType', + 'reasoningDetail', 'qualityFilterEnabled', 'filterLowQuality', 'filterShortContent', diff --git a/frontend/src/types/dataProcess.ts b/frontend/src/types/dataProcess.ts index b99fe5c..b4bf17e 100644 --- a/frontend/src/types/dataProcess.ts +++ b/frontend/src/types/dataProcess.ts @@ -5,6 +5,7 @@ export type DataProcessType = 'structured' | 'unstructured' | 'external' export type DataProcessResultStatus = 'valid' | 'modified' | 'invalid' export type DataProcessSplit = 'train' | 'validation' | 'test' export type DataProcessOutputType = 'standard' | 'reasoning' +export type DataProcessReasoningDetail = 'normal' | 'detailed' export interface DataProcessPage { items: T[] @@ -22,6 +23,7 @@ export interface DataProcessDatasetSplit { export type DataProcessConfig = Record & { dataset_split?: DataProcessDatasetSplit output_type?: DataProcessOutputType + reasoning_detail?: DataProcessReasoningDetail } export interface DataProcessTask { diff --git a/frontend/src/views/data-process/DataProcessCreateView.vue b/frontend/src/views/data-process/DataProcessCreateView.vue index 999db3e..66f708d 100644 --- a/frontend/src/views/data-process/DataProcessCreateView.vue +++ b/frontend/src/views/data-process/DataProcessCreateView.vue @@ -202,6 +202,7 @@ function toBackendConfig(): DataProcessConfig { generation_model_id: options.generationModelId, generation_prompt: options.generationPrompt, output_type: options.outputType, + reasoning_detail: options.reasoningDetail, temperature: options.temperature, max_tokens: options.maxTokens, json_mode: options.jsonMode, @@ -210,7 +211,6 @@ function toBackendConfig(): DataProcessConfig { filter_short_content: options.filterShortContent, min_output_length: options.minOutputLength, } - if (processType.value === 'unstructured') { return { ...common, diff --git a/frontend/src/views/data-process/DataProcessDetailView.vue b/frontend/src/views/data-process/DataProcessDetailView.vue index 3f60ac2..8787312 100644 --- a/frontend/src/views/data-process/DataProcessDetailView.vue +++ b/frontend/src/views/data-process/DataProcessDetailView.vue @@ -71,6 +71,7 @@ const configLabelMap: Record = { generation_model_id: '数据生成模型', generation_prompt: '生成提示语', output_type: '输出类型', + reasoning_detail: '推理详细程度', temperature: '生成温度', max_tokens: '最大输出长度', json_mode: 'JSON 输出', @@ -225,15 +226,19 @@ const durationText = computed(() => { return minutes ? `${minutes} 分 ${restSeconds} 秒` : `${restSeconds} 秒` }) -const configRows = computed(() => Object.entries(detail.value?.config || {}) - .filter(([key]) => ( - key !== 'generation_model_snapshot' - && !/(?:password|secret|token|api_key)/i.test(key) - )) - .map(([key, value]) => ({ - label: configLabelMap[key] || key.split('_').join(' '), - value: formatConfigValue(key, value), - }))) +const configRows = computed(() => { + const config = detail.value?.config || {} + return Object.entries(config) + .filter(([key]) => ( + key !== 'generation_model_snapshot' + && (key !== 'reasoning_detail' || config.output_type === 'reasoning') + && !/(?:password|secret|token|api_key)/i.test(key) + )) + .map(([key, value]) => ({ + label: configLabelMap[key] || key.split('_').join(' '), + value: formatConfigValue(key, value), + })) +}) function formatConfigValue(key: string, value: unknown) { if (key === 'generation_model_id') { @@ -249,6 +254,9 @@ function formatConfigValue(key: string, value: unknown) { if (key === 'output_type') { return value === 'reasoning' ? '思维链回答' : '标准回答' } + if (key === 'reasoning_detail') { + return value === 'detailed' ? '详细推理' : '普通推理' + } if (key === 'dataset_split' && value && typeof value === 'object') { const split = value as Partial return `训练集 ${split.train ?? 0}% / 验证集 ${split.validation ?? 0}% / 测试集 ${split.test ?? 0}%` diff --git a/frontend/src/views/data-process/create/GenerationOptionsPanel.vue b/frontend/src/views/data-process/create/GenerationOptionsPanel.vue index 1a0461a..b29cb67 100644 --- a/frontend/src/views/data-process/create/GenerationOptionsPanel.vue +++ b/frontend/src/views/data-process/create/GenerationOptionsPanel.vue @@ -1,7 +1,10 @@