502 lines
19 KiB
Python
502 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
from astrbot.api import FunctionTool
|
|
from astrbot.core.agent.run_context import ContextWrapper
|
|
from astrbot.core.agent.tool import ToolExecResult
|
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
|
|
|
from .bridge_protocol import ErpBridgeError, LocalErpBridgeClient
|
|
from .attachment_extract import AttachmentProvenanceError
|
|
from .attachment_provenance import VerifiedAttachmentBundle
|
|
from .plan_chain import resolved_followup
|
|
from .purchase_tabular_binding import project_purchase_tabular_payload
|
|
from .purchase_vision_binding import bind_purchase_resolve_input
|
|
from .session_auth import (
|
|
bridge_client_session_id,
|
|
bridge_process_id,
|
|
bridge_process_started_at_unix_seconds,
|
|
bridge_session_scope_token,
|
|
is_authorized_session,
|
|
session_scope_matches,
|
|
)
|
|
|
|
|
|
def _result(value: dict[str, Any]) -> str:
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
|
|
|
|
def _failure(error: Exception) -> str:
|
|
if isinstance(error, AttachmentProvenanceError):
|
|
messages = {
|
|
"purchase_vision_binding_invalid": "采购图片识别结果无法与附件指纹绑定,请重新上传。",
|
|
"purchase_vision_documents_conflict": (
|
|
"多张采购图片的类型或发票头不一致,必须先人工核对。"
|
|
),
|
|
"purchase_vision_duplicate_lines_requires_review": (
|
|
"多张采购图片存在疑似重复明细,必须先人工核对。"
|
|
),
|
|
"purchase_vision_line_limit_exceeded": (
|
|
"合并后的采购图片明细超过 200 行,已停止生成采购计划。"
|
|
),
|
|
"purchase_vision_document_unsupported": (
|
|
"图片未被识别为采购发票或采购明细,已停止生成采购计划。"
|
|
),
|
|
"purchase_vision_clarification_required": (
|
|
"图片识别结果存在不确定字段,请先核对原件后再生成采购计划。"
|
|
),
|
|
"purchase_vision_content_incomplete": (
|
|
"图片缺少自动填报所需的发票头或明细字段,请补充清晰原件。"
|
|
),
|
|
"purchase_vision_source_order_ambiguous": (
|
|
"图片包含多个来源采购单且无法逐行确定,必须先人工选择。"
|
|
),
|
|
"purchase_vision_input_mismatch": (
|
|
"模型生成的金额、数量或明细与图片识别结果不一致,已阻止计划。"
|
|
),
|
|
"purchase_vision_page_limit_exceeded": (
|
|
"本次图片与 PDF 总识别页数超过 3 页,请拆分后逐次提交。"
|
|
),
|
|
"purchase_pdf_binding_invalid": (
|
|
"PDF 页图、识别结果或原文件摘要无法完整绑定,请重新上传原文件。"
|
|
),
|
|
"purchase_pdf_page_limit_exceeded": (
|
|
"PDF 自动填报最多处理 3 页,且本次全部附件总识别页数不能超过 3 页。"
|
|
),
|
|
"pdf_page_limit_exceeded": (
|
|
"PDF 超过 3 页,未进行部分识别;请拆分后逐次提交。"
|
|
),
|
|
"pdf_encrypted": (
|
|
"加密 PDF 不允许进入自动填报,请提供经授权的未加密副本。"
|
|
),
|
|
"pdf_page_count_invalid": (
|
|
"PDF 没有可验证页面,已停止生成采购计划。"
|
|
),
|
|
"pdf_page_dimensions_invalid": (
|
|
"PDF 页面尺寸异常,已停止渲染和采购计划。"
|
|
),
|
|
"pdf_render_output_too_large": (
|
|
"PDF 渲染结果超过安全上限,请拆分或降低原件复杂度。"
|
|
),
|
|
"pdf_renderer_unavailable": (
|
|
"受审 PDFium 渲染组件不可用,未降级为不安全文本抄录。"
|
|
),
|
|
"pdf_render_timeout": (
|
|
"PDF 受限渲染超时,子进程已回收,请检查原件或拆分页面。"
|
|
),
|
|
"pdf_render_failed": (
|
|
"PDF 无法在受限渲染器中安全处理,已停止生成采购计划。"
|
|
),
|
|
"purchase_file_manual_review_required": (
|
|
"该采购文件类型尚不能确定性映射字段,必须先人工复核。"
|
|
),
|
|
"purchase_tabular_schema_not_recognized": (
|
|
"CSV/XLSX 表头不符合受支持的采购模板,已停止生成采购计划。"
|
|
),
|
|
"purchase_tabular_content_truncated": (
|
|
"CSV/XLSX 明细已被安全截断,不能生成不完整的采购计划。"
|
|
),
|
|
"purchase_tabular_content_incomplete": (
|
|
"CSV/XLSX 缺少采购明细必填列或数值,已停止生成采购计划。"
|
|
),
|
|
"purchase_tabular_header_ambiguous": (
|
|
"CSV/XLSX 存在重复业务表头,无法确定字段归属。"
|
|
),
|
|
"purchase_tabular_header_conflict": (
|
|
"CSV/XLSX 多表之间的发票头信息不一致,必须人工核对。"
|
|
),
|
|
"purchase_tabular_duplicate_lines_requires_review": (
|
|
"XLSX 多工作表存在疑似重复采购明细,必须人工核对。"
|
|
),
|
|
"purchase_tabular_date_invalid": (
|
|
"CSV/XLSX 发票日期不是严格 YYYY-MM-DD。"
|
|
),
|
|
"purchase_tabular_decimal_invalid": (
|
|
"CSV/XLSX 金额、数量或税率格式不符合固定十进制合同。"
|
|
),
|
|
}
|
|
return _result(
|
|
{
|
|
"ok": False,
|
|
"error": {
|
|
"code": error.code,
|
|
"message": messages.get(
|
|
error.code,
|
|
"采购附件来源指纹无法安全确认,请重新上传不超过三份的有效附件。"
|
|
),
|
|
},
|
|
}
|
|
)
|
|
if isinstance(error, ErpBridgeError):
|
|
return _result({"ok": False, "error": {"code": error.code, "message": str(error)}})
|
|
return _result(
|
|
{
|
|
"ok": False,
|
|
"error": {
|
|
"code": "erp_bridge_unavailable",
|
|
"message": "ERP 命令桥暂时不可用,请确认桌面 ERP 已登录。",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def _trusted_bridge_session(
|
|
context: ContextWrapper[AstrAgentContext], session_prefix: str
|
|
) -> tuple[str, int, int, str] | None:
|
|
try:
|
|
event = context.context.event
|
|
session_id = event.get_session_id()
|
|
if not is_authorized_session(
|
|
session_id,
|
|
session_prefix,
|
|
event.get_platform_name(),
|
|
):
|
|
return None
|
|
process_id = bridge_process_id(session_id)
|
|
process_started_at = bridge_process_started_at_unix_seconds(session_id)
|
|
scope_token = bridge_session_scope_token(session_id)
|
|
if process_id is None or process_started_at is None or scope_token is None:
|
|
return None
|
|
return (
|
|
bridge_client_session_id(session_id),
|
|
process_id,
|
|
process_started_at,
|
|
scope_token,
|
|
)
|
|
except (AttributeError, TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _unauthorized() -> str:
|
|
return _result(
|
|
{
|
|
"ok": False,
|
|
"error": {
|
|
"code": "erp_tool_session_denied",
|
|
"message": "当前会话不是受信任的朗速 ERP 桌宠会话。",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def _module_state(value: dict[str, Any] | None) -> tuple[str, str, str] | None:
|
|
if value is None:
|
|
return None
|
|
return (
|
|
value["moduleCode"],
|
|
value["navigationCode"],
|
|
value["moduleName"],
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ErpContextSnapshot:
|
|
session_identity: tuple[str, str, str, str, str, bool]
|
|
ui_state: tuple[
|
|
str,
|
|
tuple[str, str, str] | None,
|
|
int,
|
|
bool,
|
|
tuple[tuple[str, str, str], ...],
|
|
]
|
|
|
|
|
|
def _context_snapshot(data: dict[str, Any]) -> _ErpContextSnapshot:
|
|
return _ErpContextSnapshot(
|
|
session_identity=(
|
|
data["databaseScopeFingerprint"],
|
|
data["userId"],
|
|
data["userName"],
|
|
data["accountBook"],
|
|
data["subSystemId"],
|
|
data["isAdministrator"],
|
|
),
|
|
ui_state=(
|
|
data["subSystemName"],
|
|
_module_state(data["activeModule"]),
|
|
data["openModuleCount"],
|
|
data["openModulesTruncated"],
|
|
tuple(_module_state(module) for module in data["openModules"]),
|
|
),
|
|
)
|
|
|
|
|
|
async def _verified_scope_context(
|
|
bridge: LocalErpBridgeClient,
|
|
client_session_id: str,
|
|
process_id: int,
|
|
process_started_at: int,
|
|
expected_scope_token: str,
|
|
previous: _ErpContextSnapshot | None = None,
|
|
) -> tuple[dict[str, Any], _ErpContextSnapshot]:
|
|
data = await bridge.call(
|
|
"context.get",
|
|
client_session_id=client_session_id,
|
|
target_process_id=process_id,
|
|
target_started_at_unix_seconds=process_started_at,
|
|
session_scope_token=expected_scope_token,
|
|
)
|
|
if not isinstance(data, dict) or not session_scope_matches(
|
|
expected_scope_token,
|
|
data,
|
|
):
|
|
code = "erp_session_scope_changed" if previous is not None else (
|
|
"erp_session_scope_mismatch"
|
|
)
|
|
raise ErpBridgeError(
|
|
code,
|
|
"当前 ERP 用户、账套、子系统或数据库已不属于本次批准的桌宠会话。",
|
|
)
|
|
snapshot = _context_snapshot(data)
|
|
if previous is not None and snapshot != previous:
|
|
raise ErpBridgeError(
|
|
"erp_session_scope_changed",
|
|
"ERP 会话、权限或界面在工具调用期间发生切换,结果已丢弃。",
|
|
)
|
|
return data, snapshot
|
|
|
|
|
|
def _purchase_documents(
|
|
bundle: VerifiedAttachmentBundle,
|
|
) -> tuple[str, ...]:
|
|
vision = iter(bundle.purchase_vision_documents)
|
|
files = iter(bundle.purchase_file_documents)
|
|
result: list[str] = []
|
|
try:
|
|
for receipt in bundle.receipts:
|
|
kind = receipt[0]
|
|
if kind == "image":
|
|
result.append(next(vision))
|
|
elif kind == "file":
|
|
result.append(project_purchase_tabular_payload(next(files)))
|
|
else:
|
|
raise AttachmentProvenanceError(
|
|
"attachment_provenance_unavailable"
|
|
)
|
|
next(vision)
|
|
raise AttachmentProvenanceError("attachment_provenance_unavailable")
|
|
except StopIteration:
|
|
pass
|
|
try:
|
|
next(files)
|
|
raise AttachmentProvenanceError("attachment_provenance_unavailable")
|
|
except StopIteration:
|
|
pass
|
|
if len(result) != len(bundle.receipts):
|
|
raise AttachmentProvenanceError("attachment_provenance_unavailable")
|
|
return tuple(result)
|
|
|
|
|
|
@dataclass
|
|
class ErpContextTool(FunctionTool[AstrAgentContext]):
|
|
name: str = "erp_get_context"
|
|
description: str = "读取当前登录 ERP 的用户、账套、子系统、活动模块和已打开模块;不读取业务明细。"
|
|
parameters: dict = field(default_factory=lambda: {"type": "object", "properties": {}})
|
|
bridge: LocalErpBridgeClient | None = field(default=None, repr=False)
|
|
session_prefix: str = field(default="lserp-pet-", repr=False)
|
|
|
|
async def call(
|
|
self, context: ContextWrapper[AstrAgentContext], **kwargs: Any
|
|
) -> ToolExecResult:
|
|
bridge_context = _trusted_bridge_session(context, self.session_prefix)
|
|
if bridge_context is None:
|
|
return _unauthorized()
|
|
(
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
) = bridge_context
|
|
try:
|
|
assert self.bridge is not None
|
|
data, _ = await _verified_scope_context(
|
|
self.bridge,
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
)
|
|
return _result({
|
|
"ok": True,
|
|
"data": data,
|
|
})
|
|
except Exception as error:
|
|
return _failure(error)
|
|
|
|
|
|
@dataclass
|
|
class ErpCapabilitiesTool(FunctionTool[AstrAgentContext]):
|
|
name: str = "erp_get_capabilities"
|
|
description: str = "列出当前 ERP 进程实际注册且受权限控制的命令、风险和确认要求。"
|
|
parameters: dict = field(default_factory=lambda: {"type": "object", "properties": {}})
|
|
bridge: LocalErpBridgeClient | None = field(default=None, repr=False)
|
|
session_prefix: str = field(default="lserp-pet-", repr=False)
|
|
|
|
async def call(
|
|
self, context: ContextWrapper[AstrAgentContext], **kwargs: Any
|
|
) -> ToolExecResult:
|
|
bridge_context = _trusted_bridge_session(context, self.session_prefix)
|
|
if bridge_context is None:
|
|
return _unauthorized()
|
|
(
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
) = bridge_context
|
|
try:
|
|
assert self.bridge is not None
|
|
_, before = await _verified_scope_context(
|
|
self.bridge,
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
)
|
|
data = await self.bridge.call(
|
|
"capabilities.list",
|
|
client_session_id=client_session_id,
|
|
target_process_id=process_id,
|
|
target_started_at_unix_seconds=process_started_at,
|
|
session_scope_token=expected_scope_token,
|
|
)
|
|
await _verified_scope_context(
|
|
self.bridge,
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
before,
|
|
)
|
|
return _result({
|
|
"ok": True,
|
|
"data": data,
|
|
})
|
|
except Exception as error:
|
|
return _failure(error)
|
|
|
|
|
|
@dataclass
|
|
class ErpPlanCommandTool(FunctionTool[AstrAgentContext]):
|
|
name: str = "erp_plan_command"
|
|
description: str = (
|
|
"生成 ERP 命令的只读服务端预览计划。此工具不能执行计划;"
|
|
"purchase.invoice.resolve、hr.leave.resolve、module.record.resolve-create "
|
|
"或 module.record.resolve-update "
|
|
"唯一解析成功后,会把服务器返回的 resolvedInput 原样自动续接固定后续计划;"
|
|
"计划必须回到朗速桌宠按钮并经 ERP 原生窗口确认。"
|
|
)
|
|
parameters: dict = field(
|
|
default_factory=lambda: {
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {
|
|
"type": "string",
|
|
"description": "必须来自 erp_get_capabilities 的完整命令名。",
|
|
},
|
|
"input": {
|
|
"type": "object",
|
|
"description": "符合该命令 schema 的结构化业务输入。",
|
|
},
|
|
},
|
|
"required": ["command", "input"],
|
|
"additionalProperties": False,
|
|
}
|
|
)
|
|
bridge: LocalErpBridgeClient | None = field(default=None, repr=False)
|
|
session_prefix: str = field(default="lserp-pet-", repr=False)
|
|
attachment_receipts: Callable[
|
|
[ContextWrapper[AstrAgentContext]],
|
|
Awaitable[VerifiedAttachmentBundle],
|
|
] | None = field(default=None, repr=False)
|
|
|
|
async def call(
|
|
self,
|
|
context: ContextWrapper[AstrAgentContext],
|
|
command: str,
|
|
input: dict[str, Any],
|
|
**kwargs: Any,
|
|
) -> ToolExecResult:
|
|
bridge_context = _trusted_bridge_session(context, self.session_prefix)
|
|
if bridge_context is None:
|
|
return _unauthorized()
|
|
(
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
) = bridge_context
|
|
try:
|
|
assert self.bridge is not None
|
|
correlation_id = uuid.uuid4().hex
|
|
trusted_input = dict(input)
|
|
if command == "purchase.invoice.resolve" and self.attachment_receipts:
|
|
attachment_bundle = await self.attachment_receipts(context)
|
|
if not isinstance(attachment_bundle, VerifiedAttachmentBundle):
|
|
raise AttachmentProvenanceError(
|
|
"attachment_provenance_unavailable"
|
|
)
|
|
trusted_input = bind_purchase_resolve_input(
|
|
trusted_input,
|
|
_purchase_documents(attachment_bundle),
|
|
)
|
|
# 来源信息只允许由本地 Tool 根据本次消息附件生成。模型提供的
|
|
# 同名字段无论有无附件都必须被覆盖或移除。
|
|
trusted_input.pop("sourceDocuments", None)
|
|
receipts = attachment_bundle.source_documents()
|
|
if receipts:
|
|
trusted_input["sourceDocuments"] = receipts
|
|
_, before = await _verified_scope_context(
|
|
self.bridge,
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
)
|
|
data = await self.bridge.call(
|
|
"command.plan",
|
|
{"command": command, "input": trusted_input},
|
|
client_session_id=client_session_id,
|
|
target_process_id=process_id,
|
|
target_started_at_unix_seconds=process_started_at,
|
|
correlation_id=correlation_id,
|
|
session_scope_token=expected_scope_token,
|
|
)
|
|
_, before = await _verified_scope_context(
|
|
self.bridge,
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
before,
|
|
)
|
|
followup = resolved_followup(command, data)
|
|
if followup is not None:
|
|
resolved_command, resolved_input = followup
|
|
data = await self.bridge.call(
|
|
"command.plan",
|
|
{"command": resolved_command, "input": resolved_input},
|
|
client_session_id=client_session_id,
|
|
target_process_id=process_id,
|
|
target_started_at_unix_seconds=process_started_at,
|
|
correlation_id=correlation_id,
|
|
session_scope_token=expected_scope_token,
|
|
)
|
|
await _verified_scope_context(
|
|
self.bridge,
|
|
client_session_id,
|
|
process_id,
|
|
process_started_at,
|
|
expected_scope_token,
|
|
before,
|
|
)
|
|
if isinstance(data, dict):
|
|
data["autoFollowedFrom"] = command
|
|
return _result({"ok": True, "data": data})
|
|
except Exception as error:
|
|
return _failure(error)
|