diff --git a/.github/scripts/builder-confirmed-tool-units.py b/.github/scripts/builder-confirmed-tool-units.py index be9c229099b..fd09a25f732 100644 --- a/.github/scripts/builder-confirmed-tool-units.py +++ b/.github/scripts/builder-confirmed-tool-units.py @@ -2698,7 +2698,10 @@ a4_expected.update( "test_excluded_resource_list_reaches_formal_plan_without_a_knowledge_waiver": 3, "test_excluded_resource_list_never_disables_server_or_model_source_guards": 3, "test_no_extra_components_resource_list_reaches_plan_without_changing_the_original_goal": 1, - "test_full_captured_bare_resource_exclusion_reaches_plan_without_a_knowledge_waiver": 1 + "test_full_captured_bare_resource_exclusion_reaches_plan_without_a_knowledge_waiver": 1, + "test_captured_meeting_model_only_confirmation_preserves_goal_without_a_knowledge_waiver": 1, + "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_knowledge_source_cue": { "test_explicitly_excluded_source_does_not_create_a_knowledge_goal": 11, @@ -2718,7 +2721,18 @@ a4_expected.update( "test_bare_explicit_negative_roles_obtain_collection_meaning_only_from_the_list": 5, "test_bare_role_prefixes_still_require_a_complete_identifier": 4, "test_bare_negative_roles_do_not_hide_nonlist_unknown_positive_or_nested_values": 9, - "test_positive_goal_still_dominates_a_bare_negative_resource_role": 1 + "test_positive_goal_still_dominates_a_bare_negative_resource_role": 1, + "test_captured_meeting_goal_excludes_knowledge_without_a_goal_change": 2, + "test_complete_preceding_resource_items_allow_bounded_new_descriptors": 8, + "test_excluded_knowledge_mention_requires_a_complete_bare_list_item": 12, + "test_english_source_suffix_prose_is_not_a_bare_excluded_item": 6, + "test_bare_excluded_item_accepts_end_punctuation_or_list_connector": 7, + "test_preceding_item_expansion_preserves_unknown_uncertain_and_affirmative_guards": 12, + "test_compound_source_subject_does_not_hide_a_shared_predicate": 10, + "test_english_compound_source_subject_requires_complete_exclusion_framing": 6, + "test_complete_known_suffix_lists_remain_explicit_exclusions": 8, + "test_new_resource_prefix_does_not_waive_ambiguous_postcomma_prose": 3, + "test_legacy_independent_postcomma_clause_preserves_explicit_exclusion": 2 }, "tests.unit_tests.services.dify_builder.test_resources": { "test_list_tenant_resources_normalizes_all_three_kinds": 1, diff --git a/api/services/dify_builder/plan.py b/api/services/dify_builder/plan.py index be6acebd570..6f4fd720b95 100644 --- a/api/services/dify_builder/plan.py +++ b/api/services/dify_builder/plan.py @@ -64,12 +64,25 @@ _RESOURCE_CLASS = ( r"(?:工具|插件|模型|文件|代码|知识库|知识检索|code|tools?|https?|agents?|plugins?|models?|files?|" r"knowledge[ _-]+(?:bases?|retrieval))" ) -# Only native resource classes prove a list. Free-form objects may contain a -# new affirmative predicate; an unknown list item must retain clarification. +_RESOURCE_LIST_SEPARATOR = r"(?:[,,、/&]|\band\b|\bor\b|和|或|以及|与)" +# Keep independent post-comma clauses only for the original bounded grammar. +# Expanded prefixes require a complete suffix list, never shared predicate prose. +_LEGACY_EXCLUDED_LIST_TAIL = re.compile( + rf"\s*(?:{_RESOURCE_MODIFIER}{_RESOURCE_CLASS}\s*{_RESOURCE_LIST_SEPARATOR}\s*)*{_RESOURCE_MODIFIER}" +) _EXCLUDED_LIST_TAIL = re.compile( - rf"\s*(?:{_RESOURCE_MODIFIER}{_RESOURCE_CLASS}\s*(?:[,,、/&]|\band\b|\bor\b|和|或|以及|与)\s*)*" + rf"\s*(?:{_RESOURCE_MODIFIER}(?:new\s+|新)?(?:资源|resources?|{_RESOURCE_CLASS})" + rf"\s*{_RESOURCE_LIST_SEPARATOR}\s*)*" rf"{_RESOURCE_MODIFIER}" ) +_SUFFIX_RESOURCE_ITEM = ( + rf"{_RESOURCE_MODIFIER}(?:new\s+|新)?" + rf"(?:资源|resources?|凭据|credentials?|(?:业务)?(?:账户|账号)|(?:business\s+)?accounts?|{_RESOURCE_CLASS})" +) +_EXCLUDED_LIST_SUFFIX = re.compile( + rf"\s*(?:{_RESOURCE_LIST_SEPARATOR}\s*{_SUFFIX_RESOURCE_ITEM}\s*)*(?=$|[。.!?!?;;\n])" +) +_INDEPENDENT_POSTCOMMA = re.compile(rf"\s*[,,](?!\s*{_SUFFIX_RESOURCE_ITEM})") _NEGATED_EXCLUSION = re.compile(r"不|无需|无须|禁止|禁用|避免|拒绝|\b(?:not|never|without|avoid|cannot|no|\w+n['’]t)\b") _SOURCE_IMPLEMENTATION = re.compile( r"\s*(?:的)?(?:检索)?(?:节点|配置|参数|绑定)|" @@ -117,11 +130,19 @@ def has_knowledge_source_cue(goal: str, requirements: object) -> bool: for cue in _KNOWLEDGE_CUE.finditer(text): if _SOURCE_IMPLEMENTATION.match(text[cue.end() :]): return True + suffix = text[cue.end() :] prefix = _SOURCE_SCOPE_BREAK.split(text[: cue.start()])[-1] if _CONDITIONAL_SOURCE.search(prefix + _SOURCE_SCOPE_BREAK.split(text[cue.end() :])[0]): return True excluded = any( _EXCLUDED_LIST_TAIL.fullmatch(prefix[exclusion.end() :]) + and ( + _EXCLUDED_LIST_SUFFIX.match(suffix) + or ( + _LEGACY_EXCLUDED_LIST_TAIL.fullmatch(prefix[exclusion.end() :]) + and _INDEPENDENT_POSTCOMMA.match(suffix) + ) + ) # An earlier negation in the same adjoining clause makes the # exclusion unproven (cannot avoid, 不可以不, 不需要禁止...). and not _NEGATED_EXCLUSION.search(re.split(r"[,,、]", prefix[: exclusion.start()])[-1]) diff --git a/api/tests/unit_tests/services/dify_builder/test_knowledge_discovery.py b/api/tests/unit_tests/services/dify_builder/test_knowledge_discovery.py index 7402b894931..2e25313c8c8 100644 --- a/api/tests/unit_tests/services/dify_builder/test_knowledge_discovery.py +++ b/api/tests/unit_tests/services/dify_builder/test_knowledge_discovery.py @@ -20,6 +20,8 @@ from tests.unit_tests.services.dify_builder.test_knowledge_source_cue import ( CAPTURED_AGENT_GOAL, CAPTURED_BARE_ROLE_REQUIREMENTS, CAPTURED_FORBIDDEN_ADDITIONS, + CAPTURED_MEETING_GOAL, + CAPTURED_MEETING_REQUIREMENTS, CAPTURED_NO_EXTRA_COMPONENTS, MEETING_GOAL, ) @@ -652,3 +654,60 @@ def test_full_captured_bare_resource_exclusion_reaches_plan_without_a_knowledge_ assert "knowledge_decision_pending" not in stored.resource_selection assert any(item.kind == "plan" for item in items) assert not any(item.kind == "form" for item in items) + + +def test_captured_meeting_model_only_confirmation_preserves_goal_without_a_knowledge_waiver() -> None: + plan = WorkflowPlan("Meeting minutes", "Input", "Output", ["Use the selected model"], used_resource_ids=[MODEL]) + context = DifyBuilderContext( + goal_text=CAPTURED_MEETING_GOAL, + requirements=CAPTURED_MEETING_REQUIREMENTS, + resource_selection={"knowledge_source_unavailable": False, "knowledge_requires_clarification": False}, + ) + result, stored, _, items = confirm(ReconciliationAgent(plan), offered=[], ids=[MODEL], context=context) + assert result.current_state == PcState.BUILD_PLAN_APPROVAL + assert stored.goal_text == CAPTURED_MEETING_GOAL + assert stored.requirements == CAPTURED_MEETING_REQUIREMENTS + assert stored.resource_selection["resource_ids"] == [MODEL] + assert "knowledge_decision_pending" not in stored.resource_selection + assert "knowledge_scope_decision" not in stored.resource_selection + assert any(item.kind == "plan" for item in items) + assert not any(item.kind == "form" for item in items) + + +@pytest.mark.parametrize("prefix", ["不使用工具、", "不需要任何新资源、工具、"]) +@pytest.mark.parametrize("suffix", ["仍需检索", "访问尚未决定", "不一定不用"]) +def test_nonbare_knowledge_item_keeps_handler_clarification_with_false_model_hint(prefix: str, suffix: str) -> None: + goal = prefix + "知识库" + suffix + plan = WorkflowPlan("Text summary", "Input", "Summary", ["Use the selected model"], used_resource_ids=[MODEL]) + context = DifyBuilderContext( + goal_text=goal, + requirements=CAPTURED_MEETING_REQUIREMENTS, + resource_selection={"knowledge_source_unavailable": False, "knowledge_requires_clarification": False}, + ) + result, stored, _, _ = confirm(ReconciliationAgent(plan), offered=[], ids=[MODEL], context=context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert stored.resource_selection["knowledge_decision_pending"]["original_goal"] == goal + assert not stored.checkpoint_id + + +@pytest.mark.parametrize( + "goal", + [ + "不需要新资源,知识库和文件仍需检索。", + "不需要新资源,知识库、文件仍需检索。", + "不需要新资源,知识库,文件仍需检索。", + "Do not use new resources, knowledge bases and files are still required.", + "不需要新资源,知识库和财务系统是否使用待确认。", + ], +) +def test_shared_source_predicate_keeps_handler_clarification_with_false_model_hint(goal: str) -> None: + plan = WorkflowPlan("Text summary", "Input", "Summary", ["Use the selected model"], used_resource_ids=[MODEL]) + context = DifyBuilderContext( + goal_text=goal, + requirements=CAPTURED_MEETING_REQUIREMENTS, + resource_selection={"knowledge_source_unavailable": False, "knowledge_requires_clarification": False}, + ) + result, stored, _, _ = confirm(ReconciliationAgent(plan), offered=[], ids=[MODEL], context=context) + assert result.current_state == PcState.BUILD_GOAL_ANALYSIS + assert stored.resource_selection["knowledge_decision_pending"]["original_goal"] == goal + assert not stored.checkpoint_id diff --git a/api/tests/unit_tests/services/dify_builder/test_knowledge_source_cue.py b/api/tests/unit_tests/services/dify_builder/test_knowledge_source_cue.py index 1bb0992196c..0efd4125bf6 100644 --- a/api/tests/unit_tests/services/dify_builder/test_knowledge_source_cue.py +++ b/api/tests/unit_tests/services/dify_builder/test_knowledge_source_cue.py @@ -12,6 +12,42 @@ MEETING_GOAL = ( ) +CAPTURED_MEETING_GOAL = ( + "创建一个专用的合成会议纪要验收 Workflow 草稿,名称" + "「Dev ENG1235 Meeting 439e」。先生成" + "计划供我审阅,不要执行测试或发布。只允许 Start → 一" + "个当前已经配置可用模型的 LLM → End,3 个节点、2" + " 条连线,不需要任何新资源、工具、Code、Agent、HT" + "TP、知识库、文件或凭据。Start 必填文本输入 meet" + "ing_notes。LLM 根据会议原文,用中文按顺序输出四" + "节:结论、待办、待确认事项、风险与阻塞。区分已决定与未决定;" + "对缺失负责人、日期、金额标注待确认,不编造;风险与阻塞只列原" + "文明示内容,未提及则写「原文未提及」。End 返回文本 mi" + "nutes,绑定 LLM 的 text。" +) +CAPTURED_MEETING_REQUIREMENTS = { + "mode": "workflow_draft_plan_only", + "publish": False, + "llm_model": "当前已经配置可用的模型", + "llm_rules": [ + "区分已决定与未决定", + "对缺失负责人、日期、金额标注待确认,不编造", + "风险与阻塞只列原文明示内容,未提及则写「原文未提及」", + ], + "edge_count": 2, + "node_count": 3, + "end_outputs": [{"type": "text", "variable": "minutes", "value_selector": "LLM.text"}], + "start_inputs": [{"type": "text", "required": True, "variable": "meeting_notes"}], + "allowed_nodes": ["Start", "LLM", "End"], + "execute_tests": False, + "workflow_name": "Dev ENG1235 Meeting 439e", + "excluded_resources": ["工具", "Code", "Agent", "HTTP", "知识库", "文件", "凭据"], + "llm_output_language": "中文", + "new_resources_allowed": False, + "llm_output_sections_in_order": ["结论", "待办", "待确认事项", "风险与阻塞"], +} + + CAPTURED_AGENT_GOAL = ( "请生成一个合同风险初筛 Workflow。Start 节点接收 contract_text" "(合同正文,文本,必填)和 jurisdiction(适用地区,文本,选填)。优先搜索当前" @@ -309,3 +345,113 @@ def test_bare_negative_roles_do_not_hide_nonlist_unknown_positive_or_nested_valu def test_positive_goal_still_dominates_a_bare_negative_resource_role() -> None: assert has_knowledge_source_cue("必须使用知识库回答", {"without": ["知识库"]}) + + +@pytest.mark.parametrize("requirements", [{}, None]) +def test_captured_meeting_goal_excludes_knowledge_without_a_goal_change(requirements: object) -> None: + assert not has_knowledge_source_cue( + CAPTURED_MEETING_GOAL, CAPTURED_MEETING_REQUIREMENTS if requirements is None else requirements + ) + + +@pytest.mark.parametrize( + "goal", + [ + "不需要任何新资源、工具、Code、Agent、HTTP、知识库、文件或凭据。", + "不需要资源、知识库。", + "不使用任何新工具和知识库。", + "不要添加新模型、文件或知识库。", + "Do not use any new resources, tools, HTTP or knowledge bases.", + "Do not add resources and knowledge bases.", + "Without new tools or knowledge bases.", + "Do not use any new models, files and knowledge bases.", + ], +) +def test_complete_preceding_resource_items_allow_bounded_new_descriptors(goal: str) -> None: + assert not has_knowledge_source_cue(goal, {}) + + +@pytest.mark.parametrize("prefix", ["不使用工具、", "不需要任何新资源、工具、"]) +@pytest.mark.parametrize("suffix", ["仍需检索", "访问尚未决定", "不一定不用", "A", "_archive", "-backup"]) +def test_excluded_knowledge_mention_requires_a_complete_bare_list_item(prefix: str, suffix: str) -> None: + assert has_knowledge_source_cue(prefix + "知识库" + suffix, {}) + + +@pytest.mark.parametrize("prefix", ["Do not use tools, ", "Do not use any new resources, tools, "]) +@pytest.mark.parametrize("suffix", [" still requires retrieval", " access is undecided", " may still be needed"]) +def test_english_source_suffix_prose_is_not_a_bare_excluded_item(prefix: str, suffix: str) -> None: + assert has_knowledge_source_cue(prefix + "knowledge bases" + suffix, {}) + + +@pytest.mark.parametrize("suffix", ["", "。", " 。", "、文件。", " 或文件。", ",文件。", "; format text"]) +def test_bare_excluded_item_accepts_end_punctuation_or_list_connector(suffix: str) -> None: + assert not has_knowledge_source_cue("不需要任何新资源、工具、知识库" + suffix, {}) + + +@pytest.mark.parametrize( + "goal", + [ + "不需要新知识库。", + "Do not use new knowledge bases.", + "不需要任何新财务系统、知识库。", + "Do not use new bespoke registries and knowledge bases.", + "不需要任何新资源、知识库节点。", + "不需要任何新资源、知识库配置。", + "不需要任何新资源、知识库,除非必要。", + "如果必要,不需要任何新资源、知识库。", + "不能不使用新资源、知识库。", + "不需要任何新资源、知识库。但必须使用知识库回答。", + "Do not use new resources, knowledge base nodes.", + "Do not use new resources, knowledge bases unless retrieval is needed.", + ], +) +def test_preceding_item_expansion_preserves_unknown_uncertain_and_affirmative_guards(goal: str) -> None: + assert has_knowledge_source_cue(goal, {}) + + +@pytest.mark.parametrize("prefix", ["不需要新资源,", "不使用工具、"]) +@pytest.mark.parametrize( + "suffix", + [ + "知识库和文件仍需检索。", + "知识库、文件仍需检索。", + "知识库,文件仍需检索。", + "知识库和财务系统是否使用待确认。", + "知识库和外部业务账户仍需访问。", + ], +) +def test_compound_source_subject_does_not_hide_a_shared_predicate(prefix: str, suffix: str) -> None: + assert has_knowledge_source_cue(prefix + suffix, {}) + + +@pytest.mark.parametrize("prefix", ["Do not use new resources, ", "Do not use tools, "]) +@pytest.mark.parametrize("connector", [" and ", " or ", ", "]) +def test_english_compound_source_subject_requires_complete_exclusion_framing(prefix: str, connector: str) -> None: + assert has_knowledge_source_cue(prefix + "knowledge bases" + connector + "files are still required.", {}) + + +@pytest.mark.parametrize("prefix", ["不需要新资源,", "不使用工具、"]) +@pytest.mark.parametrize( + "suffix", ["知识库。", "知识库、文件或凭据。", "知识库,文件,凭据。", "知识库、文件或外部业务账户。"] +) +def test_complete_known_suffix_lists_remain_explicit_exclusions(prefix: str, suffix: str) -> None: + assert not has_knowledge_source_cue(prefix + suffix, {}) + + +@pytest.mark.parametrize( + "goal", + [ + "不需要新资源,知识库,财务系统是否使用待确认。", + "不需要新资源,知识库,检索方案待确认。", + "Do not use new resources, knowledge bases, the bespoke registry is undecided.", + ], +) +def test_new_resource_prefix_does_not_waive_ambiguous_postcomma_prose(goal: str) -> None: + assert has_knowledge_source_cue(goal, {}) + + +@pytest.mark.parametrize( + "goal", ["不使用知识库,只整理用户文本。", "Do not use a knowledge base, format supplied text."] +) +def test_legacy_independent_postcomma_clause_preserves_explicit_exclusion(goal: str) -> None: + assert not has_knowledge_source_cue(goal, {})