Merge branch 'feat/agent-node-v2' into deploy/dev

This commit is contained in:
Novice
2025-12-30 13:43:40 +08:00
61 changed files with 6527 additions and 1246 deletions

View File

@ -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",

View File

@ -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,
)

View File

@ -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,