Files

409 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Plan proposals and the presentation snapshot shared by Build and Edit."""
import re
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from core.dify_builder.contract import PlanCard, ResourceOption
from core.dify_builder.models import DifyBuilderContext
from services.dify_builder.knowledge_source_intent import KnowledgeSourceIntent
PLAN_PRESENTATION_INSTRUCTION = (
"Give the plan a short, specific title describing its goal, without a step count. "
"Describe input_summary and output_summary in one concise natural-language sentence each. "
"They describe the entire resulting workflow after this plan is applied: what starts it or "
"what it receives, and what it returns or delivers. Include inputs and outputs that remain "
"unchanged. These are expected workflow behavior, not the edit request, node configuration, "
"a JSON schema, sample data, or a claim that the workflow has already run. "
"Do not invent inputs, destinations, external effects, or results absent from the source. "
'Reply with ONLY JSON: {"title": "...", "input_summary": "...", '
'"output_summary": "...", "plan": ["step", ...]}.'
" Keep at most 30 steps, 2000 characters per step/summary, 200 characters in the title, "
"and 24000 characters across the complete plan."
)
@dataclass
class WorkflowPlan:
title: str
input_summary: str
output_summary: str
items: list[str]
# Set only by resource reconciliation; unused selections must not force a
# generated action. None retains the ordinary abstract/Edit plan contract.
used_resource_ids: list[str] | None = None
# Server-owned, nonexecuting knowledge preferences; never graph bindings.
discovery_resource_ids: list[str] | None = None
# Optional cognition metadata, not source confirmation or resource authority.
knowledge_source_intent: KnowledgeSourceIntent | None = None
@dataclass
class BuildClarification:
"""Editable recommendations, never a plan or confirmed business answers."""
fields: list[dict[str, Any]]
values: dict[str, Any]
kind: Literal["clarification"] = field(default="clarification", init=False)
knowledge_source_intent: KnowledgeSourceIntent | None = None
class _ClarificationField(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
key: str = Field(min_length=1, max_length=200)
label: str = Field(min_length=1, max_length=2000)
type: Literal["text", "textarea", "select", "bool", "number"]
options: list[str] = Field(default_factory=list, max_length=30)
required: Literal[True] = True
hint: str | None = Field(default=None, max_length=2000)
placeholder: str | None = Field(default=None, max_length=2000)
class _ClarificationResponse(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
kind: Literal["clarification"]
fields: list[_ClarificationField] = Field(min_length=1, max_length=6)
values: dict[str, str | bool | int | float | None]
@model_validator(mode="after")
def validate_answers(self) -> "_ClarificationResponse":
"""Accept only scalar recommendations matching unique visible controls."""
keys = [spec.key for spec in self.fields]
if len(set(keys)) != len(keys) or set(self.values) - set(keys):
raise ValueError("Clarification values must name unique visible fields.")
for spec in self.fields:
value = self.values.get(spec.key)
if not spec.key.strip() or not spec.label.strip():
raise ValueError("Clarification keys and labels must be nonblank.")
if spec.type == "select" and (not spec.options or any(not option.strip() for option in spec.options)):
raise ValueError("A select question needs nonblank options.")
if value is not None and not (
(spec.type in {"text", "textarea", "select"} and isinstance(value, str))
or (spec.type == "bool" and isinstance(value, bool))
or (spec.type == "number" and type(value) in {int, float})
):
raise ValueError("Clarification recommendations must match their scalar controls.")
if spec.type == "select" and value not in (None, "") and value not in spec.options:
raise ValueError("A select recommendation must match an offered option.")
return self
def parse_build_plan_response(data: object) -> WorkflowPlan | BuildClarification:
"""Decode a Build outcome; malformed questions cannot become an approved plan.
Edit continues to use parse_plan_response. Clarifications contain only
required scalar controls and recommendations; submission owns confirmation.
"""
if isinstance(data, dict) and data.get("kind") == "clarification":
try:
response = _ClarificationResponse.model_validate(data)
except ValidationError:
raise ValueError(
"Clarification requires 1-6 valid required scalar questions and matching recommendations."
) from None
return BuildClarification(
fields=[
spec.model_dump(exclude_none=True, exclude_defaults=True) | {"required": True}
for spec in response.fields
],
values=dict(response.values),
)
if isinstance(data, dict) and any(key in data for key in ("fields", "values")):
raise ValueError("Build questions require a clarification outcome without a plan.")
return parse_plan_response(data)
class ResourceDiscovery(list[ResourceOption]): # noqa: FURB189 - preserve the existing list-valued agent port
"""Server facts; model hints may ask a question, never waive a goal."""
def __init__(
self,
options: list[ResourceOption],
*,
knowledge_source_unavailable: bool = False,
knowledge_requires_clarification: bool = False,
) -> None:
super().__init__(options)
self.knowledge_source_unavailable = knowledge_source_unavailable
self.knowledge_requires_clarification = knowledge_requires_clarification
_KNOWLEDGE_CUE = re.compile(r"知识库|知识检索|knowledge[ _-]+(?:bases?|retrieval)", re.IGNORECASE)
_SOURCE_SCOPE_BREAK = re.compile(r"[。.!?!?;;\n]|\b(?:but|however|yet|instead|then)\b|但是|不过|然而|而是|但")
_SOURCE_EXCLUSION = re.compile(
r"不(?:要)?(?:使用|用|依赖|访问|查询|检索|增加|添加)|(?:无需|无须|不需要)(?:使用|用|依赖|访问|查询|检索|增加|添加)?|"
r"(?:禁止|禁用|排除)(?:使用|访问|查询|检索|增加|添加)?|"
r"\b(?:do not|don't|must not|should not|never)\s+(?:use|query|access|include|add|rely on|retrieve from)\s+|"
r"\b(?:without|exclude|excluding|no need for)\s+|\bno need to\s+(?:use|query|access)\s+|"
r"\bavoid\s+(?:using|querying|accessing)\s+"
)
_RESOURCE_MODIFIER = r"(?:(?:a|an|the|any|external|existing|workspace|other|外部|现有|已有|任何|工作区|其他)\s*)*"
_RESOURCE_CLASS = (
r"(?:工具|插件|模型|文件|代码|知识库|知识检索|code|tools?|https?|agents?|plugins?|models?|files?|"
r"knowledge[ _-]+(?:bases?|retrieval))"
)
_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}(?: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*(?:的)?(?:检索)?(?:节点|配置|参数|绑定)|"
r"\s+(?:retrieval\s+)?(?:nodes?|configuration|settings|parameters?|bindings?)\b"
)
_CONDITIONAL_SOURCE = re.compile(r"\b(?:if|unless|except)\b|除非|如果|若")
# A complete identifier role, not an arbitrary negative substring or node list.
_EXCLUDED_RESOURCE_LIST_ROLE = re.compile(
r"(?:forbidden|excluded|disallowed|no|without)"
r"(?:_(?:(?:extra|additional|other)_)?(?:resources|components|additions))?"
)
def _requirement_texts(value: object) -> Iterator[str]:
# Ordinary JSON keys and booleans do not request a source.
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, child in value.items():
if isinstance(key, str) and _EXCLUDED_RESOURCE_LIST_ROLE.fullmatch(key) and isinstance(child, list):
# Only exact native categories inherit this explicit list role.
# Unknown prose and nested children may still request retrieval.
for member in child:
if isinstance(member, str) and re.fullmatch(_RESOURCE_CLASS, member.strip(), re.IGNORECASE):
continue
yield from _requirement_texts(member)
else:
yield from _requirement_texts(child)
elif isinstance(value, (list, tuple)):
for child in value:
yield from _requirement_texts(child)
def has_knowledge_source_cue(goal: str, requirements: object) -> bool:
"""Ask for affirmative/uncertain sources, not an explicitly excluded resource.
Only a local use/access exclusion (including a resource list) discounts a
mention. Node/configuration restrictions and double negatives cannot waive
retrieval. Any remaining mention, including a positive requirement elsewhere,
still asks; this does not override offered resources or other source guards.
"""
for text in (goal, *_requirement_texts(requirements)):
text = text.casefold()
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])
for exclusion in _SOURCE_EXCLUSION.finditer(prefix)
)
if not excluded:
return True
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."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
class PlanValidationError(ValueError):
"""Existing plan refusal with value-free facts for optional diagnostics."""
def __init__(
self,
message: str,
*,
reason_code: str,
path: tuple[str, ...],
observed_count: int | None = None,
limit_count: int | None = None,
) -> None:
"""Accept validator-owned facts; retain ValueError behavior and safe feedback.
Postcondition: detail contains only the supplied category, schema path,
and optional aggregate counts. Projection remains the privacy boundary.
"""
super().__init__(message)
self.detail: dict[str, Any] = {"stage": "plan", "reason_code": reason_code, "path": path}
if observed_count is not None and limit_count is not None:
self.detail.update(observed_count=observed_count, limit_count=limit_count)
def parse_plan_response(data: object) -> WorkflowPlan:
"""Decode an untrusted proposal without changing acceptance or quoting values.
Postcondition: return the complete valid proposal or raise a ValueError
carrying only validator-owned diagnostic facts; perform no I/O or retries.
"""
if not isinstance(data, dict):
raise PlanValidationError(
f"The response must be an object, received {type(data).__name__}.", reason_code="type", path=()
)
if "plan" not in data:
raise PlanValidationError("The required plan field is missing.", reason_code="missing", path=("plan",))
plan = data["plan"]
if not isinstance(plan, list):
raise PlanValidationError(
f"The plan field must be a nonempty list of strings, received {type(plan).__name__}.",
reason_code="type",
path=("plan",),
)
if not plan:
raise PlanValidationError(
"The plan field must contain at least one nonblank string.",
reason_code="empty",
path=("plan",),
observed_count=0,
limit_count=1,
)
if len(plan) > 30:
raise PlanValidationError(
"The plan exceeds the 30-step limit.",
reason_code="step_count",
path=("plan",),
observed_count=len(plan),
limit_count=30,
)
items: list[str] = []
for index, item in enumerate(plan):
if not isinstance(item, str):
raise PlanValidationError(
f"The plan[{index}] field must be a nonblank string, received {type(item).__name__}.",
reason_code="type",
path=("plan", "*"),
)
if not item.strip():
raise PlanValidationError(
f"The plan[{index}] field is blank; every item must be a nonblank string.",
reason_code="blank",
path=("plan", "*"),
observed_count=0,
limit_count=1,
)
if len(item) > 2000:
raise PlanValidationError(
"A plan step exceeds the 2000-character limit.",
reason_code="step_length",
path=("plan", "*"),
observed_count=len(item),
limit_count=2000,
)
items.append(item)
overview: dict[str, str] = {}
for key in ("title", "input_summary", "output_summary"):
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise PlanValidationError(
f"The {key} field must be a nonblank string.",
reason_code="missing" if key not in data else "type" if not isinstance(value, str) else "blank",
path=(key,),
observed_count=0 if isinstance(value, str) else None,
limit_count=1 if isinstance(value, str) else None,
)
if len(value) > (200 if key == "title" else 2000):
raise PlanValidationError(
f"The {key} field exceeds its character limit.",
reason_code="overview_length",
path=(key,),
observed_count=len(value),
limit_count=200 if key == "title" else 2000,
)
overview[key] = value
if sum(map(len, [*items, *overview.values()])) > 24_000:
raise PlanValidationError(
"The complete plan exceeds the 24000-character limit.",
reason_code="total_length",
path=(),
observed_count=sum(map(len, [*items, *overview.values()])),
limit_count=24000,
)
return WorkflowPlan(
title=overview["title"],
input_summary=overview["input_summary"],
output_summary=overview["output_summary"],
items=items,
)
def store_plan(context: DifyBuilderContext, plan: WorkflowPlan) -> None:
"""Replace steps and their overview together, including on replanning."""
context.plan_items = list(plan.items)
context.plan_title = plan.title
context.plan_input_summary = plan.input_summary
context.plan_output_summary = plan.output_summary
def make_plan_card(context: DifyBuilderContext) -> PlanCard:
"""Freeze the overview alongside the final, resource-bound step list."""
return PlanCard(
title=context.plan_title,
input_summary=context.plan_input_summary,
output_summary=context.plan_output_summary,
items=list(context.plan_items),
)