diff --git a/api/core/dify_builder/handlers_build.py b/api/core/dify_builder/handlers_build.py index 2e993cd5b91..3227dfa65b5 100644 --- a/api/core/dify_builder/handlers_build.py +++ b/api/core/dify_builder/handlers_build.py @@ -12,6 +12,7 @@ import uuid from core.dify_builder.contract import ( AssistantTurnItem, + BuildLearningCard, ChallengeCard, ChangeSetCard, CheckpointCard, @@ -20,6 +21,7 @@ from core.dify_builder.contract import ( ErrorCard, FormCard, FormField, + NoticeItem, PlanCard, PublishCard, ResourceSelectCard, @@ -31,12 +33,21 @@ from core.dify_builder.contract import ( TraceStep, ) from core.dify_builder.handlers_fix import action_string, append_card, perform_revert -from core.dify_builder.models import ChangeSet, Checkpoint, DifyBuilderContext, Session, Snapshot, Turn +from core.dify_builder.models import ( + ChangeSet, + Checkpoint, + ConversationItem, + DifyBuilderContext, + Session, + Snapshot, + Turn, +) from core.dify_builder.runner import Env, Handler, StepResult from core.dify_builder.state import PcState __all__ = [ "build_registry", + "handle_await_learning", "handle_capability_check", "handle_execution", "handle_goal_analysis", @@ -58,6 +69,16 @@ def _emit_canvas(env: Env, event: str, **extra) -> None: env.emit_canvas({"event": event, **extra}) +def _emit_completion(fc: DifyBuilderContext) -> list[ConversationItem]: + """Shared build-complete summary, emitted on every governance-tail exit.""" + rows = [ + SummaryRow(label="Workflow", value="Start -> Knowledge -> LLM -> End"), + SummaryRow(label="Nodes", value=str(len(fc.built_node_ids))), + SummaryRow(label="Status", value="Complete"), + ] + return append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows)) + + _REQUIREMENT_FIELDS = [ FormField(key="report_types", label="Report types", type="text"), FormField(key="audience", label="Audience", type="text"), @@ -481,16 +502,42 @@ def handle_publish(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> def handle_governance_feedback(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult: - """(working, auto) Governance tail is spec-deferred (no body). As the - handler transitioning INTO the terminal build.complete, it emits the - completion summary. Reached both via publish and via keep_draft (which - skips publish), so the status row is scenario-neutral.""" - rows = [ - SummaryRow(label="Workflow", value="Start -> Knowledge -> LLM -> End"), - SummaryRow(label="Nodes", value=str(len(fc.built_node_ids))), - SummaryRow(label="Status", value="Complete"), - ] - items = append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows)) + """(working, auto) Governance tail: apply the skill-learning policy. + automatic -> learn + accepted card; disabled -> skipped card; ask -> + emit the pending prompt and rest at build.await_learning. Reached via both + publish and keep_draft (scenario-neutral).""" + policy = fc.skill_learning_policy or "ask" + if policy == "automatic": + descriptor = env.agent.learn_from_build( + fc.goal_text, dict(fc.requirements), list(fc.plan_items), list(fc.built_node_ids) + ) + items = append_card(fc, BuildLearningCard(policy="automatic", state="accepted")) + items += append_card(fc, NoticeItem(text=descriptor)) + items += _emit_completion(fc) + return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items) + if policy == "disabled": + items = append_card(fc, BuildLearningCard(policy="disabled", state="skipped")) + items += _emit_completion(fc) + return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items) + # ask (default): prompt, then rest for the user's accept/skip. + items = append_card(fc, BuildLearningCard(policy="ask", state="pending")) + return StepResult(next=PcState.BUILD_AWAIT_LEARNING, context=fc, items=items) + + +def handle_await_learning(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult: + """(waiting) Resolve the ask-policy skill-learning prompt. accept_learning + -> learn + accepted decision; anything else (skip_learning / absent) -> + skipped. Either way emit the completion summary and reach build.complete.""" + kind = turn.action.kind if turn.action is not None else "" + if kind == "accept_learning": + descriptor = env.agent.learn_from_build( + fc.goal_text, dict(fc.requirements), list(fc.plan_items), list(fc.built_node_ids) + ) + items = append_card(fc, DecisionItem(text="Accepted skill learning")) + items += append_card(fc, NoticeItem(text=descriptor)) + else: + items = append_card(fc, DecisionItem(text="Skipped skill learning")) + items += _emit_completion(fc) return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items) @@ -530,5 +577,6 @@ def build_registry() -> dict[PcState, Handler]: PcState.BUILD_REVIEW: handle_review, PcState.BUILD_PUBLISH: handle_publish, PcState.BUILD_GOVERNANCE_FEEDBACK: handle_governance_feedback, + PcState.BUILD_AWAIT_LEARNING: handle_await_learning, PcState.BUILD_REVERTED: handle_reverted, } diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index e5a39a5d20a..9fee1fa61fc 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -21,11 +21,11 @@ def _actor() -> Actor: return Actor(account_id="acc-1", tenant_id="tenant-1") -def _new_env(dify=None, emit_canvas=None) -> tuple[Env, InMemoryRepository]: +def _new_env(dify=None, emit_canvas=None, agent=None) -> tuple[Env, InMemoryRepository]: repo = InMemoryRepository() env = Env( dify=dify or FakeDifyPort(), - agent=PlaceholderAgent(), + agent=agent or PlaceholderAgent(), repo=repo, now=lambda: datetime.min, emit_canvas=emit_canvas, @@ -329,20 +329,97 @@ def test_publish_calls_dify_and_advances_to_governance_feedback(): assert {"event": "publish_workflow"} in events -def test_governance_feedback_emits_completion_summary_and_reaches_complete(): +def test_governance_automatic_learns_and_reaches_complete(): from core.dify_builder.handlers_build import handle_governance_feedback + from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent - env, repo = _new_env() + env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent()) s = _seed_build_session( repo, PcState.BUILD_GOVERNANCE_FEEDBACK, - built_node_ids=["start", "knowledge_retrieval", "llm", "end"], + skill_learning_policy="automatic", + built_node_ids=["a", "b"], ) res = handle_governance_feedback(env, Turn(actor=_actor()), *repo.get_session(s.id)) assert res.next == PcState.BUILD_COMPLETE - summary = next(i for i in res.items if i.kind == "summary") - assert summary.payload["variant"] == "completion" - assert summary.payload["rows"] + kinds = [i.kind for i in res.items] + assert "build_learning" in kinds and "summary" in kinds + assert env.agent.learn_calls == 1 # seam called for automatic + + +def test_governance_disabled_skips_and_reaches_complete(): + from core.dify_builder.handlers_build import handle_governance_feedback + from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent + + env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent()) + s = _seed_build_session( + repo, + PcState.BUILD_GOVERNANCE_FEEDBACK, + skill_learning_policy="disabled", + built_node_ids=["a"], + ) + res = handle_governance_feedback(env, Turn(actor=_actor()), *repo.get_session(s.id)) + assert res.next == PcState.BUILD_COMPLETE + # build_learning present with state skipped; seam NOT called + bl = [i for i in res.items if i.kind == "build_learning"][0] + assert bl.payload["state"] == "skipped" + assert getattr(env.agent, "learn_calls", 0) == 0 + + +def test_governance_ask_rests_at_await_learning_with_pending_card(): + from core.dify_builder.handlers_build import handle_governance_feedback + from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent + + env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent()) + s = _seed_build_session( + repo, + PcState.BUILD_GOVERNANCE_FEEDBACK, + skill_learning_policy="ask", + built_node_ids=["a"], + ) + res = handle_governance_feedback(env, Turn(actor=_actor()), *repo.get_session(s.id)) + assert res.next == PcState.BUILD_AWAIT_LEARNING + bl = [i for i in res.items if i.kind == "build_learning"][0] + assert bl.payload["policy"] == "ask" and bl.payload["state"] == "pending" + assert getattr(env.agent, "learn_calls", 0) == 0 # not learned until accepted + + +def test_await_learning_accept_learns_and_completes(): + from core.dify_builder.handlers_build import handle_await_learning + from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent + + env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent()) + s = _seed_build_session( + repo, + PcState.BUILD_AWAIT_LEARNING, + skill_learning_policy="ask", + built_node_ids=["a"], + ) + res = handle_await_learning( + env, Turn(action=Action(kind="accept_learning", base_version=1), actor=_actor()), *repo.get_session(s.id) + ) + assert res.next == PcState.BUILD_COMPLETE + kinds = [i.kind for i in res.items] + assert "decision" in kinds and "summary" in kinds + assert env.agent.learn_calls == 1 + + +def test_await_learning_skip_completes_without_learning(): + from core.dify_builder.handlers_build import handle_await_learning + from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent + + env, repo = _new_env(dify=FakeBuildDifyPort(), agent=StubAgent()) + s = _seed_build_session( + repo, + PcState.BUILD_AWAIT_LEARNING, + skill_learning_policy="ask", + built_node_ids=["a"], + ) + res = handle_await_learning( + env, Turn(action=Action(kind="skip_learning", base_version=1), actor=_actor()), *repo.get_session(s.id) + ) + assert res.next == PcState.BUILD_COMPLETE + assert getattr(env.agent, "learn_calls", 0) == 0 def test_reverted_retry_returns_to_initial_plan(): @@ -372,6 +449,7 @@ def test_build_registry_covers_all_non_terminal_build_states(): PcState.BUILD_REVIEW, PcState.BUILD_PUBLISH, PcState.BUILD_GOVERNANCE_FEEDBACK, + PcState.BUILD_AWAIT_LEARNING, PcState.BUILD_REVERTED, } assert PcState.BUILD_COMPLETE not in build_registry() # terminal: no handler @@ -418,12 +496,17 @@ def test_full_build_flow_goal_to_complete(): out = runner.advance(s.id, Turn(action=Action(kind="run_test", base_version=out.version), actor=_actor())) assert out.current_state == PcState.BUILD_REVIEW - # 7) publish_workflow -> build.publish (auto) -> governance_feedback (auto) -> build.complete + # 7) publish_workflow -> build.publish (auto) -> governance_feedback (auto) + # -> rests at build.await_learning (default policy "ask") publish_action = Action(kind="publish_workflow", base_version=out.version) out = runner.advance(s.id, Turn(action=publish_action, actor=_actor())) - assert out.current_state == PcState.BUILD_COMPLETE + assert out.current_state == PcState.BUILD_AWAIT_LEARNING assert dify.published is True + # 8) skip_learning -> build.complete + out = runner.advance(s.id, Turn(action=Action(kind="skip_learning", base_version=out.version), actor=_actor())) + assert out.current_state == PcState.BUILD_COMPLETE + # ordered card stream: every Build card kind appears, seq-ordered. items = repo.list_conversation(s.id) kinds = [i.kind for i in items] @@ -470,6 +553,9 @@ def test_full_build_flow_keep_draft_reaches_complete_without_publish(): assert out.current_state == PcState.BUILD_REVIEW out = runner.advance(s.id, Turn(action=Action(kind="keep_draft", base_version=out.version), actor=_actor())) + assert out.current_state == PcState.BUILD_AWAIT_LEARNING # default policy "ask" + + out = runner.advance(s.id, Turn(action=Action(kind="skip_learning", base_version=out.version), actor=_actor())) assert out.current_state == PcState.BUILD_COMPLETE assert dify.published is False # keep_draft skips publish assert not any(i.kind == "publish" for i in repo.list_conversation(s.id))