diff --git a/backend/pipeline/jd/runner.py b/backend/pipeline/jd/runner.py index 12de0ca..7a1979d 100644 --- a/backend/pipeline/jd/runner.py +++ b/backend/pipeline/jd/runner.py @@ -191,6 +191,14 @@ def get_default_report_config() -> dict[str, Any]: } +def get_default_strategy_config() -> dict[str, Any]: + """策略生成页独立默认(与 ``report_config`` 无关),供前端回填与 PATCH 合并。""" + return { + # 打开页面时是否默认勾选「使用大模型生成」 + "use_llm_default": False, + } + + def write_competitor_analysis_for_run_dir( run_dir: Path, keyword: str, diff --git a/backend/pipeline/migrations/0021_pipelinejob_strategy_config.py b/backend/pipeline/migrations/0021_pipelinejob_strategy_config.py new file mode 100644 index 0000000..7d30487 --- /dev/null +++ b/backend/pipeline/migrations/0021_pipelinejob_strategy_config.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.1 on 2026-04-17 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0020_search_merged_volume_sort'), + ] + + operations = [ + migrations.AddField( + model_name='pipelinejob', + name='strategy_config', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/backend/pipeline/models.py b/backend/pipeline/models.py index 327a5d6..b6b4d66 100644 --- a/backend/pipeline/models.py +++ b/backend/pipeline/models.py @@ -37,6 +37,8 @@ class PipelineJob(models.Model): scenario_filter_enabled = models.BooleanField(null=True, blank=True) # 竞品报告 / competitor-brief:关注词、场景词组、外部市场表等(JSON,空对象=用爬虫脚本默认) report_config = models.JSONField(default=dict, blank=True) + # 策略生成页独立配置(与 report_config 无关),如默认是否使用大模型润色等 + strategy_config = models.JSONField(default=dict, blank=True) status = models.CharField( max_length=16, choices=JobStatus.choices, diff --git a/backend/pipeline/serializers.py b/backend/pipeline/serializers.py index 3e7ec46..bf843c4 100644 --- a/backend/pipeline/serializers.py +++ b/backend/pipeline/serializers.py @@ -86,6 +86,33 @@ def validate_report_config_body(value: dict) -> dict: return value +_STRATEGY_CONFIG_ALLOWED_KEYS = frozenset({"use_llm_default"}) +_DEFAULT_STRATEGY_CONFIG: dict[str, bool] = {"use_llm_default": False} + + +def validate_strategy_config_body(value: dict) -> dict: + """策略生成页独立 JSON(与 ``report_config`` 无关)。""" + if not isinstance(value, dict): + raise serializers.ValidationError("须为 JSON 对象") + value = dict(value) + extra = set(value.keys()) - _STRATEGY_CONFIG_ALLOWED_KEYS + if extra: + raise serializers.ValidationError( + f"未知字段:{', '.join(sorted(extra))}" + ) + if "use_llm_default" in value and value["use_llm_default"] is not None: + if not isinstance(value["use_llm_default"], bool): + raise serializers.ValidationError("use_llm_default 须为 true 或 false") + merged = { + **_DEFAULT_STRATEGY_CONFIG, + **{k: value[k] for k in _STRATEGY_CONFIG_ALLOWED_KEYS if k in value}, + } + raw = json.dumps(merged, ensure_ascii=False) + if len(raw) > 32_000: + raise serializers.ValidationError("策略配置体积过大") + return merged + + _ARTIFACT_FILES: tuple[tuple[str, str], ...] = ( ("merged", "keyword_pipeline_merged.csv"), ("pc_search", "pc_search_export.csv"), @@ -119,6 +146,7 @@ class PipelineJobSerializer(serializers.ModelSerializer): "list_pages", "scenario_filter_enabled", "report_config", + "strategy_config", "status", "cancellation_requested", "resume_from_checkpoint", @@ -142,6 +170,7 @@ class PipelineJobSerializer(serializers.ModelSerializer): "created_at", "updated_at", "report_config", + "strategy_config", ] def get_inline_cookie_used(self, obj: PipelineJob) -> bool: @@ -343,6 +372,15 @@ class JobReportConfigPatchSerializer(serializers.Serializer): return validate_report_config_body(value) +class JobStrategyConfigPatchSerializer(serializers.Serializer): + strategy_config = serializers.JSONField() + + def validate_strategy_config(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("须为 JSON 对象") + return validate_strategy_config_body(value) + + class RegenerateReportRequestSerializer(serializers.Serializer): """重新生成竞品报告:规则引擎或大模型(与 ``AI_crawler.chat_completion_text`` 同一网关)。""" diff --git a/backend/pipeline/tests/test_strategy_config.py b/backend/pipeline/tests/test_strategy_config.py new file mode 100644 index 0000000..f87f2bb --- /dev/null +++ b/backend/pipeline/tests/test_strategy_config.py @@ -0,0 +1,22 @@ +"""策略页独立 ``strategy_config`` 校验。""" +from __future__ import annotations + +import pytest +from rest_framework import serializers + +from pipeline.serializers import validate_strategy_config_body + + +def test_validate_strategy_config_merges_default() -> None: + out = validate_strategy_config_body({}) + assert out["use_llm_default"] is False + + +def test_validate_strategy_config_unknown_key() -> None: + with pytest.raises(serializers.ValidationError): + validate_strategy_config_body({"foo": 1}) + + +def test_validate_strategy_config_use_llm_type() -> None: + with pytest.raises(serializers.ValidationError): + validate_strategy_config_body({"use_llm_default": "yes"}) diff --git a/backend/pipeline/urls.py b/backend/pipeline/urls.py index e4d7538..f0f1978 100644 --- a/backend/pipeline/urls.py +++ b/backend/pipeline/urls.py @@ -8,6 +8,11 @@ urlpatterns = [ views.ReportConfigDefaultsView.as_view(), name="report-config-defaults", ), + path( + "strategy-config-defaults/", + views.StrategyConfigDefaultsView.as_view(), + name="strategy-config-defaults", + ), path("jobs/", views.JobListCreateView.as_view(), name="job-list-create"), path("jobs//", views.JobDetailView.as_view(), name="job-detail"), path( diff --git a/backend/pipeline/views/__init__.py b/backend/pipeline/views/__init__.py index 24501f2..113a713 100644 --- a/backend/pipeline/views/__init__.py +++ b/backend/pipeline/views/__init__.py @@ -26,6 +26,7 @@ from .job_views import ( JobRegenerateReportView, JobResumeView, ReportConfigDefaultsView, + StrategyConfigDefaultsView, ) from .product_views import ( JdProductDetailView, @@ -55,6 +56,7 @@ __all__ = [ "JobRegenerateReportView", "JobResumeView", "ReportConfigDefaultsView", + "StrategyConfigDefaultsView", "JdProductDetailView", "JdProductListView", "JdProductSnapshotDetailView", diff --git a/backend/pipeline/views/job_views.py b/backend/pipeline/views/job_views.py index 7fb408b..6979628 100644 --- a/backend/pipeline/views/job_views.py +++ b/backend/pipeline/views/job_views.py @@ -18,6 +18,7 @@ from ..ingest import resolve_and_validate_run_dir from ..jd.runner import ( build_competitor_brief_for_job, get_default_report_config, + get_default_strategy_config, merge_llm_supplement_with_rules_report, regenerate_competitor_report, write_competitor_analysis_markdown, @@ -28,6 +29,7 @@ from ..serializers import ( CreatePipelineJobSerializer, JobReportConfigPatchSerializer, JobResumeRequestSerializer, + JobStrategyConfigPatchSerializer, PipelineJobSerializer, RegenerateReportRequestSerializer, ) @@ -75,6 +77,7 @@ class JobListCreateView(APIView): list_pages=data.get("list_pages") or "", scenario_filter_enabled=data.get("scenario_filter_enabled"), report_config=report_config_initial, + strategy_config=get_default_strategy_config(), status=JobStatus.PENDING, ) t = threading.Thread(target=execute_job, args=(job.id,), daemon=True) @@ -108,6 +111,11 @@ class JobDetailView(APIView): ser.is_valid(raise_exception=True) job.report_config = ser.validated_data["report_config"] update_fields.append("report_config") + if "strategy_config" in body: + ser = JobStrategyConfigPatchSerializer(data={"strategy_config": body["strategy_config"]}) + ser.is_valid(raise_exception=True) + job.strategy_config = ser.validated_data["strategy_config"] + update_fields.append("strategy_config") if "run_dir" in body: try: job.run_dir = str( @@ -118,7 +126,9 @@ class JobDetailView(APIView): update_fields.append("run_dir") if not update_fields: return Response( - {"detail": "请提供 report_config 或 run_dir(用于绑定已有批次目录)"}, + { + "detail": "请提供 report_config、strategy_config 或 run_dir(用于绑定已有批次目录)" + }, status=status.HTTP_400_BAD_REQUEST, ) job.save(update_fields=update_fields + ["updated_at"]) @@ -207,6 +217,13 @@ class ReportConfigDefaultsView(APIView): ) +class StrategyConfigDefaultsView(APIView): + """返回策略生成页独立默认 JSON(与 ``report_config`` 无关)。""" + + def get(self, request): + return Response(get_default_strategy_config()) + + class JobDownloadView(APIView): def get(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() diff --git a/frontend/src/composables/useJobs.js b/frontend/src/composables/useJobs.js index 6fcf1b9..4e69a39 100644 --- a/frontend/src/composables/useJobs.js +++ b/frontend/src/composables/useJobs.js @@ -192,6 +192,10 @@ export function reportConfigDefaultsUrl() { return '/api/report-config-defaults/' } +export function strategyConfigDefaultsUrl() { + return '/api/strategy-config-defaults/' +} + /** * @param {Record | string} [opts] 筛选参数对象;兼容旧调用:传入字符串视为 comments 的 sku_id */ diff --git a/frontend/src/views/jd/JdStrategyBuildView.vue b/frontend/src/views/jd/JdStrategyBuildView.vue index 286d993..5f1fb4e 100644 --- a/frontend/src/views/jd/JdStrategyBuildView.vue +++ b/frontend/src/views/jd/JdStrategyBuildView.vue @@ -1,7 +1,12 @@