Files
2026-08-14 14:28:28 +08:00

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)