fix(builder): preserve confirmed requirements in knowledge clarification

This commit is contained in:
Charles Yao committed 2026-11-01 08:07:26 -08:00
1 parent c620d136b0
commit 2f2c3f6d6c
8 files changed
+363 -2

No files matched your search

@@ -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,
+3
View File
@@ -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 = ""
@@ -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(
{
@@ -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 = ""
@@ -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
+28
View File
@@ -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."""
+3
View File
@@ -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", ""),
@@ -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