diff --git a/.github/scripts/builder-confirmed-tool-units.py b/.github/scripts/builder-confirmed-tool-units.py index fd09a25f732..0a881d49380 100644 --- a/.github/scripts/builder-confirmed-tool-units.py +++ b/.github/scripts/builder-confirmed-tool-units.py @@ -194,6 +194,7 @@ FILES += [ "api/tests/unit_tests/services/dify_builder/test_knowledge_inventory.py", "api/tests/unit_tests/services/dify_builder/test_knowledge_discovery.py", "api/tests/unit_tests/services/dify_builder/test_knowledge_source_cue.py", + "api/tests/unit_tests/services/dify_builder/test_requirement_provenance.py", ] SELECTED_GUARDS = [ "api/tests/unit_tests/core/dify_builder/test_handlers_edit.py::test_sufficient_goal_reaches_plan_gate_or_actionable_retry_with_monotonic_progress", @@ -2703,6 +2704,23 @@ a4_expected.update( "test_nonbare_knowledge_item_keeps_handler_clarification_with_false_model_hint": 6, "test_shared_source_predicate_keeps_handler_clarification_with_false_model_hint": 5 }, + "tests.unit_tests.services.dify_builder.test_requirement_provenance": { + "test_actual_fresh_analysis_ignores_only_unconfirmed_derived_source_summary": 1, + "test_explicitly_submitted_source_values_and_defaults_remain_guarded": 4, + "test_only_present_offered_payload_keys_are_confirmed_and_current_values_are_used": 1, + "test_raw_goal_without_complete_explicit_exclusion_keeps_full_requirements_guard": 4, + "test_unknown_malformed_or_missing_key_provenance_never_drops_the_source_guard": 6, + "test_missing_confirmed_key_fails_closed_even_without_another_source_value": 1, + "test_fresh_provenance_never_waives_other_server_source_guards": 4, + "test_confirmation_provenance_roundtrips_without_aliasing": 3, + "test_malformed_serialized_provenance_decodes_as_legacy_unknown": 6, + "test_missing_serialized_provenance_stays_unknown_after_a_later_submission": 1, + "test_reanalysis_replaces_stale_confirmation_provenance": 1, + "test_new_task_reset_cannot_reuse_old_confirmation_provenance": 4, + "test_adjustment_history_retains_confirmed_source_after_request_is_cleared": 1, + "test_clear_discards_confirmation_provenance_at_the_durable_owner": 1, + "test_fix_activation_clears_provenance_with_replaced_requirements": 1, + }, "tests.unit_tests.services.dify_builder.test_knowledge_source_cue": { "test_explicitly_excluded_source_does_not_create_a_knowledge_goal": 11, "test_affirmative_mixed_or_uncertain_source_still_requires_clarification": 29, diff --git a/api/core/dify_builder/models.py b/api/core/dify_builder/models.py index b110f261a54..4bd3025d33b 100644 --- a/api/core/dify_builder/models.py +++ b/api/core/dify_builder/models.py @@ -397,6 +397,9 @@ class DifyBuilderContext: attachment_material: list[dict[str, Any]] = field(default_factory=list) reply_language: str = "" # detected user-input language (BCP-47); drives reply/localization requirements: dict[str, Any] = field(default_factory=dict) + # Server-owned submitted form keys. None means legacy/unknown provenance; + # [] means fresh analysis with no user-confirmed requirement values. + confirmed_requirement_keys: list[str] | None = None plan_items: list[str] = field(default_factory=list) plan_title: str = "" plan_input_summary: str = "" diff --git a/api/services/dify_builder/application/handlers_build.py b/api/services/dify_builder/application/handlers_build.py index 150f704ec33..69bcac8321b 100644 --- a/api/services/dify_builder/application/handlers_build.py +++ b/api/services/dify_builder/application/handlers_build.py @@ -100,9 +100,10 @@ from services.dify_builder.initial_draft import build_intents_for_draft from services.dify_builder.plan import ( ResourceDiscovery, WorkflowPlan, - has_knowledge_source_cue, + has_confirmed_knowledge_source_cue, make_plan_card, store_plan, + validated_confirmed_requirement_keys, ) from services.dify_builder.repair_gate import NO_REPAIR_REASON @@ -174,6 +175,7 @@ def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderCon analysis = env.agent.analyze_goal(fc.goal_text, reply_language=fc.reply_language or None) fc.form_fields = list(analysis.get("fields") or []) fc.requirements = dict(analysis.get("values") or {}) + fc.confirmed_requirement_keys = [] _refine_app_name(env, fc) progress.activate("build-prepare-requirements") @@ -259,6 +261,20 @@ def handle_goal_analysis(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex keys = [f["key"] for f in fc.form_fields if isinstance(f, dict) and f.get("key")] previous_requirements = fc.requirements fc.requirements = merge_known_keys(fc.requirements, turn.action.payload, keys) + confirmed_keys = validated_confirmed_requirement_keys(fc.confirmed_requirement_keys) + if confirmed_keys is not None: + fc.confirmed_requirement_keys = list( + dict.fromkeys( + [ + *confirmed_keys, + *( + key + for key in keys + if isinstance(key, str) and key in turn.action.payload and key in fc.requirements + ), + ] + ) + ) if fc.requirements != previous_requirements: clear_task_acceptance(fc) @@ -622,7 +638,7 @@ def handle_resource_recommendation(env: Env, turn: Turn, s: Session, fc: DifyBui or fc.resource_selection.get("blocked_capability") == "knowledge_retrieval_phase_two" or fc.resource_selection.get("knowledge_source_unavailable") or fc.resource_selection.get("knowledge_requires_clarification") - or has_knowledge_source_cue(fc.goal_text, fc.requirements) + or has_confirmed_knowledge_source_cue(fc.goal_text, fc.requirements, fc.confirmed_requirement_keys) ) ): progress.fail_step("build-bind-resources") @@ -1593,6 +1609,13 @@ def _collect_adjustment(fc: DifyBuilderContext) -> StepResult: previous_request = fc.requirements.get(_ADJUSTMENT_REQUEST) if isinstance(previous_request, str) and previous_request.strip(): fc.requirements[_ADJUSTMENT_HISTORY] = [*fc.requirements.get(_ADJUSTMENT_HISTORY, []), previous_request] + confirmed_keys = validated_confirmed_requirement_keys(fc.confirmed_requirement_keys) + if confirmed_keys is not None and _ADJUSTMENT_REQUEST in confirmed_keys: + # The accepted request moved into history; do not lose its source cue + # when the next form clears the current request for another submission. + fc.confirmed_requirement_keys = [key for key in confirmed_keys if key != _ADJUSTMENT_REQUEST] + if isinstance(previous_request, str) and previous_request.strip() and _ADJUSTMENT_HISTORY not in confirmed_keys: + fc.confirmed_requirement_keys.append(_ADJUSTMENT_HISTORY) fc.form_fields = [field for field in fc.form_fields if field.get("key") != _ADJUSTMENT_REQUEST] fc.form_fields.append( { diff --git a/api/services/dify_builder/conversation_tasks.py b/api/services/dify_builder/conversation_tasks.py index d060fc68eaf..da3109462e0 100644 --- a/api/services/dify_builder/conversation_tasks.py +++ b/api/services/dify_builder/conversation_tasks.py @@ -36,6 +36,7 @@ def _reset_for_new_task(session: Session, context: DifyBuilderContext, mode: Ent context.paused = False context.recovery_class = "" context.attachment_material = [] + context.confirmed_requirement_keys = None context.retained_test_input_ref = context.test_input_ref or context.retained_test_input_ref context.confirmed_consent_id = "" context.confirmed_command_id = "" diff --git a/api/services/dify_builder/fix_intent_repository.py b/api/services/dify_builder/fix_intent_repository.py index 04429f23013..7849f50351a 100644 --- a/api/services/dify_builder/fix_intent_repository.py +++ b/api/services/dify_builder/fix_intent_repository.py @@ -359,6 +359,7 @@ class SqlFixIntentRepository: context.last_repair_error = context.last_repair_graph_revision = context.last_edit_rejection = "" context.goal_text = "" context.requirements = context.edit_rules = context.resource_selection = {} + context.confirmed_requirement_keys = None context.plan_items = context.built_node_ids = context.edit_target_node_ids = [] context.attachment_material = [] context.last_snapshot_hash = revision diff --git a/api/services/dify_builder/plan.py b/api/services/dify_builder/plan.py index 6f4fd720b95..dc1ffa4fc1c 100644 --- a/api/services/dify_builder/plan.py +++ b/api/services/dify_builder/plan.py @@ -153,6 +153,34 @@ def has_knowledge_source_cue(goal: str, requirements: object) -> bool: return False +def validated_confirmed_requirement_keys(value: object) -> list[str] | None: + """Copy server provenance; malformed or absent data retains legacy scanning.""" + if ( + not isinstance(value, list) + or any(not isinstance(key, str) or not key for key in value) + or len(set(value)) != len(value) + ): + return None + return list(value) + + +def has_confirmed_knowledge_source_cue( + goal: str, requirements: dict[str, object], confirmed_keys: list[str] | None +) -> bool: + """Discount derived summaries only for an already recognized raw exclusion. + + Keep all requirements for unknown provenance or goals without an explicit + excluded source. Confirmed keys refer to current values, including defaults; + a missing current key is unproven and must keep the source gate closed. + """ + keys = validated_confirmed_requirement_keys(confirmed_keys) + if keys is None or not _KNOWLEDGE_CUE.search(goal) or has_knowledge_source_cue(goal, {}): + return has_knowledge_source_cue(goal, requirements) + if any(key not in requirements for key in keys): + return True + return has_knowledge_source_cue(goal, {key: requirements[key] for key in keys}) + + class EditPlanNotActionableError(RuntimeError): """An Edit goal has no approvable change; never a stored/public plan.""" diff --git a/api/services/dify_builder/serde.py b/api/services/dify_builder/serde.py index 65bcc1c87e2..62c6c72dc69 100644 --- a/api/services/dify_builder/serde.py +++ b/api/services/dify_builder/serde.py @@ -22,6 +22,7 @@ from core.dify_builder.models import ( Risk, TestInput, ) +from services.dify_builder.plan import validated_confirmed_requirement_keys def context_to_dict(fc: DifyBuilderContext) -> dict[str, Any]: @@ -36,6 +37,7 @@ def context_to_dict(fc: DifyBuilderContext) -> dict[str, Any]: from services.dify_builder.task_lifecycle import validated_task_summaries result = asdict(fc) + result["confirmed_requirement_keys"] = validated_confirmed_requirement_keys(fc.confirmed_requirement_keys) result["code_risk_assessments"] = validated_assessments(fc.code_risk_assessments) result["task_summaries"] = [asdict(summary) for summary in validated_task_summaries(fc.task_summaries)] return result @@ -84,6 +86,7 @@ def context_from_dict(d: dict[str, Any]) -> DifyBuilderContext: attachment_material=list(d.get("attachment_material") or []), active_entry_mode=d.get("active_entry_mode", ""), requirements=dict(d.get("requirements") or {}), + confirmed_requirement_keys=validated_confirmed_requirement_keys(d.get("confirmed_requirement_keys")), plan_items=list(d.get("plan_items") or []), plan_title=d.get("plan_title", ""), plan_input_summary=d.get("plan_input_summary", ""), diff --git a/api/tests/unit_tests/services/dify_builder/test_requirement_provenance.py b/api/tests/unit_tests/services/dify_builder/test_requirement_provenance.py new file mode 100644 index 00000000000..8d1dcd4f19c --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/test_requirement_provenance.py @@ -0,0 +1,284 @@ +"""Server confirmation provenance bounds only explicit raw source exclusions.""" + +import json +from datetime import datetime +from typing import override + +import pytest + +from core.dify_builder.models import Action, Actor, ConversationItem, DifyBuilderContext, EntryMode, Session, Turn +from core.dify_builder.state import PcState +from services.dify_builder.application.handlers_build import ( + _collect_adjustment, + handle_capability_check, + handle_goal_analysis, +) +from services.dify_builder.application.runner import Env +from services.dify_builder.conversation_tasks import _reset_for_new_task +from services.dify_builder.plan import WorkflowPlan +from services.dify_builder.repository import SqlDifyBuilderRepository +from services.dify_builder.serde import context_from_dict, context_to_dict +from services.dify_builder.service import DifyBuilderService +from tests.unit_tests.core.dify_builder.fakes import FakeDifyPort, InMemoryRepository +from tests.unit_tests.services.dify_builder.test_bound_resource_plan_reconciliation import ReconciliationAgent +from tests.unit_tests.services.dify_builder.test_knowledge_discovery import MODEL, PLAN, confirm +from tests.unit_tests.services.dify_builder.test_knowledge_source_cue import CAPTURED_MEETING_GOAL + +pytest_plugins = ["tests.unit_tests.services.dify_builder.test_service"] + +CAPTURED_REQUIREMENTS = { + "mode": "draft_only_plan_review", + "llm_rules": [ + "区分已决定与未决定", + "缺失负责人、日期、金额标注待确认,不编造", + "风险与阻塞只列原文明示内容,未提及则写「原文未提及」", + ], + "end_outputs": [{"type": "text", "source": "LLM.text", "variable": "minutes"}], + "model_policy": "使用当前已配置可用的模型,不新增资源、工具、Code、Agent、HTTP、知识库、文件或凭据", + "start_inputs": [{"type": "text", "label": "会议原文", "required": True, "variable": "meeting_notes"}], + "workflow_name": "Dev ENG1235 Meeting 439e", + "allowed_topology": "Start → LLM → End,3 个节点、2 条连线", + "execution_policy": "先生成计划供审阅,不执行测试或发布", + "llm_output_language": "中文", + "llm_sections_in_order": ["结论", "待办", "待确认事项", "风险与阻塞"], +} + + +class ProvenanceAgent(ReconciliationAgent): + @override + def analyze_goal(self, goal_text: str, *, reply_language: str | None = None) -> dict[str, object]: + return {"fields": [], "values": dict(CAPTURED_REQUIREMENTS)} + + @override + def propose_plan_v1( + self, requirements: dict[str, object], *, goal_text: str, reply_language: str | None = None + ) -> WorkflowPlan: + return PLAN + + +def setup_context() -> tuple[Env, Session, DifyBuilderContext]: + context = DifyBuilderContext(goal_text=CAPTURED_MEETING_GOAL, requirements=dict(CAPTURED_REQUIREMENTS)) + session = Session(app_id="app", entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_CAPABILITY_CHECK) + env = Env(agent=ProvenanceAgent(PLAN), repo=InMemoryRepository(), dify=FakeDifyPort(), now=lambda: datetime.min) + return env, session, context + + +def submit(env: Env, session: Session, context: DifyBuilderContext, payload: dict[str, object]) -> None: + handle_goal_analysis( + env, + Turn( + actor=Actor(account_id="actor", tenant_id="tenant"), + action=Action(kind="submit_requirements", payload=payload), + ), + session, + context, + ) + + +def _confirm( + context: DifyBuilderContext, +) -> tuple[Session, DifyBuilderContext, ReconciliationAgent, list[ConversationItem]]: + agent = ReconciliationAgent(PLAN) + session, restored, _, items = confirm(agent, offered=[], ids=[MODEL], context=context) + return session, restored, agent, items + + +def test_actual_fresh_analysis_ignores_only_unconfirmed_derived_source_summary() -> None: + env, session, context = setup_context() + result = handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + assert result.next == PcState.BUILD_INITIAL_PLAN + agent = ReconciliationAgent(PLAN) + result_session, restored, _, _ = confirm(agent, offered=[], ids=[MODEL], context=context) + assert result_session.current_state == PcState.BUILD_PLAN_APPROVAL + assert restored.goal_text == CAPTURED_MEETING_GOAL + assert agent.requests[0]["requirements"] == CAPTURED_REQUIREMENTS + assert not restored.resource_selection.get("knowledge_scope_decision") + + +@pytest.mark.parametrize("value", ["使用知识库回答", "如果需要则访问知识库", "不新增知识库", "默认使用知识库"]) +def test_explicitly_submitted_source_values_and_defaults_remain_guarded(value: str) -> None: + env, session, context = setup_context() + handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + context.form_fields = [{"key": "answer", "type": "text"}] + context.requirements["answer"] = value + submit(env, session, context, {"answer": value}) + result, restored, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert agent.requests == [] + assert restored.confirmed_requirement_keys == ["answer"] + + +def test_only_present_offered_payload_keys_are_confirmed_and_current_values_are_used() -> None: + env, session, context = setup_context() + handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + context.form_fields = [{"key": "answer", "type": "text"}, {"key": "not_submitted", "type": "text"}] + context.requirements.update(answer="使用知识库", not_submitted="使用知识库") + submit( + env, + session, + context, + { + "answer": "无需知识库", + "model_policy": "使用知识库", + "forged": "使用知识库", + "confirmed_requirement_keys": ["model_policy"], + }, + ) + assert context.confirmed_requirement_keys == ["answer"] + assert "forged" not in context.requirements + assert context.requirements["model_policy"] == CAPTURED_REQUIREMENTS["model_policy"] + result, _, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_PLAN_APPROVAL + assert len(agent.requests) == 1 + + +@pytest.mark.parametrize( + "goal", ["根据公司文档回答", "使用知识库", "如果需要使用知识库", "不使用知识库,但是使用知识检索"] +) +def test_raw_goal_without_complete_explicit_exclusion_keeps_full_requirements_guard(goal: str) -> None: + env, session, context = setup_context() + handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + context.goal_text = goal + result, _, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert agent.requests == [] + + +@pytest.mark.parametrize("provenance", [None, "answer", {}, [1], [""], ["missing"]]) +def test_unknown_malformed_or_missing_key_provenance_never_drops_the_source_guard(provenance: object) -> None: + _, _, context = setup_context() + context.__dict__["confirmed_requirement_keys"] = provenance + result, _, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert agent.requests == [] + + +def test_missing_confirmed_key_fails_closed_even_without_another_source_value() -> None: + _, _, context = setup_context() + context.requirements = {"style": "brief"} + context.confirmed_requirement_keys = ["missing"] + result, _, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert agent.requests == [] + + +@pytest.mark.parametrize( + "selection", + [ + {"offered_knowledge_ids": ["knowledge:offered"]}, + {"knowledge_source_unavailable": True}, + {"knowledge_requires_clarification": True}, + {"blocked_capability": "knowledge_retrieval_phase_two"}, + ], +) +def test_fresh_provenance_never_waives_other_server_source_guards(selection: dict[str, object]) -> None: + env, session, context = setup_context() + handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + context.resource_selection = selection + result, _, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert agent.requests == [] + + +@pytest.mark.parametrize("keys", [None, [], ["answer"]]) +def test_confirmation_provenance_roundtrips_without_aliasing(keys: list[str] | None) -> None: + _, _, context = setup_context() + context.confirmed_requirement_keys = keys + restored = context_from_dict(json.loads(json.dumps(context_to_dict(context)))) + assert getattr(restored, "confirmed_requirement_keys", "missing") == keys + if keys is not None: + assert restored.confirmed_requirement_keys is not None + restored.confirmed_requirement_keys.append("other") + assert context.confirmed_requirement_keys == keys + + +@pytest.mark.parametrize("raw", ["answer", {}, [1], ["answer", None], [""], ["answer", "answer"]]) +def test_malformed_serialized_provenance_decodes_as_legacy_unknown(raw: object) -> None: + restored = context_from_dict({"confirmed_requirement_keys": raw}) + assert getattr(restored, "confirmed_requirement_keys", "missing") is None + + +def test_missing_serialized_provenance_stays_unknown_after_a_later_submission() -> None: + env, session, _ = setup_context() + context = context_from_dict( + { + "goal_text": CAPTURED_MEETING_GOAL, + "requirements": CAPTURED_REQUIREMENTS, + "form_fields": [{"key": "style", "type": "text"}], + } + ) + submit(env, session, context, {"style": "brief"}) + assert getattr(context, "confirmed_requirement_keys", "missing") is None + result, _, _, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + + +def test_reanalysis_replaces_stale_confirmation_provenance() -> None: + env, session, context = setup_context() + context.confirmed_requirement_keys = ["old"] + handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + assert getattr(context, "confirmed_requirement_keys", "missing") == [] + + +@pytest.mark.parametrize("mode", list(EntryMode)) +def test_new_task_reset_cannot_reuse_old_confirmation_provenance(mode: EntryMode) -> None: + _, session, context = setup_context() + context.confirmed_requirement_keys = ["model_policy"] + _reset_for_new_task(session, context, mode) + assert context.confirmed_requirement_keys is None + + +def test_adjustment_history_retains_confirmed_source_after_request_is_cleared() -> None: + env, session, context = setup_context() + handle_capability_check(env, Turn(actor=Actor(account_id="actor", tenant_id="tenant")), session, context) + _collect_adjustment(context) + submit(env, session, context, {"__builder_adjustment_request": "使用知识库"}) + _collect_adjustment(context) + assert context.requirements["__builder_adjustment_request"] == "" + assert getattr(context, "confirmed_requirement_keys", None) == ["__builder_adjustment_history"] + result, _, agent, _ = _confirm(context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert agent.requests == [] + + +def test_clear_discards_confirmation_provenance_at_the_durable_owner( + service: DifyBuilderService, repo: SqlDifyBuilderRepository +) -> None: + from tests.unit_tests.services.dify_builder.test_conversation_lifecycle import lifecycle, seed + from tests.unit_tests.services.dify_builder.test_service import _actor + + session = seed(repo) + current, context = repo.get_session(session.id) + context.confirmed_requirement_keys = ["old"] + repo.compare_and_advance(session.id, current.version, current.current_state, context, []) + current, _ = repo.get_session(session.id) + lifecycle().clear( + service, session.id, _actor(), base_version=current.version, client_action_id="clear", confirmed=True + ) + _, cleared = repo.get_session(session.id) + assert getattr(cleared, "confirmed_requirement_keys", "missing") is None + + +def test_fix_activation_clears_provenance_with_replaced_requirements(repo: SqlDifyBuilderRepository) -> None: + from services.dify_builder.fix_intent_repository import SqlFixIntentRepository + from tests.unit_tests.services.dify_builder.test_fix_intents import ACTOR, native_run, queue + from tests.unit_tests.services.dify_builder.test_service import ACCOUNT_ID, APP_ID, TENANT_ID + + session = Session( + app_id=APP_ID, + tenant_id=TENANT_ID, + owner_account_id=ACCOUNT_ID, + entry_mode=EntryMode.BUILD, + current_state=PcState.BUILD_COMPLETE, + ) + repo.create_session( + session, + DifyBuilderContext(requirements={"old": "使用知识库"}, confirmed_requirement_keys=["old"]), + [], + ) + intent = queue(repo, session, native_run(repo)) + activated = SqlFixIntentRepository(repo).activate(ACTOR, session.id, intent.id, dsl={}, revision="current-draft") + _, context = repo.get_session(session.id) + assert activated.status == "activated" + assert context.requirements == {} + assert context.confirmed_requirement_keys is None