mirror of
https://github.com/langgenius/dify.git
synced 2026-04-26 13:45:57 +08:00
Merge branch 'feat/agent-node-v2' into deploy/dev
This commit is contained in:
@ -49,6 +49,7 @@ from .model import (
|
||||
EndUser,
|
||||
IconType,
|
||||
InstalledApp,
|
||||
LLMGenerationDetail,
|
||||
Message,
|
||||
MessageAgentThought,
|
||||
MessageAnnotation,
|
||||
@ -155,6 +156,7 @@ __all__ = [
|
||||
"IconType",
|
||||
"InstalledApp",
|
||||
"InvitationCode",
|
||||
"LLMGenerationDetail",
|
||||
"LoadBalancingModelConfig",
|
||||
"Message",
|
||||
"MessageAgentThought",
|
||||
|
||||
@ -31,6 +31,8 @@ from .provider_ids import GenericProviderID
|
||||
from .types import LongText, StringUUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.app.entities.llm_generation_entities import LLMGenerationDetailData
|
||||
|
||||
from .workflow import Workflow
|
||||
|
||||
|
||||
@ -1201,6 +1203,18 @@ class Message(Base):
|
||||
.all()
|
||||
)
|
||||
|
||||
# FIXME (Novice) -- It's easy to cause N+1 query problem here.
|
||||
@property
|
||||
def generation_detail(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get LLM generation detail for this message.
|
||||
Returns the detail as a dictionary or None if not found.
|
||||
"""
|
||||
detail = db.session.query(LLMGenerationDetail).filter_by(message_id=self.id).first()
|
||||
if detail:
|
||||
return detail.to_dict()
|
||||
return None
|
||||
|
||||
@property
|
||||
def retriever_resources(self) -> Any:
|
||||
return self.message_metadata_dict.get("retriever_resources") if self.message_metadata else []
|
||||
@ -2091,3 +2105,87 @@ class TenantCreditPool(Base):
|
||||
|
||||
def has_sufficient_credits(self, required_credits: int) -> bool:
|
||||
return self.remaining_credits >= required_credits
|
||||
|
||||
|
||||
class LLMGenerationDetail(Base):
|
||||
"""
|
||||
Store LLM generation details including reasoning process and tool calls.
|
||||
|
||||
Association (choose one):
|
||||
- For apps with Message: use message_id (one-to-one)
|
||||
- For Workflow: use workflow_run_id + node_id (one run may have multiple LLM nodes)
|
||||
"""
|
||||
|
||||
__tablename__ = "llm_generation_details"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="llm_generation_detail_pkey"),
|
||||
sa.Index("idx_llm_generation_detail_message", "message_id"),
|
||||
sa.Index("idx_llm_generation_detail_workflow", "workflow_run_id", "node_id"),
|
||||
sa.CheckConstraint(
|
||||
"(message_id IS NOT NULL AND workflow_run_id IS NULL AND node_id IS NULL)"
|
||||
" OR "
|
||||
"(message_id IS NULL AND workflow_run_id IS NOT NULL AND node_id IS NOT NULL)",
|
||||
name="ck_llm_generation_detail_assoc_mode",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
# Association fields (choose one)
|
||||
message_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, unique=True)
|
||||
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Core data as JSON strings
|
||||
reasoning_content: Mapped[str | None] = mapped_column(LongText)
|
||||
tool_calls: Mapped[str | None] = mapped_column(LongText)
|
||||
sequence: Mapped[str | None] = mapped_column(LongText)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
|
||||
def to_domain_model(self) -> "LLMGenerationDetailData":
|
||||
"""Convert to Pydantic domain model with proper validation."""
|
||||
from core.app.entities.llm_generation_entities import LLMGenerationDetailData
|
||||
|
||||
return LLMGenerationDetailData(
|
||||
reasoning_content=json.loads(self.reasoning_content) if self.reasoning_content else [],
|
||||
tool_calls=json.loads(self.tool_calls) if self.tool_calls else [],
|
||||
sequence=json.loads(self.sequence) if self.sequence else [],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for API response."""
|
||||
return self.to_domain_model().to_response_dict()
|
||||
|
||||
@classmethod
|
||||
def from_domain_model(
|
||||
cls,
|
||||
data: "LLMGenerationDetailData",
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
message_id: str | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
) -> "LLMGenerationDetail":
|
||||
"""Create from Pydantic domain model."""
|
||||
# Enforce association mode at object creation time as well.
|
||||
message_mode = message_id is not None
|
||||
workflow_mode = workflow_run_id is not None or node_id is not None
|
||||
if message_mode and workflow_mode:
|
||||
raise ValueError("LLMGenerationDetail cannot set both message_id and workflow_run_id/node_id.")
|
||||
if not message_mode and not (workflow_run_id and node_id):
|
||||
raise ValueError("LLMGenerationDetail requires either message_id or workflow_run_id+node_id.")
|
||||
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=node_id,
|
||||
reasoning_content=json.dumps(data.reasoning_content) if data.reasoning_content else None,
|
||||
tool_calls=json.dumps([tc.model_dump() for tc in data.tool_calls]) if data.tool_calls else None,
|
||||
sequence=json.dumps([seg.model_dump() for seg in data.sequence]) if data.sequence else None,
|
||||
)
|
||||
|
||||
@ -57,6 +57,37 @@ from .types import EnumText, LongText, StringUUID
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_generation_outputs(outputs: Mapping[str, Any]) -> bool:
|
||||
if not outputs:
|
||||
return False
|
||||
|
||||
allowed_sequence_types = {"reasoning", "content", "tool_call"}
|
||||
|
||||
def valid_sequence_item(item: Mapping[str, Any]) -> bool:
|
||||
return isinstance(item, Mapping) and item.get("type") in allowed_sequence_types
|
||||
|
||||
def valid_value(value: Any) -> bool:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
|
||||
content = value.get("content")
|
||||
reasoning_content = value.get("reasoning_content")
|
||||
tool_calls = value.get("tool_calls")
|
||||
sequence = value.get("sequence")
|
||||
|
||||
return (
|
||||
isinstance(content, str)
|
||||
and isinstance(reasoning_content, list)
|
||||
and all(isinstance(item, str) for item in reasoning_content)
|
||||
and isinstance(tool_calls, list)
|
||||
and all(isinstance(item, Mapping) for item in tool_calls)
|
||||
and isinstance(sequence, list)
|
||||
and all(valid_sequence_item(item) for item in sequence)
|
||||
)
|
||||
|
||||
return all(valid_value(value) for value in outputs.values())
|
||||
|
||||
|
||||
class WorkflowType(StrEnum):
|
||||
"""
|
||||
Workflow Type Enum
|
||||
@ -664,6 +695,10 @@ class WorkflowRun(Base):
|
||||
def workflow(self):
|
||||
return db.session.query(Workflow).where(Workflow.id == self.workflow_id).first()
|
||||
|
||||
@property
|
||||
def outputs_as_generation(self):
|
||||
return is_generation_outputs(self.outputs_dict)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
@ -677,6 +712,7 @@ class WorkflowRun(Base):
|
||||
"inputs": self.inputs_dict,
|
||||
"status": self.status,
|
||||
"outputs": self.outputs_dict,
|
||||
"outputs_as_generation": self.outputs_as_generation,
|
||||
"error": self.error,
|
||||
"elapsed_time": self.elapsed_time,
|
||||
"total_tokens": self.total_tokens,
|
||||
|
||||
Reference in New Issue
Block a user