From 74211ed899475cb157c4670be6b4ec9bc32aae26 Mon Sep 17 00:00:00 2001 From: abella Date: Mon, 29 Jun 2026 15:27:54 +0800 Subject: [PATCH 1/3] feat(p3-api): expose converter readiness and guard conversion --- apps/api/app/software_design_v2/service.py | 85 +++++++++++++++++-- apps/api/tests/test_software_design_v2_api.py | 49 +++++++++++ 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/apps/api/app/software_design_v2/service.py b/apps/api/app/software_design_v2/service.py index 35c32e8..5484138 100644 --- a/apps/api/app/software_design_v2/service.py +++ b/apps/api/app/software_design_v2/service.py @@ -100,7 +100,7 @@ def list_input_packages(self) -> dict: def list_converters(self) -> dict: registry = get_design_converter_plugin_registry() - return {"items": [converter.to_api() for converter in registry.list_converters()]} + return {"items": [self._converter_to_api(converter) for converter in registry.list_converters()]} def create_session(self, payload: P3DesignSessionCreate) -> dict: input_package = self._get_input_package(payload.input_package_id) @@ -125,7 +125,13 @@ def create_session(self, payload: P3DesignSessionCreate) -> dict: "output_style": payload.generation_policy.get("output_style", "按标准软设正文写,不写聊天语气"), }, "status": "conversion_pending", - "conversion": self._build_conversion_state("conversion_pending", "standard_sdd_draft", None, None), + "conversion": self._build_conversion_state( + "conversion_pending", + "standard_sdd_draft", + None, + None, + converter=self._default_converter_api(), + ), "design_document": None, "design_baseline": None, "workorder_projection": None, @@ -171,14 +177,27 @@ def run_conversion(self, session_id: str, payload: P3DesignConversionRun) -> dic def _run_design_converter(self, design_session: dict, payload: P3DesignConversionRun, strategy: str) -> dict: registry = get_design_converter_plugin_registry() manifest = registry.require(payload.converter_id.strip() if payload.converter_id else registry.default_converter().converter_id) - design_session["conversion"] = self._build_conversion_state("conversion_running", strategy, None, None, converter=manifest.to_api()) + converter_api = self._converter_to_api(manifest) + readiness = dict(converter_api.get("readiness") or {}) + if readiness and readiness.get("ready") is False: + message = str(readiness.get("message") or "P3 design converter is not ready") + self._record_conversion_failure(design_session, strategy, converter_api, message) + raise ValueError(message) + + design_session["conversion"] = self._build_conversion_state( + "conversion_running", + strategy, + None, + None, + converter=converter_api, + ) request = self._build_converter_request(design_session, payload, strategy) adapter = load_design_converter_adapter(manifest) try: result = adapter.run(request) except ValueError as exc: - self._record_conversion_failure(design_session, strategy, manifest.to_api(), str(exc)) + self._record_conversion_failure(design_session, strategy, converter_api, str(exc)) raise design_document = self._normalize_converter_design_document(result, design_session) design_baseline = self._build_design_baseline_from_converter_result(result, design_session) @@ -193,7 +212,7 @@ def _run_design_converter(self, design_session: dict, payload: P3DesignConversio strategy, design_document, design_baseline, - converter=result.converter, + converter=self._merge_converter_readiness(result.converter, converter_api), process_output=result.process_output, ) design_session["updated_at"] = self._now() @@ -229,6 +248,62 @@ def _record_conversion_failure(self, design_session: dict, strategy: str, conver ] self._persist_design_session(design_session) + def _default_converter_api(self) -> dict | None: + try: + registry = get_design_converter_plugin_registry() + return self._converter_to_api(registry.default_converter()) + except ValueError: + return None + + def _converter_to_api(self, manifest) -> dict: + converter = manifest.to_api() + converter["readiness"] = self._build_converter_readiness(converter) + return converter + + def _merge_converter_readiness(self, converter: dict, fallback_converter: dict) -> dict: + normalized = dict(converter or {}) + fallback = dict(fallback_converter or {}) + if "readiness" not in normalized and fallback.get("readiness"): + normalized["readiness"] = fallback["readiness"] + if "requires" not in normalized and fallback.get("requires"): + normalized["requires"] = fallback["requires"] + if "name" not in normalized and fallback.get("name"): + normalized["name"] = fallback["name"] + return normalized + + @staticmethod + def _build_converter_readiness(converter: dict) -> dict: + requires = dict(converter.get("requires") or {}) + if requires.get("dify_api") is not True: + return { + "ready": True, + "status": "ready", + "message": "转换器不依赖外部 Dify API 配置。", + "required_config_keys": [], + "missing_config_keys": [], + } + + api_key_configured = bool(_env("CODEFACTORY_P3_DIFY_API_KEY", "DIFY_API_KEY")) + required_config_keys = ["CODEFACTORY_P3_DIFY_API_KEY", "DIFY_API_KEY"] + if api_key_configured: + return { + "ready": True, + "status": "ready", + "message": "P3 Dify 转换器已检测到 API Key,可执行需规转软设转换。", + "required_config_keys": required_config_keys, + "missing_config_keys": [], + "configured": {"dify_api_key": True}, + } + return { + "ready": False, + "status": "missing_configuration", + "message": "DIFY_API_KEY is not configured for requirement-to-sdd-dify-workflow", + "required_config_keys": required_config_keys, + "missing_config_keys": required_config_keys, + "configured": {"dify_api_key": False}, + "operator_hint": "请在本地或部署环境配置 CODEFACTORY_P3_DIFY_API_KEY,或兼容配置 DIFY_API_KEY。", + } + def _build_converter_request( self, design_session: dict, diff --git a/apps/api/tests/test_software_design_v2_api.py b/apps/api/tests/test_software_design_v2_api.py index 2fbb1d3..737b86e 100644 --- a/apps/api/tests/test_software_design_v2_api.py +++ b/apps/api/tests/test_software_design_v2_api.py @@ -21,6 +21,7 @@ def fake_design_converter_loader(monkeypatch): "CODEFACTORY_P3_SCOPED_DIFY_RESPONSE_MODE", ): monkeypatch.delenv(env_name, raising=False) + monkeypatch.setenv("CODEFACTORY_P3_DIFY_API_KEY", "test-p3-dify-key") class FakeDesignConverterAdapter: def __init__(self, converter_id: str) -> None: @@ -361,6 +362,54 @@ def test_software_design_v2_lists_available_design_converters() -> None: assert items[0]["protocol"] == "p3-design-converter-protocol@1" assert items[0]["observability_level"] == "limited" assert items[0]["capabilities"]["design_document"] is True + assert items[0]["readiness"]["ready"] is True + assert items[0]["readiness"]["status"] == "ready" + assert "CODEFACTORY_P3_DIFY_API_KEY" in items[0]["readiness"]["required_config_keys"] + + +def test_software_design_v2_blocks_dify_conversion_when_api_key_missing(monkeypatch) -> None: + monkeypatch.delenv("CODEFACTORY_P3_DIFY_API_KEY", raising=False) + monkeypatch.delenv("DIFY_API_KEY", raising=False) + client = TestClient(create_app()) + _create_frozen_requirement_authoring_document(client) + + converters = client.get("/api/software-design-v2/converters") + assert converters.status_code == 200 + converter = converters.json()["items"][0] + assert converter["readiness"]["ready"] is False + assert converter["readiness"]["status"] == "missing_configuration" + assert "DIFY_API_KEY" in converter["readiness"]["message"] + + input_package_id = client.get("/api/software-design-v2/input-packages").json()["items"][0]["input_package_id"] + session_response = client.post( + "/api/software-design-v2/sessions", + json={ + "input_package_id": input_package_id, + "design_title": "空域协同规划软件设计说明 - 未配置转换器", + "version_label": "v0.1", + "generation_policy": { + "architecture_preference": "统一服务优先,保留拆分点", + "module_granularity": "3-5 个业务模块,不拆太细", + "output_style": "按标准软设正文写,不写聊天语气", + }, + }, + ) + assert session_response.status_code == 200 + session = session_response.json() + assert session["conversion"]["converter"]["readiness"]["ready"] is False + + converted = client.post( + f"/api/software-design-v2/sessions/{session['session_id']}/conversion", + json={"strategy": "standard_sdd_draft"}, + ) + assert converted.status_code == 400 + assert "DIFY_API_KEY" in converted.json()["detail"] + + failed_session = client.get(f"/api/software-design-v2/sessions/{session['session_id']}").json() + assert failed_session["status"] == "conversion_failed" + assert failed_session["conversion"]["status"] == "conversion_failed" + assert failed_session["conversion"]["converter"]["readiness"]["ready"] is False + assert failed_session["conversion"]["process_output"]["error"]["source"] == "design_converter" def test_software_design_v2_applies_scoped_patch_proposal_to_design_document() -> None: From 39d8e40a5a09bb264c7ab70c4cd33f7d8a2b5a26 Mon Sep 17 00:00:00 2001 From: abella Date: Mon, 29 Jun 2026 15:28:10 +0800 Subject: [PATCH 2/3] feat(p3-web): show converter readiness in Design Lab --- .../src/components/stageWorkbench/models.ts | 13 ++++ apps/web/src/lib/api.ts | 25 ++++++ apps/web/src/pages/P3DesignLabPage.css | 16 ++++ apps/web/src/pages/P3DesignLabPage.tsx | 41 +++++++++- .../adapters/p3DesignLabWorkbenchAdapter.ts | 26 +++++++ apps/web/src/test/P3DesignLabPage.test.tsx | 78 ++++++++++++++++++- 6 files changed, 197 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/stageWorkbench/models.ts b/apps/web/src/components/stageWorkbench/models.ts index d65c233..4d665b2 100644 --- a/apps/web/src/components/stageWorkbench/models.ts +++ b/apps/web/src/components/stageWorkbench/models.ts @@ -177,6 +177,19 @@ export type StageConversionViewModel = { elapsedSeconds: number; progressNote?: string; strategy: string; + converter?: { + converterId: string; + name?: string; + converterType?: string; + readiness?: { + ready: boolean; + status: string; + message: string; + requiredConfigKeys: string[]; + missingConfigKeys: string[]; + operatorHint?: string; + }; + } | null; strategyOptions: Array<{ value: string; label: string; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f78adec..8cbd907 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -2605,9 +2605,34 @@ export type P3DesignLabConversionStep = { status: "pending" | "running" | "done" | "failed" | string; }; +export type P3DesignConverterReadiness = { + ready: boolean; + status: "ready" | "missing_configuration" | "unavailable" | string; + message: string; + required_config_keys?: string[]; + missing_config_keys?: string[]; + configured?: Record; + operator_hint?: string; +}; + +export type P3DesignConverter = { + converter_id: string; + name?: string; + converter_type?: string; + document_type?: string; + protocol?: string; + status?: string; + priority?: number; + capabilities?: Record; + requires?: Record; + observability_level?: string; + readiness?: P3DesignConverterReadiness; +}; + export type P3DesignLabConversionState = { status: "conversion_pending" | "conversion_running" | "conversion_failed" | "draft_ready" | string; strategy: string; + converter?: P3DesignConverter | null; strategy_options: Array<{ value: string; label: string; description: string }>; steps: P3DesignLabConversionStep[]; draft_preview?: { diff --git a/apps/web/src/pages/P3DesignLabPage.css b/apps/web/src/pages/P3DesignLabPage.css index 06dd62a..d58728c 100644 --- a/apps/web/src/pages/P3DesignLabPage.css +++ b/apps/web/src/pages/P3DesignLabPage.css @@ -407,6 +407,22 @@ font-weight: 850; } +.p3-design-lab-converter-readiness { + border-radius: 0; +} + +.p3-design-lab-converter-readiness .ant-alert-message { + color: #1f3330; + font-size: 12px; + font-weight: 950; +} + +.p3-design-lab-converter-readiness .ant-alert-description { + color: #4e625d; + font-size: 11px; + line-height: 1.45; +} + .p3-design-lab-conversion-strategy-picker { display: grid; gap: 7px; diff --git a/apps/web/src/pages/P3DesignLabPage.tsx b/apps/web/src/pages/P3DesignLabPage.tsx index 72c5409..8f11b0f 100644 --- a/apps/web/src/pages/P3DesignLabPage.tsx +++ b/apps/web/src/pages/P3DesignLabPage.tsx @@ -121,6 +121,28 @@ function getApiErrorDetail(error: unknown) { return ""; } +function getConversionReadinessNotice(workbench: StageDocumentWorkbenchViewModel) { + const readiness = workbench.conversion.converter?.readiness; + if (!readiness) { + return null; + } + const missingKeys = readiness.missingConfigKeys.length ? readiness.missingConfigKeys.join(" / ") : ""; + if (readiness.ready) { + return { + ready: true, + title: "转换器已就绪", + description: readiness.message || "当前转换器已通过本地配置检查。", + }; + } + return { + ready: false, + title: "转换器未就绪", + description: + readiness.operatorHint || + (missingKeys ? `缺少配置:${missingKeys}` : readiness.message || "当前转换器缺少必要运行配置。"), + }; +} + export function P3DesignLabPage() { const location = useLocation(); const navigate = useNavigate(); @@ -417,6 +439,11 @@ export function P3DesignLabPage() { if (conversionInFlightSessionId === designSession.session_id) { return; } + const readinessNotice = getConversionReadinessNotice(workbench); + if (readinessNotice && !readinessNotice.ready) { + setError(`${readinessNotice.title}:${readinessNotice.description}`); + return; + } const runningSessionId = designSession.session_id; try { setSubmitting(true); @@ -1546,6 +1573,8 @@ function StageRelationInspector({ }) { const relationType = typeof selection.payload?.relationType === "string" ? selection.payload.relationType : ""; const isBasicConversion = selection.objectId === "reqdoc"; + const readinessNotice = getConversionReadinessNotice(workbench); + const converterUnavailable = Boolean(hasSession && readinessNotice && !readinessNotice.ready); return ( <> @@ -1571,6 +1600,16 @@ function StageRelationInspector({ 转换为 {toInspectorText(selection.payload?.outputSummary)} + {readinessNotice ? ( + + ) : null}
转换策略 @@ -1588,7 +1627,7 @@ function StageRelationInspector({