feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -24,6 +24,8 @@ COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimburs
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
|
||||
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx"
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
@@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
@@ -301,6 +304,35 @@ class AgentFoundationAssetSeedMixin:
|
||||
"rule_template_label": "通信费报销 Excel 模板",
|
||||
},
|
||||
)
|
||||
company_preapproval_rule = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宣",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
published_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
working_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
},
|
||||
)
|
||||
skill_expense_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.SKILL.value,
|
||||
code="skill.expense.summary_lookup",
|
||||
@@ -468,6 +500,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
*platform_risk_assets,
|
||||
company_travel_rule,
|
||||
company_communication_rule,
|
||||
company_preapproval_rule,
|
||||
skill_expense_asset,
|
||||
skill_ar_asset,
|
||||
invoice_mcp_asset,
|
||||
@@ -495,6 +528,11 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self._hide_deprecated_finance_rule_assets()
|
||||
|
||||
@@ -581,6 +619,18 @@ class AgentFoundationAssetSeedMixin:
|
||||
change_note="初始化通信费报销 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_preapproval_rule.name,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
metadata=company_preapproval_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化费用申请审批 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=skill_expense_asset,
|
||||
version="v1.0.0",
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
@@ -25,6 +26,8 @@ from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
@@ -115,6 +118,10 @@ class AgentFoundationAssetTopUpMixin:
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
|
||||
)
|
||||
|
||||
company_preapproval_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE)
|
||||
)
|
||||
|
||||
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
||||
|
||||
attachment_rule = self._create_seed_asset(
|
||||
@@ -392,6 +399,36 @@ class AgentFoundationAssetTopUpMixin:
|
||||
},
|
||||
)
|
||||
|
||||
if COMPANY_PREAPPROVAL_RULE_CODE not in existing_codes:
|
||||
|
||||
company_preapproval_rule = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宣",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
},
|
||||
)
|
||||
|
||||
if company_travel_rule is not None:
|
||||
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
|
||||
if not str(company_travel_rule.current_version or "").strip():
|
||||
@@ -536,6 +573,77 @@ class AgentFoundationAssetTopUpMixin:
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if company_preapproval_rule is not None:
|
||||
company_preapproval_rule.scenario_json = list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
|
||||
if not str(company_preapproval_rule.current_version or "").strip():
|
||||
company_preapproval_rule.current_version = COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
if not str(company_preapproval_rule.working_version or "").strip():
|
||||
company_preapproval_rule.working_version = company_preapproval_rule.current_version
|
||||
if not str(company_preapproval_rule.published_version or "").strip():
|
||||
company_preapproval_rule.published_version = company_preapproval_rule.current_version
|
||||
if not str(company_preapproval_rule.status or "").strip():
|
||||
company_preapproval_rule.status = AgentAssetStatus.ACTIVE.value
|
||||
|
||||
company_preapproval_rule.description = (
|
||||
"通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。"
|
||||
)
|
||||
company_preapproval_rule.config_json = {
|
||||
**(company_preapproval_rule.config_json or {}),
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
}
|
||||
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
company_preapproval_rule,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
company_preapproval_rule,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_preapproval_rule.name,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
metadata=company_preapproval_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化费用申请审批 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if (
|
||||
str(company_preapproval_rule.current_version or "").strip()
|
||||
== COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
):
|
||||
self._ensure_asset_review(
|
||||
company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
reviewer="顾承宣",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版费用申请审批规则表已确认,可作为财务规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
self._hide_deprecated_finance_rule_assets()
|
||||
|
||||
if "skill.ar.aging_summary" not in existing_codes:
|
||||
|
||||
@@ -82,10 +82,14 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",)
|
||||
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",)
|
||||
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON = ("费用申请",)
|
||||
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
|
||||
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
@@ -19,6 +21,7 @@ from app.services.agent_asset_spreadsheet import (
|
||||
)
|
||||
from app.services.agent_foundation_constants import (
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
)
|
||||
from app.services.finance_rule_catalog import (
|
||||
@@ -54,6 +57,14 @@ class AgentFoundationSpreadsheetMixin:
|
||||
expense_types=["communication"],
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="费用申请审批规则",
|
||||
expense_types=["meal", "entertainment", "office", "all"],
|
||||
)
|
||||
)
|
||||
return synced_count
|
||||
|
||||
def _ensure_core_finance_rule_asset(
|
||||
@@ -92,14 +103,19 @@ class AgentFoundationSpreadsheetMixin:
|
||||
asset.status = AgentAssetStatus.DISABLED.value
|
||||
asset.scenario_json = ["已废弃"]
|
||||
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
|
||||
if replacement
|
||||
else (
|
||||
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
|
||||
)
|
||||
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。"
|
||||
)
|
||||
else:
|
||||
deprecated_reason = (
|
||||
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
|
||||
"不再作为独立财务规则表展示。"
|
||||
)
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"enabled": False,
|
||||
@@ -258,6 +274,93 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
)
|
||||
|
||||
def _ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
|
||||
self,
|
||||
|
||||
asset: AgentAsset,
|
||||
|
||||
*,
|
||||
|
||||
version: str,
|
||||
|
||||
actor_name: str,
|
||||
|
||||
):
|
||||
|
||||
return self._ensure_finance_rule_spreadsheet_seed(
|
||||
|
||||
asset,
|
||||
|
||||
version=version,
|
||||
|
||||
actor_name=actor_name,
|
||||
|
||||
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
|
||||
fallback_sheet_name="费用申请审批规则",
|
||||
|
||||
workbook_sheets=[
|
||||
(
|
||||
"费用申请审批规则",
|
||||
[
|
||||
[
|
||||
"费用类型代码",
|
||||
"费用类型",
|
||||
"触发条件",
|
||||
"阈值金额",
|
||||
"前置要求",
|
||||
"审批要求",
|
||||
"风险动作",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
"meal/entertainment",
|
||||
"业务招待费",
|
||||
"单次费用金额大于 500 元",
|
||||
500,
|
||||
"必须先提交费用申请单,并说明客户、参与人和招待事由",
|
||||
"申请单需按审批链完成审批后方可报销",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"适配 meal 与 entertainment 两个本体费用类型",
|
||||
],
|
||||
[
|
||||
"office",
|
||||
"办公用品费",
|
||||
"单次或批量采购金额大于 2000 元",
|
||||
2000,
|
||||
"必须先提交办公采购或费用申请单",
|
||||
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"覆盖办公用品、办公耗材、低值易耗品等场景",
|
||||
],
|
||||
[
|
||||
"all",
|
||||
"通用大额费用",
|
||||
"任意费用金额大于 2000 元",
|
||||
2000,
|
||||
"必须进入费用申请和审批流程",
|
||||
"至少完成直属领导审批;按预算和财务规则继续流转",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"差旅、通信等已有专项规则时可同时适用专项规则",
|
||||
],
|
||||
],
|
||||
),
|
||||
(
|
||||
"字段说明",
|
||||
[
|
||||
["字段", "说明"],
|
||||
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
|
||||
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
|
||||
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
|
||||
["审批要求", "说明申请单进入审批链后的最低审批要求"],
|
||||
["风险动作", "说明报销阶段未满足规则时的系统处理"],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
def _read_or_build_company_travel_rule_file(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -24,6 +25,34 @@ logger = get_logger("app.services.agent_runs")
|
||||
|
||||
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
|
||||
LIST_ROUTE_FIELDS = (
|
||||
("route_job_type", "job_type"),
|
||||
("route_task_type", "task_type"),
|
||||
("route_task_code", "task_code"),
|
||||
("route_task_name", "task_name"),
|
||||
("route_task_title", "task_title"),
|
||||
("route_asset_name", "asset_name"),
|
||||
("route_selected_agent", "selected_agent"),
|
||||
("route_phase", "phase"),
|
||||
("route_stage", "stage"),
|
||||
("route_report_type", "report_type"),
|
||||
("route_snapshot_key", "snapshot_key"),
|
||||
("route_folder", "folder"),
|
||||
("route_heartbeat_at", "heartbeat_at"),
|
||||
)
|
||||
LIST_ONTOLOGY_FIELDS = (
|
||||
("ontology_scenario", "scenario"),
|
||||
("ontology_intent", "intent"),
|
||||
("ontology_parse_strategy", "parse_strategy"),
|
||||
)
|
||||
LIST_PROGRESS_FIELDS = {
|
||||
"percent",
|
||||
"total_documents",
|
||||
"completed_documents",
|
||||
"failed_documents",
|
||||
"skipped_documents",
|
||||
"current_stage",
|
||||
}
|
||||
|
||||
|
||||
class AgentRunService:
|
||||
@@ -41,8 +70,22 @@ class AgentRunService:
|
||||
) -> list[AgentRunRead]:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs()
|
||||
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
||||
return [self._serialize_run(item) for item in runs]
|
||||
rows = self.repository.list_light(
|
||||
agent=agent,
|
||||
status=status,
|
||||
source=source,
|
||||
limit=limit,
|
||||
)
|
||||
tool_calls_by_run_id = self._group_light_tool_calls(
|
||||
self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows])
|
||||
)
|
||||
return [
|
||||
self._serialize_run_list_item(
|
||||
item,
|
||||
tool_calls_by_run_id.get(str(item["run_id"]), []),
|
||||
)
|
||||
for item in rows
|
||||
]
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRunRead | None:
|
||||
self._ensure_ready()
|
||||
@@ -435,3 +478,99 @@ class AgentRunService:
|
||||
if semantic_parse
|
||||
else None,
|
||||
)
|
||||
|
||||
def _serialize_run_list_item(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
tool_calls: list[dict[str, Any]],
|
||||
) -> AgentRunRead:
|
||||
return AgentRunRead(
|
||||
id=str(row["id"]),
|
||||
run_id=str(row["run_id"]),
|
||||
agent=str(row["agent"]),
|
||||
source=str(row["source"]),
|
||||
user_id=row.get("user_id"),
|
||||
task_id=row.get("task_id"),
|
||||
ontology_json=self._build_list_ontology_json(row),
|
||||
route_json=self._build_list_route_json(row),
|
||||
permission_level=str(row["permission_level"]),
|
||||
status=str(row["status"]),
|
||||
result_summary=row.get("result_summary"),
|
||||
error_message=row.get("error_message"),
|
||||
started_at=row["started_at"],
|
||||
finished_at=row.get("finished_at"),
|
||||
tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls],
|
||||
semantic_parse=None,
|
||||
)
|
||||
|
||||
def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for source_key, target_key in LIST_ROUTE_FIELDS:
|
||||
self._set_if_present(payload, target_key, row.get(source_key))
|
||||
|
||||
progress = self._coerce_json_object(row.get("route_progress"))
|
||||
compact_progress = {
|
||||
key: value
|
||||
for key, value in progress.items()
|
||||
if key in LIST_PROGRESS_FIELDS and self._is_scalar_json_value(value)
|
||||
}
|
||||
if compact_progress:
|
||||
payload["progress"] = compact_progress
|
||||
return payload
|
||||
|
||||
def _build_list_ontology_json(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for source_key, target_key in LIST_ONTOLOGY_FIELDS:
|
||||
self._set_if_present(payload, target_key, row.get(source_key))
|
||||
return payload
|
||||
|
||||
def _serialize_light_tool_call(self, row: dict[str, Any]) -> AgentToolCallRead:
|
||||
return AgentToolCallRead(
|
||||
id=str(row["id"]),
|
||||
run_id=str(row["run_id"]),
|
||||
tool_type=str(row["tool_type"]),
|
||||
tool_name=str(row["tool_name"]),
|
||||
request_json={},
|
||||
response_json={},
|
||||
status=str(row["status"]),
|
||||
duration_ms=int(row.get("duration_ms") or 0),
|
||||
error_message=row.get("error_message"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _group_light_tool_calls(
|
||||
tool_calls: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for tool_call in tool_calls:
|
||||
grouped.setdefault(str(tool_call.get("run_id") or ""), []).append(tool_call)
|
||||
return grouped
|
||||
|
||||
@staticmethod
|
||||
def _coerce_json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if normalized.startswith("{") and normalized.endswith("}"):
|
||||
try:
|
||||
loaded = json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _set_if_present(payload: dict[str, Any], key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return
|
||||
if not AgentRunService._is_scalar_json_value(value):
|
||||
return
|
||||
payload[key] = value
|
||||
|
||||
@staticmethod
|
||||
def _is_scalar_json_value(value: Any) -> bool:
|
||||
return value is None or isinstance(value, str | int | float | bool)
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -30,7 +30,7 @@ BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
|
||||
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,)
|
||||
ARCHIVED_REIMBURSEMENT_STAGES = (
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
@@ -67,24 +67,31 @@ class ExpenseClaimAccessPolicy:
|
||||
normalized_type == "application",
|
||||
normalized_type.like("%\\_application", escape="\\"),
|
||||
)
|
||||
return or_(
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
),
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
reimbursement_condition = and_(
|
||||
~application_condition,
|
||||
or_(
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
application_archive_condition = and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
)
|
||||
return or_(
|
||||
reimbursement_condition,
|
||||
application_archive_condition,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
|
||||
@@ -96,8 +103,6 @@ class ExpenseClaimAccessPolicy:
|
||||
def is_archived_claim(claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
is_application_claim = (
|
||||
@@ -105,11 +110,9 @@ class ExpenseClaimAccessPolicy:
|
||||
or normalized_type == "application"
|
||||
or normalized_type.endswith("_application")
|
||||
)
|
||||
if (
|
||||
is_application_claim
|
||||
and normalized_status in ARCHIVED_CLAIM_STATUSES
|
||||
and stage in APPLICATION_ARCHIVED_STAGES
|
||||
):
|
||||
if is_application_claim:
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in APPLICATION_ARCHIVED_STAGES
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
|
||||
|
||||
|
||||
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
"expense_application": "other",
|
||||
"application": "other",
|
||||
}
|
||||
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationHandoffMixin:
|
||||
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
|
||||
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
|
||||
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}。"
|
||||
return draft_claim
|
||||
|
||||
@staticmethod
|
||||
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
|
||||
application_ids: set[str] = set()
|
||||
application_nos: set[str] = set()
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
source = str(flag.get("source") or "").strip()
|
||||
has_application_reference = any(
|
||||
str(flag.get(key) or "").strip()
|
||||
for key in (
|
||||
"application_claim_id",
|
||||
"applicationClaimId",
|
||||
"application_claim_no",
|
||||
"applicationClaimNo",
|
||||
)
|
||||
)
|
||||
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
|
||||
continue
|
||||
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
|
||||
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
|
||||
if application_id:
|
||||
application_ids.add(application_id)
|
||||
if application_no:
|
||||
application_nos.add(application_no)
|
||||
return application_ids, application_nos
|
||||
|
||||
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
|
||||
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
|
||||
conditions = []
|
||||
if application_ids:
|
||||
conditions.append(ExpenseClaim.id.in_(application_ids))
|
||||
if application_nos:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
|
||||
if not conditions:
|
||||
return []
|
||||
|
||||
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
|
||||
return [claim for claim in claims if self._is_expense_application_claim(claim)]
|
||||
|
||||
def _archive_linked_applications_after_reimbursement_paid(
|
||||
self,
|
||||
*,
|
||||
reimbursement_claim: ExpenseClaim,
|
||||
payment_flag: dict[str, Any],
|
||||
operator: str,
|
||||
current_user: Any,
|
||||
) -> list[dict[str, str]]:
|
||||
archived_applications: list[dict[str, str]] = []
|
||||
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
|
||||
for application_claim in self._find_linked_application_claims(reimbursement_claim):
|
||||
previous_status = str(application_claim.status or "").strip()
|
||||
previous_stage = str(application_claim.approval_stage or "").strip()
|
||||
if previous_stage == APPLICATION_ARCHIVE_STAGE:
|
||||
continue
|
||||
|
||||
normalized_status = previous_status.lower()
|
||||
if normalized_status not in {"approved", "completed"}:
|
||||
continue
|
||||
|
||||
before_json = self._serialize_claim(application_claim)
|
||||
archive_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "application_archive_sync",
|
||||
"event_type": "expense_application_archived_by_reimbursement",
|
||||
"archive_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "申请归档",
|
||||
"message": (
|
||||
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
|
||||
"系统同步将申请单归档。"
|
||||
),
|
||||
"operator": operator,
|
||||
"operator_username": getattr(current_user, "username", ""),
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in getattr(current_user, "role_codes", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
"reimbursement_claim_id": reimbursement_claim.id,
|
||||
"reimbursement_claim_no": reimbursement_claim.claim_no,
|
||||
"payment_event_id": payment_event_id,
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": "approved",
|
||||
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"expense_application",
|
||||
)
|
||||
application_claim.status = "approved"
|
||||
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
|
||||
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
|
||||
archived_applications.append(
|
||||
{
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": str(application_claim.claim_no or "").strip(),
|
||||
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
|
||||
}
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_application.archive_by_reimbursement",
|
||||
resource_type="expense_claim",
|
||||
resource_id=application_claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(application_claim),
|
||||
)
|
||||
|
||||
return archived_applications
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
@@ -62,7 +62,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
if merged_budget_approval:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
@@ -73,7 +73,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
if requires_budget_review:
|
||||
@@ -99,7 +99,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "预算管理者审核通过"
|
||||
if is_application_claim:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
@@ -186,7 +186,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and next_stage == APPROVAL_DONE_STAGE:
|
||||
if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE:
|
||||
if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
|
||||
claim,
|
||||
@@ -289,6 +289,15 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"reimbursement",
|
||||
)
|
||||
|
||||
archived_applications = self._archive_linked_applications_after_reimbursement_paid(
|
||||
reimbursement_claim=claim,
|
||||
payment_flag=payment_flag,
|
||||
operator=operator,
|
||||
current_user=current_user,
|
||||
)
|
||||
if archived_applications:
|
||||
payment_flag["archived_application_claims"] = archived_applications
|
||||
|
||||
claim.status = PAYMENT_PAID_STATUS
|
||||
claim.approval_stage = PAYMENT_PAID_STAGE
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]
|
||||
|
||||
@@ -63,6 +63,8 @@ def build_platform_risk_flag(
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(),
|
||||
"finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any
|
||||
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -73,6 +75,8 @@ CANONICAL_APPROVAL_STAGES = {
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
@@ -214,8 +218,10 @@ def _approved_stage(raw_stage: str, is_application_claim: bool) -> str:
|
||||
stage = _normalize_stage_alias(raw_stage)
|
||||
lowered = str(raw_stage or "").strip().lower()
|
||||
if is_application_claim:
|
||||
if not stage or lowered == "completed":
|
||||
return APPROVAL_DONE_STAGE
|
||||
if stage == APPLICATION_ARCHIVE_STAGE:
|
||||
return APPLICATION_ARCHIVE_STAGE
|
||||
if not stage or lowered == "completed" or stage == APPROVAL_DONE_STAGE:
|
||||
return APPLICATION_LINK_STATUS_STAGE
|
||||
return stage
|
||||
if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}:
|
||||
return stage
|
||||
|
||||
@@ -2,6 +2,8 @@ DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批"
|
||||
BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
|
||||
FINANCE_APPROVAL_STAGE = "财务审批"
|
||||
APPROVAL_DONE_STAGE = "审批完成"
|
||||
APPLICATION_LINK_STATUS_STAGE = "关联单据状态"
|
||||
APPLICATION_ARCHIVE_STAGE = "申请归档"
|
||||
ARCHIVE_ACCOUNTING_STAGE = "归档入账"
|
||||
PAYMENT_PENDING_STATUS = "pending_payment"
|
||||
PAYMENT_PAID_STATUS = "paid"
|
||||
|
||||
@@ -858,7 +858,7 @@ class ExpenseClaimService(
|
||||
self._release_budget_for_delete(claim, current_user)
|
||||
self._delete_claim_analysis_records(resource_id)
|
||||
self._attachment_storage.delete_claim_files(claim)
|
||||
ReceiptFolderService().delete_receipts_for_claim(resource_id)
|
||||
ReceiptFolderService().unlink_receipts_for_claim(resource_id)
|
||||
self.db.delete(claim)
|
||||
self.db.commit()
|
||||
|
||||
@@ -1021,4 +1021,3 @@ class ExpenseClaimService(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
)
|
||||
|
||||
DEPRECATED_FINANCE_RULE_CODES = (
|
||||
"rule.expense.company_transport_hotel_detail_reimbursement",
|
||||
@@ -17,4 +20,6 @@ DEPRECATED_FINANCE_RULE_REPLACEMENTS = {
|
||||
"rule.expense.company_transport_hotel_detail_reimbursement": (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
),
|
||||
"rule.expense.company_meal_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
"rule.expense.company_office_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -26,10 +24,23 @@ class HermesEmployeeProfileScannerService:
|
||||
summary["baseline_summary"] = baseline_summary
|
||||
logger.info(
|
||||
"Hermes employee profile scan completed: %s",
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
self._build_log_summary(summary),
|
||||
)
|
||||
return summary
|
||||
|
||||
def _build_log_summary(self, summary: dict) -> dict:
|
||||
baseline_summary = self._as_dict(summary.get("baseline_summary"))
|
||||
buckets = baseline_summary.get("buckets")
|
||||
return {
|
||||
"target_employee_count": self._to_int(summary.get("target_employee_count")),
|
||||
"snapshot_count": self._to_int(summary.get("snapshot_count")),
|
||||
"high_attention_employee_count": self._to_int(
|
||||
summary.get("high_attention_employee_count")
|
||||
),
|
||||
"window_days": summary.get("window_days") or [],
|
||||
"baseline_bucket_count": len(buckets) if isinstance(buckets, list) else 0,
|
||||
}
|
||||
|
||||
def _build_baseline_summary(self) -> dict:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
@@ -42,3 +53,14 @@ class HermesEmployeeProfileScannerService:
|
||||
for claim in self.db.scalars(stmt).all()
|
||||
]
|
||||
return ProfileBaselineUpdater().build_from_claims(claims).as_dict()
|
||||
|
||||
@staticmethod
|
||||
def _as_dict(value: object) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: object) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -17,6 +20,7 @@ from app.services.document_intelligence import DocumentIntelligenceService
|
||||
|
||||
WORKER_JSON_PREFIX = "__OCR_JSON__="
|
||||
SUPPORTED_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".pdf"}
|
||||
OCR_RESULT_CACHE_LIMIT = 32
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -50,6 +54,12 @@ class AggregatedOcrDocument:
|
||||
|
||||
|
||||
class OcrService:
|
||||
_cache_lock = threading.Lock()
|
||||
_result_cache: OrderedDict[str, OcrRecognizeDocumentRead] = OrderedDict()
|
||||
_worker_semaphore_lock = threading.Lock()
|
||||
_worker_semaphore: threading.Semaphore | None = None
|
||||
_worker_semaphore_limit = 0
|
||||
|
||||
def __init__(self, db: Session | None = None) -> None:
|
||||
self.settings = get_settings()
|
||||
self.document_intelligence_service = DocumentIntelligenceService(db)
|
||||
@@ -70,6 +80,7 @@ class OcrService:
|
||||
python_bin = self._resolve_python_bin()
|
||||
worker_path = self._resolve_worker_path()
|
||||
worker_payload: dict = {}
|
||||
cache_keys_by_source: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
for filename, content, media_type in files:
|
||||
@@ -109,6 +120,16 @@ class OcrService:
|
||||
)
|
||||
continue
|
||||
|
||||
cache_key = self._build_cache_key(content)
|
||||
cached_document = self._read_cached_document(
|
||||
cache_key,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
)
|
||||
if cached_document is not None:
|
||||
documents.append(cached_document)
|
||||
continue
|
||||
|
||||
temp_path = temp_root / f"{uuid4().hex}{suffix}"
|
||||
temp_path.write_bytes(content)
|
||||
cleanup_paths.append(temp_path)
|
||||
@@ -116,15 +137,16 @@ class OcrService:
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||
prepared_inputs.extend(
|
||||
self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
pdf_inputs = self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
prepared_inputs.extend(pdf_inputs)
|
||||
for item in pdf_inputs:
|
||||
cache_keys_by_source.setdefault(item.source_key, cache_key)
|
||||
except RuntimeError as exc:
|
||||
documents.append(
|
||||
OcrRecognizeDocumentRead(
|
||||
@@ -135,10 +157,11 @@ class OcrService:
|
||||
)
|
||||
continue
|
||||
|
||||
source_key = uuid4().hex
|
||||
prepared_inputs.append(
|
||||
PreparedOcrInput(
|
||||
input_path=temp_path,
|
||||
source_key=uuid4().hex,
|
||||
source_key=source_key,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
preview_kind="image" if resolved_media_type.startswith("image/") else "",
|
||||
@@ -149,6 +172,7 @@ class OcrService:
|
||||
),
|
||||
)
|
||||
)
|
||||
cache_keys_by_source[source_key] = cache_key
|
||||
|
||||
if prepared_inputs:
|
||||
worker_payload = self._invoke_worker(
|
||||
@@ -156,11 +180,15 @@ class OcrService:
|
||||
worker_path=worker_path,
|
||||
input_paths=[item.input_path for item in prepared_inputs],
|
||||
)
|
||||
documents.extend(
|
||||
self._build_documents(
|
||||
worker_documents=worker_payload.get("documents", []),
|
||||
prepared_inputs=prepared_inputs,
|
||||
)
|
||||
recognized_documents = self._build_documents(
|
||||
worker_documents=worker_payload.get("documents", []),
|
||||
prepared_inputs=prepared_inputs,
|
||||
)
|
||||
documents.extend(recognized_documents)
|
||||
self._write_cached_documents(
|
||||
recognized_documents,
|
||||
prepared_inputs=prepared_inputs,
|
||||
cache_keys_by_source=cache_keys_by_source,
|
||||
)
|
||||
|
||||
success_count = sum(
|
||||
@@ -215,6 +243,79 @@ class OcrService:
|
||||
raise RuntimeError(f"OCR worker 不存在:{worker_path}")
|
||||
return str(worker_path)
|
||||
|
||||
def _build_cache_key(self, content: bytes) -> str:
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
return "|".join(
|
||||
[
|
||||
self.settings.ocr_language,
|
||||
self.settings.ocr_text_detection_model,
|
||||
self.settings.ocr_text_recognition_model,
|
||||
digest,
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _read_cached_document(
|
||||
cls,
|
||||
cache_key: str,
|
||||
*,
|
||||
filename: str,
|
||||
media_type: str,
|
||||
) -> OcrRecognizeDocumentRead | None:
|
||||
if not cache_key:
|
||||
return None
|
||||
with cls._cache_lock:
|
||||
cached = cls._result_cache.get(cache_key)
|
||||
if cached is None:
|
||||
return None
|
||||
cls._result_cache.move_to_end(cache_key)
|
||||
return cached.model_copy(update={"filename": filename, "media_type": media_type})
|
||||
|
||||
@classmethod
|
||||
def _write_cached_documents(
|
||||
cls,
|
||||
documents: list[OcrRecognizeDocumentRead],
|
||||
*,
|
||||
prepared_inputs: list[PreparedOcrInput],
|
||||
cache_keys_by_source: dict[str, str],
|
||||
) -> None:
|
||||
if not documents or not cache_keys_by_source:
|
||||
return
|
||||
|
||||
source_order: list[str] = []
|
||||
seen_sources: set[str] = set()
|
||||
for item in prepared_inputs:
|
||||
if item.source_key in seen_sources:
|
||||
continue
|
||||
seen_sources.add(item.source_key)
|
||||
source_order.append(item.source_key)
|
||||
|
||||
with cls._cache_lock:
|
||||
for source_key, document in zip(source_order, documents, strict=False):
|
||||
cache_key = cache_keys_by_source.get(source_key, "")
|
||||
if not cache_key:
|
||||
continue
|
||||
cls._result_cache[cache_key] = document.model_copy(
|
||||
update={
|
||||
"receipt_id": "",
|
||||
"receipt_status": "",
|
||||
"receipt_preview_url": "",
|
||||
"receipt_source_url": "",
|
||||
}
|
||||
)
|
||||
cls._result_cache.move_to_end(cache_key)
|
||||
while len(cls._result_cache) > OCR_RESULT_CACHE_LIMIT:
|
||||
cls._result_cache.popitem(last=False)
|
||||
|
||||
@classmethod
|
||||
def _resolve_worker_semaphore(cls, limit: int) -> threading.Semaphore:
|
||||
normalized_limit = max(1, int(limit or 1))
|
||||
with cls._worker_semaphore_lock:
|
||||
if cls._worker_semaphore is None or cls._worker_semaphore_limit != normalized_limit:
|
||||
cls._worker_semaphore = threading.Semaphore(normalized_limit)
|
||||
cls._worker_semaphore_limit = normalized_limit
|
||||
return cls._worker_semaphore
|
||||
|
||||
def _invoke_worker(
|
||||
self,
|
||||
*,
|
||||
@@ -235,13 +336,15 @@ class OcrService:
|
||||
for path in input_paths:
|
||||
command.extend(["--input", str(path)])
|
||||
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.settings.ocr_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
semaphore = self._resolve_worker_semaphore(self.settings.ocr_max_concurrent_workers)
|
||||
with semaphore:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.settings.ocr_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()
|
||||
raise RuntimeError(f"OCR 执行失败:{detail or 'worker 返回非 0 状态码。'}")
|
||||
|
||||
@@ -336,11 +336,11 @@ class ReceiptFolderService:
|
||||
shutil.rmtree(receipt_dir)
|
||||
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
|
||||
|
||||
def delete_receipts_for_claim(self, claim_id: str) -> int:
|
||||
def unlink_receipts_for_claim(self, claim_id: str) -> int:
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
if not normalized_claim_id:
|
||||
return 0
|
||||
deleted_count = 0
|
||||
unlinked_count = 0
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
for meta_path in list(self.root.glob("*/*/meta.json")):
|
||||
try:
|
||||
@@ -349,9 +349,18 @@ class ReceiptFolderService:
|
||||
continue
|
||||
if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id:
|
||||
continue
|
||||
shutil.rmtree(meta_path.parent, ignore_errors=True)
|
||||
deleted_count += 1
|
||||
return deleted_count
|
||||
meta["status"] = "unlinked"
|
||||
meta["linked_claim_id"] = ""
|
||||
meta["linked_claim_no"] = ""
|
||||
meta["linked_item_id"] = ""
|
||||
meta["linked_at"] = ""
|
||||
meta["updated_at"] = datetime.now(UTC).isoformat()
|
||||
self._write_meta(meta_path.parent, meta)
|
||||
unlinked_count += 1
|
||||
return unlinked_count
|
||||
|
||||
def delete_receipts_for_claim(self, claim_id: str) -> int:
|
||||
return self.unlink_receipts_for_claim(claim_id)
|
||||
|
||||
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
|
||||
meta = self._read_receipt_meta(receipt_id, current_user)
|
||||
|
||||
@@ -603,6 +603,8 @@ class RiskRuleTemplateExecutor:
|
||||
)
|
||||
if normalized.startswith("attachment."):
|
||||
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
|
||||
if normalized.startswith("application."):
|
||||
return self._resolve_application_values(normalized.removeprefix("application."), claim)
|
||||
if normalized.startswith("budget."):
|
||||
return self._resolve_budget_values(normalized.removeprefix("budget."), contexts)
|
||||
return []
|
||||
@@ -714,6 +716,99 @@ class RiskRuleTemplateExecutor:
|
||||
values.append(budget_context.get(key))
|
||||
return self._normalize_values(values)
|
||||
|
||||
def _resolve_application_values(self, field_key: str, claim: ExpenseClaim) -> list[str]:
|
||||
values: list[Any] = []
|
||||
normalized_key = str(field_key or "").strip()
|
||||
alias_map = {
|
||||
"id": (
|
||||
"application_claim_id",
|
||||
"applicationClaimId",
|
||||
"application_id",
|
||||
"applicationId",
|
||||
"claim_id",
|
||||
"claimId",
|
||||
"id",
|
||||
),
|
||||
"claim_no": (
|
||||
"application_claim_no",
|
||||
"applicationClaimNo",
|
||||
"application_no",
|
||||
"applicationNo",
|
||||
"claim_no",
|
||||
"claimNo",
|
||||
"no",
|
||||
),
|
||||
"status": ("application_status", "applicationStatus", "status"),
|
||||
"approved_amount": (
|
||||
"application_approved_amount",
|
||||
"applicationApprovedAmount",
|
||||
"approved_amount",
|
||||
"approvedAmount",
|
||||
"application_amount",
|
||||
"applicationAmount",
|
||||
"amount",
|
||||
),
|
||||
"amount": (
|
||||
"application_amount",
|
||||
"applicationAmount",
|
||||
"approved_amount",
|
||||
"approvedAmount",
|
||||
"amount",
|
||||
),
|
||||
"expense_type": (
|
||||
"application_expense_type",
|
||||
"applicationExpenseType",
|
||||
"expense_type",
|
||||
"expenseType",
|
||||
),
|
||||
"department_name": (
|
||||
"application_department_name",
|
||||
"applicationDepartmentName",
|
||||
"department_name",
|
||||
"departmentName",
|
||||
),
|
||||
"reason": ("application_reason", "applicationReason", "reason"),
|
||||
}
|
||||
lookup_keys = alias_map.get(
|
||||
normalized_key,
|
||||
(normalized_key, normalized_key.replace("_", ""), normalized_key.replace("_", "-")),
|
||||
)
|
||||
for source in self._iter_application_contexts(claim):
|
||||
for key in lookup_keys:
|
||||
if key in source and source.get(key) not in (None, ""):
|
||||
values.append(source.get(key))
|
||||
return self._normalize_values(values)
|
||||
|
||||
@staticmethod
|
||||
def _iter_application_contexts(claim: ExpenseClaim) -> list[dict[str, Any]]:
|
||||
contexts: list[dict[str, Any]] = []
|
||||
application_sources = {"application_detail", "application_handoff", "application_link"}
|
||||
nested_keys = (
|
||||
"application_detail",
|
||||
"applicationDetail",
|
||||
"review_form_values",
|
||||
"reviewFormValues",
|
||||
"expense_scene_selection",
|
||||
"expenseSceneSelection",
|
||||
)
|
||||
for flag in list(getattr(claim, "risk_flags_json", None) or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
source = str(flag.get("source") or "").strip()
|
||||
has_application_anchor = (
|
||||
source in application_sources
|
||||
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
|
||||
or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
|
||||
)
|
||||
if not has_application_anchor:
|
||||
continue
|
||||
contexts.append(flag)
|
||||
for key in nested_keys:
|
||||
nested = flag.get(key)
|
||||
if isinstance(nested, dict):
|
||||
contexts.append(nested)
|
||||
return contexts
|
||||
|
||||
def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
|
||||
values: list[Any] = []
|
||||
for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:
|
||||
|
||||
@@ -229,10 +229,9 @@ class StewardModelPlanBuilder:
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_function_call",
|
||||
stage="llm_function_call",
|
||||
title="意图识别智能体接管",
|
||||
title="拆解财务事项",
|
||||
content=(
|
||||
"已调用系统主模型的 submit_steward_intent_plan 工具,"
|
||||
"把用户话术转换为可校验的结构化财务任务计划。"
|
||||
"我正在把这句话拆成可执行的财务事项,并检查每一项应该进入申请流程还是报销流程。"
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -255,6 +254,10 @@ class StewardModelPlanBuilder:
|
||||
)
|
||||
if len(events) == 1:
|
||||
events.extend(self.planner._build_thinking_events(tasks, attachment_groups, attachments)[1:])
|
||||
else:
|
||||
gap_event = self.planner._build_business_gap_thinking_event(tasks)
|
||||
if gap_event:
|
||||
events.append(gap_event)
|
||||
return events
|
||||
|
||||
def _sanitize_model_missing_fields(
|
||||
|
||||
@@ -52,6 +52,39 @@ REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报
|
||||
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
|
||||
ISO_DATE_PATTERN = re.compile(r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?")
|
||||
|
||||
BUSINESS_FIELD_LABELS = {
|
||||
"expense_type": "费用类型",
|
||||
"time_range": "时间",
|
||||
"location": "地点",
|
||||
"reason": "事由",
|
||||
"amount": "金额",
|
||||
"transport_mode": "出行方式",
|
||||
"attachments": "附件/凭证",
|
||||
"customer_name": "客户或项目对象",
|
||||
"merchant_name": "商户/开票方",
|
||||
"department_name": "所属部门",
|
||||
"employee_name": "申请人",
|
||||
"employee_no": "员工编号",
|
||||
}
|
||||
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"travel": "差旅",
|
||||
"transport": "交通费",
|
||||
"entertainment": "业务招待费",
|
||||
"office": "办公用品",
|
||||
"meeting": "会议费",
|
||||
"training": "培训费",
|
||||
"other": "其他费用",
|
||||
}
|
||||
|
||||
TRANSPORT_MODE_LABELS = {
|
||||
"train": "火车/高铁",
|
||||
"flight": "飞机",
|
||||
"taxi": "出租车/网约车",
|
||||
"subway": "地铁",
|
||||
"other": "其他交通方式",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlannedTaskDraft:
|
||||
@@ -372,6 +405,8 @@ class StewardPlannerService:
|
||||
required = ["expense_type", "time_range", "reason"]
|
||||
if task_type == "expense_application":
|
||||
required.append("location")
|
||||
if fields.get("expense_type") in {"travel", "transport"}:
|
||||
required.append("transport_mode")
|
||||
return [key for key in required if not str(fields.get(key) or "").strip()]
|
||||
|
||||
@staticmethod
|
||||
@@ -543,10 +578,13 @@ class StewardPlannerService:
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_ontology_mapping",
|
||||
stage="ontology_mapping",
|
||||
title="映射业务本体字段",
|
||||
title="核对业务要素",
|
||||
content=ontology_summary,
|
||||
),
|
||||
]
|
||||
gap_event = self._build_business_gap_thinking_event(tasks)
|
||||
if gap_event:
|
||||
events.append(gap_event)
|
||||
if attachments:
|
||||
events.append(
|
||||
StewardThinkingEvent(
|
||||
@@ -580,23 +618,82 @@ class StewardPlannerService:
|
||||
if fields.get("location"):
|
||||
anchors.append(fields["location"])
|
||||
if fields.get("expense_type"):
|
||||
anchors.append(fields["expense_type"])
|
||||
anchors.append(StewardPlannerService._format_business_field_value("expense_type", fields["expense_type"]))
|
||||
anchor_text = "、".join(anchors) if anchors else "待补充关键字段"
|
||||
parts.append(f"{task_label}:{task.title}({anchor_text})")
|
||||
return ";".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str:
|
||||
canonical_keys = []
|
||||
missing_keys = []
|
||||
mapped_labels = []
|
||||
missing_labels = []
|
||||
for task in tasks:
|
||||
canonical_keys.extend(task.ontology_fields.keys())
|
||||
missing_keys.extend(task.missing_fields)
|
||||
unique_keys = sorted({item for item in canonical_keys if item})
|
||||
unique_missing = sorted({item for item in missing_keys if item})
|
||||
mapped = "、".join(unique_keys) if unique_keys else "暂无稳定字段"
|
||||
missing = ";缺失字段:" + "、".join(unique_missing) if unique_missing else ""
|
||||
return f"已使用 canonical ontology fields:{mapped}{missing}。兼容字段只作为输入别名,不直接进入业务逻辑。"
|
||||
mapped_labels.extend(StewardPlannerService._business_field_label(key) for key in task.ontology_fields.keys())
|
||||
missing_labels.extend(StewardPlannerService._business_field_label(key) for key in task.missing_fields)
|
||||
mapped = "、".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素"
|
||||
missing = ";还缺少:" + "、".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else ""
|
||||
return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。"
|
||||
|
||||
@staticmethod
|
||||
def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None:
|
||||
gap_lines = []
|
||||
for task in tasks:
|
||||
if not task.missing_fields:
|
||||
continue
|
||||
missing_labels = [
|
||||
StewardPlannerService._business_field_label(key)
|
||||
for key in task.missing_fields
|
||||
if key
|
||||
]
|
||||
if not missing_labels:
|
||||
continue
|
||||
if task.task_type == "expense_application" and "transport_mode" in task.missing_fields:
|
||||
gap_lines.append(
|
||||
(
|
||||
f"{task.title}已识别到{StewardPlannerService._summarize_known_business_points(task)},"
|
||||
"但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。"
|
||||
)
|
||||
)
|
||||
else:
|
||||
gap_lines.append(
|
||||
(
|
||||
f"{task.title}还缺少{'、'.join(dict.fromkeys(missing_labels))},"
|
||||
"需要在对应步骤里继续向用户确认,不能直接执行入库或提交。"
|
||||
)
|
||||
)
|
||||
if not gap_lines:
|
||||
return None
|
||||
return StewardThinkingEvent(
|
||||
event_id="intent_business_gap_check",
|
||||
stage="business_gap_check",
|
||||
title="判断待补充信息",
|
||||
content=";".join(gap_lines),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_known_business_points(task: StewardTask) -> str:
|
||||
parts = []
|
||||
for key in ("time_range", "location", "reason", "expense_type"):
|
||||
value = str(task.ontology_fields.get(key) or "").strip()
|
||||
if value:
|
||||
parts.append(
|
||||
f"{StewardPlannerService._business_field_label(key)}为"
|
||||
f"{StewardPlannerService._format_business_field_value(key, value)}"
|
||||
)
|
||||
return "、".join(parts) or "部分业务要素"
|
||||
|
||||
@staticmethod
|
||||
def _business_field_label(key: str) -> str:
|
||||
return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip())
|
||||
|
||||
@staticmethod
|
||||
def _format_business_field_value(key: str, value: str) -> str:
|
||||
cleaned = str(value or "").strip()
|
||||
if key == "expense_type":
|
||||
return EXPENSE_TYPE_LABELS.get(cleaned, cleaned)
|
||||
if key == "transport_mode":
|
||||
return TRANSPORT_MODE_LABELS.get(cleaned, cleaned)
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _summarize_attachment_correlation(
|
||||
|
||||
197
server/src/app/services/steward_runtime_decision_agent.py
Normal file
197
server/src/app/services/steward_runtime_decision_agent.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardRuntimeDecisionRequest,
|
||||
StewardRuntimeDecisionResponse,
|
||||
)
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
|
||||
|
||||
RUNTIME_NEXT_ACTIONS = {
|
||||
"plan_new_tasks",
|
||||
"submit_current_application",
|
||||
"continue_next_task",
|
||||
"fill_current_slot",
|
||||
"ask_user",
|
||||
"cancel_current_action",
|
||||
"no_op",
|
||||
}
|
||||
|
||||
|
||||
class StewardRuntimeDecisionAgent:
|
||||
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
|
||||
|
||||
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
|
||||
self.runtime_chat_service = runtime_chat_service
|
||||
|
||||
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
|
||||
normalized_request = self._normalize_request(request)
|
||||
result = self.runtime_chat_service.complete_with_tool_call(
|
||||
self._build_messages(normalized_request),
|
||||
tools=[self._build_tool_schema()],
|
||||
tool_choice={
|
||||
"type": "function",
|
||||
"function": {"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME},
|
||||
},
|
||||
max_tokens=1000,
|
||||
temperature=0.05,
|
||||
timeout_seconds=30,
|
||||
max_attempts=1,
|
||||
)
|
||||
traces = result.calls_as_dicts()
|
||||
if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME:
|
||||
response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces)
|
||||
if response is not None:
|
||||
return response
|
||||
return self._build_rule_fallback(normalized_request, traces)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
|
||||
return StewardRuntimeDecisionRequest(
|
||||
user_message=str(request.user_message or "").strip(),
|
||||
session_type=str(request.session_type or "steward").strip() or "steward",
|
||||
runtime_state=request.runtime_state if isinstance(request.runtime_state, dict) else {},
|
||||
context_json=request.context_json if isinstance(request.context_json, dict) else {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
|
||||
payload = {
|
||||
"user_message": request.user_message,
|
||||
"session_type": request.session_type,
|
||||
"runtime_state": request.runtime_state,
|
||||
"context_json": request.context_json,
|
||||
}
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 X-Financial 小财管家的运行时决策智能体。"
|
||||
"你必须基于 runtime_state 判断用户当前输入对应哪个等待动作,不能把每次输入都当成全新任务。"
|
||||
"runtime_state 会包含 current_task、remaining_tasks、completed_tasks、pending_application、"
|
||||
"pending_steward_action、waiting_for、recent_structured_result 等上下文。"
|
||||
"如果用户是在确认当前申请核对表无误,应返回 submit_current_application;"
|
||||
"如果用户是在确认继续下一项,应返回 continue_next_task;"
|
||||
"如果用户补充了当前等待字段,应返回 fill_current_slot;"
|
||||
"如果当前结构化结果仍缺字段,应返回 ask_user;"
|
||||
"只有当前没有可匹配上下文,且用户输入明显是新财务事项时,才返回 plan_new_tasks。"
|
||||
"提交、入库、绑定、审批等高风险动作只返回结构化意图,实际执行由系统安全校验完成。"
|
||||
"rationale 和 response_text 必须面向用户,不暴露内部推理链。"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_tool_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
|
||||
"description": "提交小财管家基于运行时上下文的下一步动作决策。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"next_action": {
|
||||
"type": "string",
|
||||
"enum": sorted(RUNTIME_NEXT_ACTIONS),
|
||||
},
|
||||
"target_task_id": {"type": "string"},
|
||||
"target_message_id": {"type": "string"},
|
||||
"field_key": {"type": "string"},
|
||||
"field_value": {"type": "string"},
|
||||
"confirmation_required": {"type": "boolean"},
|
||||
"question": {"type": "string"},
|
||||
"response_text": {"type": "string"},
|
||||
"rationale": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"next_action",
|
||||
"target_task_id",
|
||||
"target_message_id",
|
||||
"field_key",
|
||||
"field_value",
|
||||
"confirmation_required",
|
||||
"question",
|
||||
"response_text",
|
||||
"rationale",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _build_response_from_model_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
request: StewardRuntimeDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardRuntimeDecisionResponse | None:
|
||||
next_action = str(payload.get("next_action") or "").strip()
|
||||
if next_action not in RUNTIME_NEXT_ACTIONS:
|
||||
return None
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="llm_function_call",
|
||||
next_action=next_action, # type: ignore[arg-type]
|
||||
target_task_id=self._clean_text(payload.get("target_task_id")),
|
||||
target_message_id=self._clean_text(payload.get("target_message_id")),
|
||||
field_key=self._clean_text(payload.get("field_key")),
|
||||
field_value=self._clean_text(payload.get("field_value")),
|
||||
confirmation_required=bool(payload.get("confirmation_required")),
|
||||
question=self._clean_text(payload.get("question")),
|
||||
response_text=self._clean_text(payload.get("response_text")),
|
||||
rationale=self._clean_text(payload.get("rationale")),
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
def _build_rule_fallback(
|
||||
self,
|
||||
request: StewardRuntimeDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
state = request.runtime_state
|
||||
pending_application = state.get("pending_application") if isinstance(state.get("pending_application"), dict) else {}
|
||||
pending_steward_action = state.get("pending_steward_action") if isinstance(state.get("pending_steward_action"), dict) else {}
|
||||
waiting_for = str(state.get("waiting_for") or "").strip()
|
||||
message = request.user_message.replace(" ", "")
|
||||
confirmation_text = message in {"确认", "确定", "无误", "确认提交", "可以提交", "提交", "没问题"}
|
||||
if confirmation_text and pending_application.get("ready_to_submit"):
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="submit_current_application",
|
||||
target_message_id=str(pending_application.get("message_id") or ""),
|
||||
target_task_id=str(pending_application.get("task_id") or ""),
|
||||
rationale="模型运行时决策暂不可用,我先按当前待提交申请单上下文处理你的确认。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if confirmation_text and pending_steward_action:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="continue_next_task",
|
||||
target_message_id=str(pending_steward_action.get("message_id") or ""),
|
||||
target_task_id=str(pending_steward_action.get("target_task_id") or ""),
|
||||
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if waiting_for:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="ask_user",
|
||||
question="我需要先确认当前等待事项,请补充或选择当前问题对应的信息。",
|
||||
rationale="模型运行时决策暂不可用,当前仍存在等待用户补充的信息。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="plan_new_tasks",
|
||||
rationale="模型运行时决策暂不可用,当前没有可安全匹配的等待动作,回到任务规划。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
301
server/src/app/services/steward_slot_decision_agent.py
Normal file
301
server/src/app/services/steward_slot_decision_agent.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardSlotDecisionRequest,
|
||||
StewardSlotDecisionResponse,
|
||||
StewardSlotOption,
|
||||
)
|
||||
from app.services.ontology_field_registry import normalize_ontology_form_values
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS
|
||||
|
||||
|
||||
STEWARD_SLOT_DECISION_FUNCTION_NAME = "submit_steward_slot_decision"
|
||||
|
||||
|
||||
FIELD_CATALOG: dict[str, dict[str, str]] = {
|
||||
"expense_type": {"label": "费用类型", "description": "申请或报销所属费用场景,如差旅、交通、住宿、业务招待。"},
|
||||
"time_range": {"label": "时间", "description": "申请时为出差起止日期,报销时为费用发生日期。"},
|
||||
"location": {"label": "地点", "description": "出差目的地、费用发生地或业务活动地点。"},
|
||||
"reason": {"label": "事由", "description": "出差、报销或业务活动的业务原因。"},
|
||||
"amount": {"label": "金额", "description": "报销时为实际金额;申请时金额可由系统估算,不应默认要求用户填写。"},
|
||||
"transport_mode": {"label": "出行方式", "description": "差旅申请交通费用测算所需字段,由用户明确选择或表达。"},
|
||||
"attachments": {"label": "附件/凭证", "description": "发票、行程单、付款截图或其他证明材料。"},
|
||||
"customer_name": {"label": "客户或项目对象", "description": "业务招待、客户拜访或项目支撑涉及的对象。"},
|
||||
"merchant_name": {"label": "商户/开票方", "description": "报销票据上的商户或开票方。"},
|
||||
"department_name": {"label": "所属部门", "description": "申请人或费用归属部门。"},
|
||||
"employee_name": {"label": "申请人", "description": "发起申请或报销的员工。"},
|
||||
"employee_no": {"label": "员工编号", "description": "公司内部员工编号。"},
|
||||
}
|
||||
|
||||
APPLICATION_NON_BLOCKING_FIELDS = {"amount", "attachments", "employee_no", "department_name", "employee_name"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StewardSlotDecisionAgentResult:
|
||||
payload: dict[str, Any]
|
||||
model_call_traces: list[dict[str, Any]]
|
||||
|
||||
|
||||
class StewardSlotDecisionAgent:
|
||||
"""用大模型 function calling 判断当前任务缺什么,以及下一步是否应先追问。"""
|
||||
|
||||
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
|
||||
self.runtime_chat_service = runtime_chat_service
|
||||
|
||||
def decide(self, request: StewardSlotDecisionRequest) -> StewardSlotDecisionResponse:
|
||||
normalized_request = self._normalize_request(request)
|
||||
result = self.runtime_chat_service.complete_with_tool_call(
|
||||
self._build_messages(normalized_request),
|
||||
tools=[self._build_tool_schema()],
|
||||
tool_choice={
|
||||
"type": "function",
|
||||
"function": {"name": STEWARD_SLOT_DECISION_FUNCTION_NAME},
|
||||
},
|
||||
max_tokens=1200,
|
||||
temperature=0.05,
|
||||
timeout_seconds=30,
|
||||
max_attempts=1,
|
||||
)
|
||||
if result.tool_call is not None and result.tool_call.name == STEWARD_SLOT_DECISION_FUNCTION_NAME:
|
||||
response = self._build_response_from_model_payload(
|
||||
result.tool_call.arguments,
|
||||
normalized_request,
|
||||
result.calls_as_dicts(),
|
||||
)
|
||||
if response is not None:
|
||||
return response
|
||||
return self._build_rule_fallback(normalized_request, result.calls_as_dicts())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request(request: StewardSlotDecisionRequest) -> StewardSlotDecisionRequest:
|
||||
normalized_fields = {
|
||||
key: value
|
||||
for key, value in normalize_ontology_form_values(request.ontology_fields).items()
|
||||
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
|
||||
}
|
||||
missing_fields: list[str] = []
|
||||
for item in request.missing_fields:
|
||||
key = str(item or "").strip()
|
||||
if request.task_type == "expense_application" and key in APPLICATION_NON_BLOCKING_FIELDS:
|
||||
continue
|
||||
if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not normalized_fields.get(key):
|
||||
missing_fields.append(key)
|
||||
return StewardSlotDecisionRequest(
|
||||
task_type=request.task_type,
|
||||
user_message=str(request.user_message or "").strip(),
|
||||
ontology_fields=normalized_fields,
|
||||
missing_fields=missing_fields,
|
||||
task_context=request.task_context if isinstance(request.task_context, dict) else {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(request: StewardSlotDecisionRequest) -> list[dict[str, Any]]:
|
||||
context_payload = {
|
||||
"task_type": request.task_type,
|
||||
"user_message": request.user_message,
|
||||
"ontology_fields": request.ontology_fields,
|
||||
"missing_fields_from_intent_agent": request.missing_fields,
|
||||
"field_catalog": {
|
||||
key: FIELD_CATALOG[key]
|
||||
for key in BUSINESS_CANONICAL_FIELD_ORDER
|
||||
if key in FIELD_CATALOG
|
||||
},
|
||||
"task_context": request.task_context,
|
||||
}
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 X-Financial 小财管家的任务字段决策智能体。"
|
||||
"你必须通过 function calling 返回下一步动作。"
|
||||
"你的任务不是关键词匹配,而是结合用户意图、当前任务类型、canonical ontology 字段、"
|
||||
"上游意图识别给出的缺失字段和字段目录,判断现在应先追问用户,还是可以展示核对结果。"
|
||||
"所有 required_fields 和 missing_fields 只能使用 field_catalog 中的 canonical 字段。"
|
||||
"如果字段是内部提示、示例、系统指令或可选项,不能当作用户已经提供。"
|
||||
"费用申请场景中 amount 可由系统估算,不应作为用户必须手填字段。"
|
||||
"费用申请生成核对表阶段,attachments 不阻塞生成,可在报销或归档阶段补充;"
|
||||
"employee_no、department_name、employee_name 属于系统用户档案字段,必须从上下文读取,不能向用户追问。"
|
||||
"差旅申请通常只有 transport_mode 这类会影响费用测算的字段才需要先追问。"
|
||||
"如果缺失字段会影响后续测算、入库、附件归集或合规判断,应返回 ask_user;"
|
||||
"如果信息足以生成可核对但未提交的结果,应返回 render_preview。"
|
||||
"question 和 rationale 必须是面向用户的业务说明,不暴露内部推理链。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(context_payload, ensure_ascii=False),
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_tool_schema() -> dict[str, Any]:
|
||||
canonical_fields = list(BUSINESS_CANONICAL_FIELD_ORDER)
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": STEWARD_SLOT_DECISION_FUNCTION_NAME,
|
||||
"description": "提交小财管家当前任务的字段缺口和下一步动作决策。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"next_action": {
|
||||
"type": "string",
|
||||
"enum": ["ask_user", "render_preview"],
|
||||
},
|
||||
"required_fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": canonical_fields},
|
||||
},
|
||||
"missing_fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": canonical_fields},
|
||||
},
|
||||
"question": {"type": "string"},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"field_key": {"type": "string", "enum": canonical_fields},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["label", "value", "field_key"],
|
||||
},
|
||||
},
|
||||
"rationale": {"type": "string"},
|
||||
},
|
||||
"required": ["next_action", "required_fields", "missing_fields", "question", "options", "rationale"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _build_response_from_model_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
request: StewardSlotDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardSlotDecisionResponse | None:
|
||||
next_action = str(payload.get("next_action") or "").strip()
|
||||
if next_action not in {"ask_user", "render_preview"}:
|
||||
return None
|
||||
required_fields = self._sanitize_fields(payload.get("required_fields"))
|
||||
missing_fields = self._sanitize_fields(payload.get("missing_fields"))
|
||||
required_fields = self._filter_blocking_fields(required_fields, request.task_type)
|
||||
missing_fields = self._filter_blocking_fields(missing_fields, request.task_type)
|
||||
missing_fields = [
|
||||
key
|
||||
for key in missing_fields
|
||||
if key in required_fields or key in request.missing_fields
|
||||
]
|
||||
if next_action == "ask_user" and not missing_fields:
|
||||
missing_fields = list(request.missing_fields)
|
||||
if next_action == "ask_user" and not missing_fields:
|
||||
next_action = "render_preview"
|
||||
options = []
|
||||
question = ""
|
||||
rationale = "当前申请信息足以先生成核对结果;附件和员工编号不应作为用户补填项阻塞申请预览。"
|
||||
else:
|
||||
options = self._sanitize_options(payload.get("options"), missing_fields)
|
||||
question = self._clean_text(payload.get("question"))
|
||||
rationale = self._clean_text(payload.get("rationale"))
|
||||
return StewardSlotDecisionResponse(
|
||||
decision_source="llm_function_call",
|
||||
next_action=next_action, # type: ignore[arg-type]
|
||||
required_fields=required_fields,
|
||||
missing_fields=missing_fields,
|
||||
question=question,
|
||||
options=options,
|
||||
rationale=rationale,
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_blocking_fields(fields: list[str], task_type: str) -> list[str]:
|
||||
if task_type != "expense_application":
|
||||
return fields
|
||||
return [field for field in fields if field not in APPLICATION_NON_BLOCKING_FIELDS]
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_fields(raw_fields: Any) -> list[str]:
|
||||
fields: list[str] = []
|
||||
if not isinstance(raw_fields, list):
|
||||
return fields
|
||||
for item in raw_fields:
|
||||
key = str(item or "").strip()
|
||||
if key in BUSINESS_CANONICAL_FIELDS and key not in fields:
|
||||
fields.append(key)
|
||||
return fields
|
||||
|
||||
def _sanitize_options(self, raw_options: Any, missing_fields: list[str]) -> list[StewardSlotOption]:
|
||||
options: list[StewardSlotOption] = []
|
||||
if isinstance(raw_options, list):
|
||||
for item in raw_options:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
field_key = str(item.get("field_key") or "").strip()
|
||||
label = self._clean_text(item.get("label"))
|
||||
value = self._clean_text(item.get("value")) or label
|
||||
if not field_key or field_key not in BUSINESS_CANONICAL_FIELDS or not label or not value:
|
||||
continue
|
||||
options.append(
|
||||
StewardSlotOption(
|
||||
field_key=field_key,
|
||||
label=label,
|
||||
value=value,
|
||||
description=self._clean_text(item.get("description")),
|
||||
)
|
||||
)
|
||||
if not options and missing_fields and missing_fields[0] == "transport_mode":
|
||||
options = [
|
||||
StewardSlotOption(field_key="transport_mode", label="火车", value="火车", description="选择火车或高铁出行。"),
|
||||
StewardSlotOption(field_key="transport_mode", label="飞机", value="飞机", description="选择飞机出行。"),
|
||||
StewardSlotOption(field_key="transport_mode", label="轮船", value="轮船", description="选择轮船出行。"),
|
||||
]
|
||||
return options[:6]
|
||||
|
||||
def _build_rule_fallback(
|
||||
self,
|
||||
request: StewardSlotDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardSlotDecisionResponse:
|
||||
missing_fields = list(request.missing_fields)
|
||||
if missing_fields:
|
||||
field = missing_fields[0]
|
||||
return StewardSlotDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="ask_user",
|
||||
required_fields=list(dict.fromkeys([*request.ontology_fields.keys(), *missing_fields])),
|
||||
missing_fields=missing_fields,
|
||||
question=self._build_fallback_question(field),
|
||||
options=self._sanitize_options([], [field]),
|
||||
rationale="模型字段决策暂不可用,我先按上游意图识别给出的本体缺口向你确认。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
return StewardSlotDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="render_preview",
|
||||
required_fields=list(request.ontology_fields.keys()),
|
||||
missing_fields=[],
|
||||
question="",
|
||||
options=[],
|
||||
rationale="当前任务没有上游标记的关键字段缺口,可以先生成核对结果供你确认。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_fallback_question(field: str) -> str:
|
||||
label = FIELD_CATALOG.get(field, {}).get("label") or field
|
||||
if field == "transport_mode":
|
||||
return "请问你这次打算怎么出行?可以选择火车、飞机或轮船。"
|
||||
return f"当前还缺少{label},请先补充后我再继续处理。"
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
@@ -17,6 +18,7 @@ SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
|
||||
FAILED_STATUSES = {"failed", "failure", "error", "errored"}
|
||||
BLOCKED_STATUSES = {"blocked", "forbidden", "rejected"}
|
||||
RUNNING_STATUSES = {"running", "pending"}
|
||||
TOKEN_ESTIMATE_FALLBACK_TOTAL = 600
|
||||
|
||||
TOOL_BUCKETS = [
|
||||
{
|
||||
@@ -58,6 +60,32 @@ TOOL_BUCKETS = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DashboardToolCall:
|
||||
id: str
|
||||
run_id: str
|
||||
tool_type: str | None
|
||||
tool_name: str | None
|
||||
status: str | None
|
||||
duration_ms: int | None
|
||||
error_message: str | None
|
||||
created_at: datetime | None
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DashboardRun:
|
||||
run_id: str
|
||||
agent: str | None
|
||||
source: str | None
|
||||
user_id: str | None
|
||||
status: str | None
|
||||
started_at: datetime
|
||||
tool_calls: list[_DashboardToolCall] = field(default_factory=list)
|
||||
|
||||
|
||||
class SystemDashboardService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
@@ -116,16 +144,73 @@ class SystemDashboardService:
|
||||
def _ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
|
||||
def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[AgentRun]:
|
||||
def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[_DashboardRun]:
|
||||
stmt = (
|
||||
select(AgentRun)
|
||||
.options(selectinload(AgentRun.tool_calls))
|
||||
select(
|
||||
AgentRun.run_id.label("run_id"),
|
||||
AgentRun.agent.label("agent"),
|
||||
AgentRun.source.label("source"),
|
||||
AgentRun.user_id.label("user_id"),
|
||||
AgentRun.status.label("run_status"),
|
||||
AgentRun.started_at.label("started_at"),
|
||||
AgentToolCall.id.label("tool_id"),
|
||||
AgentToolCall.run_id.label("tool_run_id"),
|
||||
AgentToolCall.tool_type.label("tool_type"),
|
||||
AgentToolCall.tool_name.label("tool_name"),
|
||||
AgentToolCall.status.label("tool_status"),
|
||||
AgentToolCall.duration_ms.label("duration_ms"),
|
||||
AgentToolCall.error_message.label("tool_error_message"),
|
||||
AgentToolCall.created_at.label("tool_created_at"),
|
||||
AgentToolCall.request_json["input_tokens"].as_integer().label("request_input_tokens"),
|
||||
AgentToolCall.request_json["prompt_tokens"].as_integer().label("request_prompt_tokens"),
|
||||
AgentToolCall.request_json["total_tokens"].as_integer().label("request_total_tokens"),
|
||||
AgentToolCall.response_json["input_tokens"].as_integer().label("response_input_tokens"),
|
||||
AgentToolCall.response_json["output_tokens"].as_integer().label("response_output_tokens"),
|
||||
AgentToolCall.response_json["completion_tokens"].as_integer().label("response_completion_tokens"),
|
||||
AgentToolCall.response_json["total_tokens"].as_integer().label("response_total_tokens"),
|
||||
)
|
||||
.outerjoin(AgentToolCall, AgentToolCall.run_id == AgentRun.run_id)
|
||||
.where(AgentRun.started_at >= start)
|
||||
.order_by(AgentRun.started_at.asc())
|
||||
.order_by(AgentRun.started_at.asc(), AgentToolCall.created_at.asc())
|
||||
)
|
||||
if before is not None:
|
||||
stmt = stmt.where(AgentRun.started_at < before)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
runs: dict[str, _DashboardRun] = {}
|
||||
for row in self.db.execute(stmt).all():
|
||||
run = runs.get(row.run_id)
|
||||
if run is None:
|
||||
run = _DashboardRun(
|
||||
run_id=row.run_id,
|
||||
agent=row.agent,
|
||||
source=row.source,
|
||||
user_id=row.user_id,
|
||||
status=row.run_status,
|
||||
started_at=row.started_at,
|
||||
)
|
||||
runs[row.run_id] = run
|
||||
|
||||
if row.tool_id is None:
|
||||
continue
|
||||
|
||||
input_tokens, output_tokens, total_tokens = self._token_counts_from_row(row)
|
||||
run.tool_calls.append(
|
||||
_DashboardToolCall(
|
||||
id=row.tool_id,
|
||||
run_id=row.tool_run_id or row.run_id,
|
||||
tool_type=row.tool_type,
|
||||
tool_name=row.tool_name,
|
||||
status=row.tool_status,
|
||||
duration_ms=row.duration_ms,
|
||||
error_message=row.tool_error_message,
|
||||
created_at=row.tool_created_at,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
)
|
||||
|
||||
return list(runs.values())
|
||||
|
||||
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
|
||||
stmt = (
|
||||
@@ -143,7 +228,11 @@ class SystemDashboardService:
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _agent_daily_ratio(self, labels: list[str], tool_calls: list[AgentToolCall]) -> dict[str, Any]:
|
||||
def _agent_daily_ratio(
|
||||
self,
|
||||
labels: list[str],
|
||||
tool_calls: list[_DashboardToolCall],
|
||||
) -> dict[str, Any]:
|
||||
counts = {bucket["key"]: [0 for _ in labels] for bucket in TOOL_BUCKETS}
|
||||
label_index = {label: index for index, label in enumerate(labels)}
|
||||
for tool in tool_calls:
|
||||
@@ -231,7 +320,7 @@ class SystemDashboardService:
|
||||
for index, (user_id, value) in enumerate(rows)
|
||||
]
|
||||
|
||||
def _accuracy_comparison(self, tool_calls: list[AgentToolCall]) -> dict[str, Any]:
|
||||
def _accuracy_comparison(self, tool_calls: list[_DashboardToolCall]) -> dict[str, Any]:
|
||||
correct = {bucket["name"]: 0 for bucket in TOOL_BUCKETS}
|
||||
wrong = {bucket["name"]: 0 for bucket in TOOL_BUCKETS}
|
||||
for tool in tool_calls:
|
||||
@@ -297,7 +386,7 @@ class SystemDashboardService:
|
||||
|
||||
def _tool_detail_rows(
|
||||
self,
|
||||
tool_calls: list[AgentToolCall],
|
||||
tool_calls: list[_DashboardToolCall],
|
||||
records: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
token_by_tool = {str(record["tool_id"]): int(record["total"]) for record in records}
|
||||
@@ -331,14 +420,15 @@ class SystemDashboardService:
|
||||
)
|
||||
return rows
|
||||
|
||||
def _build_token_records(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
def _build_token_records(self, runs: list[_DashboardRun]) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for run in runs:
|
||||
for tool in run.tool_calls:
|
||||
input_tokens, output_tokens = self._extract_tool_tokens(tool)
|
||||
total = input_tokens + output_tokens
|
||||
input_tokens = int(tool.input_tokens or 0)
|
||||
output_tokens = int(tool.output_tokens or 0)
|
||||
total = int(tool.total_tokens or input_tokens + output_tokens)
|
||||
if total <= 0:
|
||||
total = self._estimate_tool_tokens(tool)
|
||||
total = self._estimate_tool_tokens(tool) if hasattr(tool, "request_json") else 0
|
||||
input_tokens = int(total * 0.62)
|
||||
output_tokens = total - input_tokens
|
||||
records.append(
|
||||
@@ -353,6 +443,42 @@ class SystemDashboardService:
|
||||
)
|
||||
return records
|
||||
|
||||
def _token_counts_from_row(self, row: Any) -> tuple[int, int, int]:
|
||||
input_tokens = self._first_positive_int(
|
||||
row.request_input_tokens,
|
||||
row.request_prompt_tokens,
|
||||
row.response_input_tokens,
|
||||
)
|
||||
output_tokens = self._first_positive_int(
|
||||
row.response_output_tokens,
|
||||
row.response_completion_tokens,
|
||||
)
|
||||
total_tokens = self._first_positive_int(
|
||||
row.request_total_tokens,
|
||||
row.response_total_tokens,
|
||||
)
|
||||
|
||||
if total_tokens and not input_tokens and not output_tokens:
|
||||
input_tokens = int(total_tokens * 0.62)
|
||||
output_tokens = total_tokens - input_tokens
|
||||
|
||||
if input_tokens + output_tokens <= 0 and total_tokens <= 0:
|
||||
total_tokens = TOKEN_ESTIMATE_FALLBACK_TOTAL
|
||||
input_tokens = int(total_tokens * 0.62)
|
||||
output_tokens = total_tokens - input_tokens
|
||||
|
||||
if total_tokens <= 0:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
return input_tokens, output_tokens, total_tokens
|
||||
|
||||
@staticmethod
|
||||
def _first_positive_int(*values: Any) -> int:
|
||||
for value in values:
|
||||
if isinstance(value, (int, float)) and value > 0:
|
||||
return int(value)
|
||||
return 0
|
||||
|
||||
def _extract_tool_tokens(self, tool: AgentToolCall) -> tuple[int, int]:
|
||||
payload = {
|
||||
"request": tool.request_json or {},
|
||||
@@ -392,7 +518,7 @@ class SystemDashboardService:
|
||||
return found
|
||||
return 0
|
||||
|
||||
def _tool_bucket(self, tool: AgentToolCall) -> dict[str, Any]:
|
||||
def _tool_bucket(self, tool: AgentToolCall | _DashboardToolCall) -> dict[str, Any]:
|
||||
text = f"{tool.tool_type or ''} {tool.tool_name or ''}".lower()
|
||||
if self._is_failed(tool.status) and ("timeout" in text or tool.error_message):
|
||||
return TOOL_BUCKETS[-1]
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.document_numbering import (
|
||||
)
|
||||
from app.services.user_agent_application_dates import (
|
||||
expand_application_time_with_days,
|
||||
resolve_application_date_range,
|
||||
resolve_application_days_from_time_range,
|
||||
)
|
||||
from app.services.user_agent_application_locations import normalize_application_location
|
||||
@@ -1143,8 +1144,19 @@ class UserAgentApplicationMixin:
|
||||
facts: dict[str, str],
|
||||
occurred_at: datetime,
|
||||
) -> bool:
|
||||
current_range = resolve_application_date_range(facts.get("time", ""))
|
||||
current_time = cls._normalize_application_time_identity(facts.get("time"))
|
||||
existing_detail = cls._extract_application_detail_from_claim(claim)
|
||||
existing_range = resolve_application_date_range(existing_detail.get("time"))
|
||||
if existing_range is None and claim.occurred_at is not None:
|
||||
existing_day = claim.occurred_at.date()
|
||||
existing_range = (existing_day, existing_day)
|
||||
if current_range is None and occurred_at is not None:
|
||||
current_day = occurred_at.date()
|
||||
current_range = (current_day, current_day)
|
||||
if current_range is not None and existing_range is not None:
|
||||
return current_range[0] <= existing_range[1] and existing_range[0] <= current_range[1]
|
||||
|
||||
existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
|
||||
if current_time and existing_time:
|
||||
return current_time == existing_time
|
||||
|
||||
@@ -45,7 +45,7 @@ def resolve_application_days_count(days_text: str) -> int:
|
||||
|
||||
def resolve_application_days_from_time_range(time_text: str) -> int:
|
||||
matches = re.findall(
|
||||
r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?",
|
||||
r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?",
|
||||
str(time_text or ""),
|
||||
)
|
||||
if len(matches) < 2:
|
||||
@@ -57,10 +57,29 @@ def resolve_application_days_from_time_range(time_text: str) -> int:
|
||||
return (end_date - start_date).days + 1
|
||||
|
||||
|
||||
def resolve_application_date_range(time_text: str) -> tuple[date, date] | None:
|
||||
matches = re.findall(
|
||||
r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?",
|
||||
str(time_text or ""),
|
||||
)
|
||||
dates = [
|
||||
parsed
|
||||
for parsed in (_parse_application_date(value) for value in matches)
|
||||
if parsed is not None
|
||||
]
|
||||
if not dates:
|
||||
return None
|
||||
start_date = dates[0]
|
||||
end_date = dates[-1] if len(dates) > 1 else start_date
|
||||
if end_date < start_date:
|
||||
start_date, end_date = end_date, start_date
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None:
|
||||
if time_text:
|
||||
match = re.search(
|
||||
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||
r"(?P<date>20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?)",
|
||||
time_text,
|
||||
)
|
||||
if match:
|
||||
|
||||
Reference in New Issue
Block a user