feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -1,17 +1,17 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.large_expense_without_preapproval",
"name": "大额费用未事前申请",
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
"name": "?????????",
"description": "???????? 2000 ?????????????",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"risk_category": "????",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
@@ -34,68 +34,75 @@
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"label": "????",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"label": "????",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"label": "??",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"label": "??",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"label": "????",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"label": "???ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"label": "????",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"label": "??????",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"label": "??????",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"label": "????",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [
"claim.amount",
"claim.expense_type",
@@ -103,31 +110,89 @@
"claim.reason",
"item.item_reason",
"application.id",
"application.claim_no",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
"conditions": [
{
"id": "amount_exceeds_preapproval_threshold",
"operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 2000,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
},
{
"id": "not_specific_preapproval_type",
"operator": "not_contains_any",
"fields": [
"claim.expense_type"
],
"keywords": [
"meal",
"entertainment",
"office",
"????",
"??",
"????",
"??"
]
}
],
"keywords": [
"大额费用",
"未申请",
"先申请后报销"
],
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"hit_logic": {
"all": [
"amount_exceeds_preapproval_threshold",
{
"not": "application_present"
},
"not_specific_preapproval_type"
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "?????????????????? 2000 ????????????????",
"message_template": "?????? 2000 ?????????????????????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
"budget_required": true,
"threshold_amount": 2000,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "????",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
},
"outcomes": {
"pass": {
@@ -141,16 +206,16 @@
}
},
"metadata": {
"owner": "风控与审计部",
"owner": "??????",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.805274+00:00",
"source_ref": "??????????",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 86,
"risk_level": "high",
"rule_title": "大额费用未事前申请",
"finance_rule_code": "finance.preapproval.policy",
"finance_rule_sheet": "费用申请前置规则",
"rule_title": "?????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],

View File

@@ -1,22 +1,23 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.meal_high_value_without_preapproval",
"name": "大额业务招待未申请",
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。",
"name": "??????????",
"description": "????????? 500 ?????????????",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"risk_category": "????",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"budget_required": true,
"applies_to": {
@@ -24,7 +25,8 @@
"expense"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"business_stages": [
"reimbursement"
@@ -34,74 +36,75 @@
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"label": "????",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"label": "????",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"label": "??",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"label": "??",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"label": "????",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"label": "???ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"label": "????",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"label": "??????",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"label": "??????",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"label": "????",
"type": "text",
"source": "application"
},
{
"key": "material.attendee_list_uploaded",
"label": "参与人清单已上传",
"type": "boolean",
"source": "material"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [
"claim.amount",
"claim.expense_type",
@@ -109,32 +112,73 @@
"claim.reason",
"item.item_reason",
"application.id",
"application.claim_no",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name",
"material.attendee_list_uploaded"
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
"conditions": [
{
"id": "amount_exceeds_preapproval_threshold",
"operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 500,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"keywords": [
"业务招待",
"人均超标",
"未申请"
],
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"hit_logic": {
"all": [
"amount_exceeds_preapproval_threshold",
{
"not": "application_present"
}
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "??????????? 500 ????????????????",
"message_template": "??????? 500 ?????????????????????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"budget_required": true
"budget_required": true,
"threshold_amount": 500,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "????",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
},
"outcomes": {
"pass": {
@@ -144,29 +188,30 @@
"fail": {
"severity": "high",
"action": "manual_review",
"risk_score": 84
"risk_score": 88
}
},
"metadata": {
"owner": "风控与审计部",
"owner": "??????",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.818641+00:00",
"source_ref": "??????????",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 84,
"risk_score": 88,
"risk_level": "high",
"rule_title": "大额业务招待未申请",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"rule_title": "??????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
"expense_types": [
"meal"
"meal",
"entertainment"
],
"budget_required": true
},
"severity": "high",
"risk_score": 84,
"risk_score": 88,
"risk_level": "high"
}

View File

@@ -1,17 +1,17 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.office_bulk_without_purchase",
"name": "办公用品大额采购未申请",
"description": "批量办公用品或设备采购达到阈值但未走采购申请。",
"name": "???????????",
"description": "???????????????? 2000 ???????????",
"enabled": true,
"requires_attachment": false,
"risk_dimension": "expense_control_demo",
"risk_category": "申请前置",
"risk_category": "????",
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"template_key": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
@@ -34,68 +34,75 @@
"fields": [
{
"key": "claim.amount",
"label": "报销金额",
"label": "????",
"type": "number",
"source": "claim"
},
{
"key": "claim.expense_type",
"label": "费用类型",
"label": "????",
"type": "enum",
"source": "claim"
},
{
"key": "claim.department_name",
"label": "部门",
"label": "??",
"type": "text",
"source": "claim"
},
{
"key": "claim.reason",
"label": "事由",
"label": "??",
"type": "text",
"source": "claim"
},
{
"key": "item.item_reason",
"label": "明细说明",
"label": "????",
"type": "text",
"source": "item"
},
{
"key": "application.id",
"label": "申请单",
"label": "???ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"type": "text",
"source": "application"
},
{
"key": "application.status",
"label": "申请状态",
"label": "????",
"type": "enum",
"source": "application"
},
{
"key": "application.approved_amount",
"label": "申请审批金额",
"label": "??????",
"type": "number",
"source": "application"
},
{
"key": "application.expense_type",
"label": "申请费用类型",
"label": "??????",
"type": "enum",
"source": "application"
},
{
"key": "application.department_name",
"label": "申请部门",
"label": "????",
"type": "text",
"source": "application"
}
]
},
"params": {
"template_key": "keyword_match_v1",
"template_key": "composite_rule_v1",
"semantic_type": "preapproval_required_amount_threshold",
"field_keys": [
"claim.amount",
"claim.expense_type",
@@ -103,31 +110,72 @@
"claim.reason",
"item.item_reason",
"application.id",
"application.claim_no",
"application.status",
"application.approved_amount",
"application.expense_type",
"application.department_name"
],
"search_fields": [
"claim.reason",
"item.item_reason",
"claim.expense_type"
"conditions": [
{
"id": "amount_exceeds_preapproval_threshold",
"operator": "numeric_compare",
"left_fields": [
"claim.amount"
],
"threshold": 2000,
"compare": "gt"
},
{
"id": "application_present",
"operator": "exists_any",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"keywords": [
"办公采购",
"大额办公用品",
"采购申请"
],
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"hit_logic": {
"all": [
"amount_exceeds_preapproval_threshold",
{
"not": "application_present"
}
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "???????????????? 2000 ????????????????",
"message_template": "??????? 2000 ??????????????????????????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
"budget_required": true,
"threshold_amount": 2000,
"rule_ir": {
"facts": [
{
"id": "A",
"label": "????",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"fields": [
"application.id",
"application.claim_no"
]
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
},
"outcomes": {
"pass": {
@@ -135,22 +183,22 @@
"action": "continue"
},
"fail": {
"severity": "medium",
"severity": "high",
"action": "manual_review",
"risk_score": 78
"risk_score": 84
}
},
"metadata": {
"owner": "风控与审计部",
"owner": "??????",
"stability": "platform",
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.811910+00:00",
"source_ref": "??????????",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 78,
"risk_level": "medium",
"rule_title": "办公用品大额采购未申请",
"finance_rule_code": "expense.application.policy",
"finance_rule_sheet": "费用申请前置规则",
"risk_score": 84,
"risk_level": "high",
"rule_title": "???????????",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"business_stage": [
"reimbursement"
],
@@ -159,7 +207,7 @@
],
"budget_required": true
},
"severity": "medium",
"risk_score": 78,
"risk_level": "medium"
"severity": "high",
"risk_score": 84,
"risk_level": "high"
}

View File

@@ -4,6 +4,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.3.1}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
@@ -15,6 +17,6 @@ apt-get install -y libgl1 libglib2.0-0
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==3.2.0" "paddleocr==3.5.0"
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==${PADDLEPADDLE_VERSION}" "paddleocr==${PADDLEOCR_VERSION}"
echo "PaddleOCR mobile runtime 已安装到 ${OCR_VENV_DIR}"
echo "PaddleOCR mobile runtime ${PADDLEOCR_VERSION} / PaddlePaddle ${PADDLEPADDLE_VERSION} 已安装到 ${OCR_VENV_DIR}"

View File

@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--lang", default="ch")
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
parser.add_argument("--enable-mkldnn", action="store_true")
return parser.parse_args()
@@ -106,6 +107,8 @@ def main() -> int:
use_doc_unwarping=False,
use_textline_orientation=False,
lang=args.lang,
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
enable_mkldnn=args.enable_mkldnn,
)
documents = []

View File

@@ -188,6 +188,8 @@ if is_container; then
fi
SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
SERVER_WORKERS="${SERVER_WORKERS:-${WEB_CONCURRENCY:-1}}"
export SERVER_WORKERS
needs_windows_python() {
is_msys || is_wsl
@@ -355,6 +357,12 @@ start_server() {
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
fi
if [ "$SERVER_WORKERS" -gt 1 ] 2>/dev/null; then
BACKGROUND_SCHEDULERS_ENABLED="${BACKGROUND_SCHEDULERS_ENABLED:-false}"
export BACKGROUND_SCHEDULERS_ENABLED
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" --workers "$SERVER_WORKERS"
fi
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
}

View File

@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
@@ -50,7 +51,7 @@ async def recognize_ocr_documents(
upload.content_type,
)
)
result = OcrService(db).recognize_files(payload)
result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
return ReceiptFolderService().persist_ocr_batch(
files=payload,
result=result,

View File

@@ -11,10 +11,20 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.common import ErrorResponse
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent
from app.schemas.steward import (
StewardPlanRequest,
StewardPlanResponse,
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
StewardSlotDecisionRequest,
StewardSlotDecisionResponse,
StewardThinkingEvent,
)
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_planner import StewardPlannerService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
router = APIRouter(prefix="/steward")
DbSession = Annotated[Session, Depends(get_db)]
@@ -39,6 +49,32 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post(
"/slot-decisions",
response_model=StewardSlotDecisionResponse,
summary="判断小财管家当前任务字段缺口",
description="结合当前任务、本体字段和用户上下文,使用 function calling 判断下一步应先追问用户还是展示核对结果。",
)
def create_steward_slot_decision(
payload: StewardSlotDecisionRequest,
db: DbSession,
) -> StewardSlotDecisionResponse:
return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload)
@router.post(
"/runtime-decisions",
response_model=StewardRuntimeDecisionResponse,
summary="判断小财管家运行时下一步动作",
description="结合任务队列、当前结构化结果和用户输入,使用 function calling 判断应提交当前单据、继续下一任务、补字段或重新规划。",
)
def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest,
db: DbSession,
) -> StewardRuntimeDecisionResponse:
return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload)
@router.post(
"/plans/stream",
summary="流式生成小财管家任务计划",
@@ -60,8 +96,8 @@ async def _iter_steward_plan_events(
StewardThinkingEvent(
event_id="intent_agent_stream_start",
stage="stream_start",
title="意图识别智能体接管",
content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销附件线索",
title="读取用户输入",
content="我先判断这句话里是否同时包含申请、报销附件归集事项,再决定处理顺序",
status="running",
).model_dump(mode="json"),
)
@@ -75,7 +111,7 @@ async def _iter_steward_plan_events(
for event in plan.thinking_events:
yield _encode_stream_event("thinking", event.model_dump(mode="json"))
await asyncio.sleep(0.18)
await asyncio.sleep(0.6)
yield _encode_stream_event("plan", plan.model_dump(mode="json"))

View File

@@ -38,10 +38,16 @@ class Settings(BaseSettings):
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT")
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT")
server_workers: int = Field(default=1, alias="SERVER_WORKERS")
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
background_schedulers_enabled: bool = Field(
default=True,
alias="BACKGROUND_SCHEDULERS_ENABLED",
)
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
@@ -49,8 +55,11 @@ class Settings(BaseSettings):
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
@@ -70,6 +79,7 @@ class Settings(BaseSettings):
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE")
seed_demo_financial_records: bool = Field(
default=False,

View File

@@ -18,11 +18,20 @@ def configure_session_factory() -> None:
if _engine is not None:
_engine.dispose()
_engine = create_engine(
settings.resolved_database_url,
echo=settings.sqlalchemy_echo,
pool_pre_ping=True,
)
engine_kwargs = {
"echo": settings.sqlalchemy_echo,
"pool_pre_ping": True,
}
if not settings.resolved_database_url.startswith("sqlite"):
engine_kwargs.update(
{
"pool_size": max(1, int(settings.sqlalchemy_pool_size or 10)),
"max_overflow": max(0, int(settings.sqlalchemy_max_overflow or 20)),
"pool_timeout": max(1, int(settings.sqlalchemy_pool_timeout or 30)),
}
)
_engine = create_engine(settings.resolved_database_url, **engine_kwargs)
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)

View File

@@ -25,6 +25,23 @@ from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
from app.services.knowledge_scheduler import knowledge_index_scheduler
def _effective_server_workers(settings: object) -> int:
server_workers = getattr(settings, "server_workers", None)
web_concurrency = getattr(settings, "web_concurrency", None)
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
try:
return max(1, int(workers or 1))
except (TypeError, ValueError):
return 1
def _should_start_background_schedulers(settings: object) -> bool:
if not bool(getattr(settings, "background_schedulers_enabled", True)):
return False
return _effective_server_workers(settings) <= 1
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
@@ -34,11 +51,19 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
prepare_agent_foundation()
prepare_knowledge_library()
sync_repository_hermes_skills()
knowledge_index_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
finance_report_scheduler.start()
schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started:
knowledge_index_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
finance_report_scheduler.start()
else:
logger.warning(
"Background schedulers skipped - workers=%s enabled=%s",
_effective_server_workers(settings),
settings.background_schedulers_enabled,
)
logger.info(
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
@@ -46,11 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.api_v1_prefix,
)
yield
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()
finance_dashboard_scheduler.shutdown()
knowledge_index_scheduler.shutdown()
if schedulers_started:
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()
finance_dashboard_scheduler.shutdown()
knowledge_index_scheduler.shutdown()
knowledge_index_task_manager.shutdown()
shutdown_knowledge_rag_runtime()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -28,6 +30,74 @@ class AgentRunRepository:
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def list_light(
self,
*,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
stmt = select(
AgentRun.id.label("id"),
AgentRun.run_id.label("run_id"),
AgentRun.agent.label("agent"),
AgentRun.source.label("source"),
AgentRun.user_id.label("user_id"),
AgentRun.task_id.label("task_id"),
AgentRun.permission_level.label("permission_level"),
AgentRun.status.label("status"),
AgentRun.result_summary.label("result_summary"),
AgentRun.error_message.label("error_message"),
AgentRun.started_at.label("started_at"),
AgentRun.finished_at.label("finished_at"),
AgentRun.route_json["job_type"].as_string().label("route_job_type"),
AgentRun.route_json["task_type"].as_string().label("route_task_type"),
AgentRun.route_json["task_code"].as_string().label("route_task_code"),
AgentRun.route_json["task_name"].as_string().label("route_task_name"),
AgentRun.route_json["task_title"].as_string().label("route_task_title"),
AgentRun.route_json["asset_name"].as_string().label("route_asset_name"),
AgentRun.route_json["selected_agent"].as_string().label("route_selected_agent"),
AgentRun.route_json["phase"].as_string().label("route_phase"),
AgentRun.route_json["stage"].as_string().label("route_stage"),
AgentRun.route_json["report_type"].as_string().label("route_report_type"),
AgentRun.route_json["snapshot_key"].as_string().label("route_snapshot_key"),
AgentRun.route_json["folder"].as_string().label("route_folder"),
AgentRun.route_json["heartbeat_at"].as_string().label("route_heartbeat_at"),
AgentRun.route_json["progress"].label("route_progress"),
AgentRun.ontology_json["scenario"].as_string().label("ontology_scenario"),
AgentRun.ontology_json["intent"].as_string().label("ontology_intent"),
AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"),
)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
stmt = stmt.where(AgentRun.status == status)
if source:
stmt = stmt.where(AgentRun.source == source)
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def list_light_tool_calls(self, run_ids: list[str]) -> list[dict[str, Any]]:
if not run_ids:
return []
stmt = (
select(
AgentToolCall.id.label("id"),
AgentToolCall.run_id.label("run_id"),
AgentToolCall.tool_type.label("tool_type"),
AgentToolCall.tool_name.label("tool_name"),
AgentToolCall.status.label("status"),
AgentToolCall.duration_ms.label("duration_ms"),
AgentToolCall.error_message.label("error_message"),
AgentToolCall.created_at.label("created_at"),
)
.where(AgentToolCall.run_id.in_(run_ids))
.order_by(AgentToolCall.created_at.asc())
)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def get_by_run_id(self, run_id: str) -> AgentRun | None:
stmt = select(AgentRun).where(AgentRun.run_id == run_id)
return self.db.scalar(stmt)

View File

@@ -28,7 +28,7 @@ class NotificationStatePatch(BaseModel):
class NotificationStateBatchPatch(BaseModel):
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=500)
class NotificationStateRead(BaseModel):

View File

@@ -8,6 +8,18 @@ from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardSlotNextAction = Literal["ask_user", "render_preview"]
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardRuntimeNextAction = Literal[
"plan_new_tasks",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
"ask_user",
"cancel_current_action",
"no_op",
]
StewardTaskStatus = Literal[
"planned",
"needs_confirmation",
@@ -88,3 +100,50 @@ class StewardPlanResponse(BaseModel):
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardSlotOption(BaseModel):
label: str = Field(description="用户可见选项文案。")
value: str = Field(description="写回本体字段的选项值。")
field_key: str = Field(description="对应 canonical ontology field。")
description: str = Field(default="", description="选项说明。")
class StewardSlotDecisionRequest(BaseModel):
task_type: StewardTaskType = Field(description="当前小财管家正在推进的任务类型。")
user_message: str = Field(description="用户原始话术或小财管家携带的任务上下文。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="当前已抽取的 canonical ontology 字段。")
missing_fields: list[str] = Field(default_factory=list, description="上游意图识别给出的 canonical 缺失字段。")
task_context: dict[str, Any] = Field(default_factory=dict, description="当前任务、附件、申请预览等上下文。")
class StewardSlotDecisionResponse(BaseModel):
decision_source: StewardSlotDecisionSource = Field(default="rule_fallback", description="字段决策来源。")
next_action: StewardSlotNextAction = Field(description="下一步应追问用户还是展示核对结果。")
required_fields: list[str] = Field(default_factory=list, description="模型认为当前业务需要的 canonical 字段。")
missing_fields: list[str] = Field(default_factory=list, description="当前仍缺失的 canonical 字段。")
question: str = Field(default="", description="需要追问时展示给用户的问题。")
options: list[StewardSlotOption] = Field(default_factory=list, description="可直接选择的补充选项。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardRuntimeDecisionRequest(BaseModel):
user_message: str = Field(description="用户当前输入。")
session_type: str = Field(default="steward", description="当前前端会话类型。")
runtime_state: dict[str, Any] = Field(default_factory=dict, description="小财管家运行时上下文。")
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方补充上下文。")
class StewardRuntimeDecisionResponse(BaseModel):
decision_source: StewardRuntimeDecisionSource = Field(default="rule_fallback", description="运行时决策来源。")
next_action: StewardRuntimeNextAction = Field(description="小财管家下一步动作。")
target_task_id: str = Field(default="", description="关联的小财管家任务 ID。")
target_message_id: str = Field(default="", description="关联的前端消息 ID。")
field_key: str = Field(default="", description="补字段时对应 canonical ontology field。")
field_value: str = Field(default="", description="补字段时用户提供的字段值。")
confirmation_required: bool = Field(default=False, description="执行该动作前是否仍需要用户二次确认。")
question: str = Field(default="", description="需要追问用户时展示的问题。")
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 状态码。'}")

View File

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

View File

@@ -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("_", "-")}:

View File

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

View File

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

View 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()

View 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()

View File

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

View File

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

View File

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

View File

@@ -36,10 +36,13 @@ from app.services import agent_foundation as agent_foundation_module
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,
)
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService
@@ -62,6 +65,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
for file_name in (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_FILENAME,
):
source_path = real_finance_rules / file_name
if source_path.exists():
@@ -181,8 +185,10 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
communication_rule = next(
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
)
preapproval_rule = next(item for item in rules if item.code == COMPANY_PREAPPROVAL_RULE_CODE)
travel_config = travel_rule.config_json or {}
communication_config = communication_rule.config_json or {}
preapproval_config = preapproval_rule.config_json or {}
assert travel_rule.scenario_json == ["差旅费"]
assert travel_config["scenario_category"] == "差旅费"
@@ -190,6 +196,12 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
assert communication_rule.scenario_json == ["通信费"]
assert communication_config["scenario_category"] == "通信费"
assert communication_config["ai_review_category"] == "通信费"
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
assert preapproval_config["tag"] == "财务规则"
assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy"
assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则"
assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"]
assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:

View File

@@ -106,6 +106,68 @@ def test_agent_run_service_updates_existing_tool_call() -> None:
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
def test_agent_run_list_uses_lightweight_preview_and_detail_keeps_full_payload() -> None:
with build_session() as db:
service = AgentRunService(db)
run = service.create_run(
agent=AgentName.HERMES.value,
source=AgentRunSource.SCHEDULE.value,
status=AgentRunStatus.SUCCEEDED.value,
ontology_json={
"scenario": "knowledge",
"intent": "sync",
"parse_strategy": "rule_fallback",
"model_invocation_summary": {"tokens": 999},
},
route_json={
"job_type": "knowledge_index_sync",
"phase": "indexing",
"progress": {
"percent": 50,
"total_documents": 2,
"completed_documents": 1,
"documents": [{"id": "doc-1", "text": "x" * 2000}],
},
"knowledge_ingest": {"documents": [{"id": "doc-1", "text": "x" * 2000}]},
},
)
service.record_tool_call(
run_id=run.run_id,
tool_type=AgentToolType.LLM.value,
tool_name="lightrag.index_documents",
request_json={"prompt": "x" * 2000},
response_json={"documents": [{"id": "doc-1", "text": "x" * 2000}]},
status="succeeded",
duration_ms=123,
)
listed = next(item for item in service.list_runs(limit=20) if item.run_id == run.run_id)
detail = service.get_run(run.run_id)
assert listed.ontology_json == {
"scenario": "knowledge",
"intent": "sync",
"parse_strategy": "rule_fallback",
}
assert listed.route_json["job_type"] == "knowledge_index_sync"
assert listed.route_json["phase"] == "indexing"
assert listed.route_json["progress"] == {
"percent": 50,
"total_documents": 2,
"completed_documents": 1,
}
assert "knowledge_ingest" not in listed.route_json
assert len(listed.tool_calls) == 1
assert listed.tool_calls[0].tool_name == "lightrag.index_documents"
assert listed.tool_calls[0].request_json == {}
assert listed.tool_calls[0].response_json == {}
assert detail is not None
assert "knowledge_ingest" in detail.route_json
assert detail.tool_calls[0].request_json["prompt"]
assert detail.tool_calls[0].response_json["documents"]
def test_agent_run_service_summarizes_model_and_tool_failures() -> None:
with build_session() as db:
service = AgentRunService(db)

View File

@@ -16,7 +16,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_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
@@ -147,7 +147,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert any(
isinstance(flag, dict)
@@ -160,7 +160,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("route_decision", {}).get("requires_budget_review") is False
for flag in approved.risk_flags_json
)
@@ -218,7 +218,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "approval_routing"
@@ -285,7 +285,7 @@ def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
route_flag = [
flag
for flag in approved.risk_flags_json

View File

@@ -319,6 +319,44 @@ def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch)
assert ai_pre_review["business_stage"] == "expense_application"
def test_preapproval_amount_rules_run_from_rule_library() -> None:
with build_session() as db:
claim = _build_claim(claim_no="RE-PREAPPROVAL-MEAL", expense_type="meal")
claim.amount = Decimal("501.00")
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)["flags"]
meal_flags = [
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.application.meal_high_value_without_preapproval"
]
assert len(meal_flags) == 1
assert meal_flags[0]["finance_rule_code"] == "expense.preapproval.policy"
assert "500" in meal_flags[0]["message"]
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-preapproval-ok",
"application_claim_no": "AP-202606-OK",
}
]
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)["flags"]
assert all(
flag.get("rule_code") != "risk.application.meal_high_value_without_preapproval"
for flag in flags
if isinstance(flag, dict)
)
def test_reimbursement_item_sync_persists_rule_center_risk_preview(
tmp_path,
monkeypatch,

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
@@ -31,11 +32,14 @@ from app.services.expense_claim_attachment_storage import ExpenseClaimAttachment
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
@@ -3907,6 +3911,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
approval_stage="审批完成",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525121000-ARCHIVED",
employee_name="",
department_name="E部",
project_code="PRJ-E",
expense_type="travel_application",
reason="E 申请",
location="广州",
amount=Decimal("600.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
status="approved",
approval_stage=APPLICATION_ARCHIVE_STAGE,
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525123000-HGFEDCBA",
employee_name="",
@@ -3933,7 +3954,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
assert {claim.claim_no for claim in claims} == {
"EXP-ARCH-101",
"EXP-ARCH-PAID",
"AP-20260525120000-ABCDEFGH",
"AP-20260525121000-ARCHIVED",
}
@@ -4288,6 +4309,65 @@ def test_admin_can_delete_archived_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
receipt_owner = CurrentUserContext(
username="emp-1",
name="Employee",
role_codes=[],
is_admin=False,
)
admin_user = CurrentUserContext(
username="superadmin",
name="Admin",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="Shanghai")
db.add(claim)
db.commit()
claim_id = claim.id
claim_no = claim.claim_no
item_id = claim.items[0].id
receipt_service = ReceiptFolderService()
receipt = receipt_service.save_receipt(
filename="admin-delete-linked-receipt.pdf",
content=b"%PDF-1.4 linked",
media_type="application/pdf",
current_user=receipt_owner,
linked_claim_id=claim_id,
linked_claim_no=claim_no,
linked_item_id=item_id,
document=OcrRecognizeDocumentRead(
filename="admin-delete-linked-receipt.pdf",
media_type="application/pdf",
text="invoice number 123 amount 100",
document_type="vat_invoice",
document_type_label="invoice",
scene_code="other",
scene_label="receipt",
),
)
assert receipt.status == "linked"
deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner)
assert unlinked_receipt.status == "unlinked"
assert unlinked_receipt.linked_claim_id == ""
assert unlinked_receipt.linked_claim_no == ""
assert unlinked_receipt.linked_at is None
finally:
get_settings.cache_clear()
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
@@ -4842,7 +4922,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert approved.approval_stage == "关联单据状态"
archived_claims = ExpenseClaimService(db).list_archived_claims(
CurrentUserContext(
username="finance-archive@example.com",
@@ -4851,7 +4931,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
is_admin=False,
)
)
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交"
@@ -4891,7 +4971,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("generated_draft_claim_id") == generated_draft.id
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
for flag in approved.risk_flags_json
@@ -5002,7 +5082,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
@@ -5147,7 +5227,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
@@ -5158,7 +5238,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
@@ -5235,7 +5315,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert approved.approval_stage == "关联单据状态"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
@@ -5250,7 +5330,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
@@ -5819,6 +5899,94 @@ def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
)
def test_marking_linked_reimbursement_paid_archives_application_claim() -> None:
current_user = CurrentUserContext(
username="finance-pay-linked-application@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
application_claim = ExpenseClaim(
claim_no="AP-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel_application",
reason="支撑国网部署",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC),
status="approved",
approval_stage=APPROVAL_DONE_STAGE,
risk_flags_json=[],
)
db.add(application_claim)
db.flush()
reimbursement_claim = ExpenseClaim(
claim_no="RE-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel",
reason="支撑国网部署报销",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC),
status="pending_payment",
approval_stage="待付款",
risk_flags_json=[
{
"source": "application_handoff",
"event_type": "expense_application_to_reimbursement_draft",
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
}
],
)
db.add(reimbursement_claim)
db.commit()
archived_before = ExpenseClaimService(db).list_archived_claims(current_user)
assert all(claim.claim_no != application_claim.claim_no for claim in archived_before)
paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)
assert paid is not None
db.refresh(application_claim)
assert application_claim.status == "approved"
assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_archive_sync"
and flag.get("event_type") == "expense_application_archived_by_reimbursement"
and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no
and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE
for flag in application_claim.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "payment"
and any(
item.get("application_claim_no") == application_claim.claim_no
for item in flag.get("archived_application_claims", [])
if isinstance(item, dict)
)
for flag in paid.risk_flags_json
)
archived_after = ExpenseClaimService(db).list_archived_claims(current_user)
assert any(claim.claim_no == application_claim.claim_no for claim in archived_after)
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext(
username="finance-returned@example.com",

View File

@@ -3,7 +3,8 @@ from app.services.expense_claim_status_registry import (
normalize_expense_claim_state,
)
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
FINANCE_APPROVAL_STAGE,
PAYMENT_PAID_STAGE,
@@ -40,7 +41,19 @@ def test_normalize_reimbursement_archive_stage_differs_from_application_done() -
)
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
assert application_state.approval_stage == APPROVAL_DONE_STAGE
assert application_state.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_normalize_application_archive_stage_is_distinct_from_approval_done() -> None:
state = normalize_expense_claim_state(
"approved",
APPLICATION_ARCHIVE_STAGE,
claim_no="AP-20260602-0002",
expense_type="travel_application",
)
assert state.status == "approved"
assert state.approval_stage == APPLICATION_ARCHIVE_STAGE
def test_normalize_payment_stages_by_status() -> None:

View File

@@ -117,3 +117,28 @@ def test_notification_state_endpoint_reads_and_updates_current_user_state() -> N
assert payload["states"][0]["hidden_at"] is None
assert payload["states"][0]["context_json"]["kind"] == "workbench"
assert other_response.json()["states"] == []
def test_notification_state_endpoint_accepts_document_center_bulk_read_state() -> None:
client = build_client()
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
states = [
{
"notification_id": f"document:owned:DOC-{index}",
"read": True,
"hidden": False,
"context_json": {"kind": "document", "target_type": "documents-center"},
}
for index in range(150)
]
response = client.post(
"/api/v1/notification-states",
json={"states": states},
headers=headers,
)
assert response.status_code == 200
payload = response.json()
assert len(payload["states"]) == 150
assert all(item["read_at"] for item in payload["states"])

View File

@@ -179,6 +179,64 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
assert recognized.lines[1].page_index == 1
def test_ocr_service_reuses_cached_document_for_same_content(
monkeypatch,
tmp_path: Path,
) -> None:
calls = {"count": 0}
def fake_invoke_worker(
self,
*,
python_bin: str,
worker_path: str,
input_paths: list[Path],
) -> dict:
calls["count"] += 1
return {
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"documents": [
{
"input_path": str(input_paths[0]),
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"text": "增值税电子发票 金额 20 元",
"summary": "增值税电子发票,金额 20 元。",
"avg_score": 0.97,
"line_count": 1,
"page_count": 1,
"warnings": [],
"lines": [
{
"text": "增值税电子发票 金额 20 元",
"score": 0.97,
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
}
],
}
],
}
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
OcrService._result_cache.clear()
get_settings.cache_clear()
try:
first = OcrService().recognize_files([("first.png", b"same-image", "image/png")])
second = OcrService().recognize_files([("second.png", b"same-image", "image/png")])
finally:
OcrService._result_cache.clear()
get_settings.cache_clear()
assert calls["count"] == 1
assert first.documents[0].filename == "first.png"
assert second.documents[0].filename == "second.png"
assert second.documents[0].summary == first.documents[0].summary
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
monkeypatch,
tmp_path: Path,

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import pytest
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead
@@ -71,7 +69,7 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
get_settings.cache_clear()
def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None:
def test_receipt_folder_unlink_receipts_for_claim_marks_linked_receipts_unlinked(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
@@ -101,9 +99,17 @@ def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkey
),
)
assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1"
assert service.delete_receipts_for_claim("claim-1") == 1
with pytest.raises(FileNotFoundError):
service.get_receipt(receipt.id, current_user)
linked_detail = service.get_receipt(receipt.id, current_user)
assert linked_detail.status == "linked"
assert linked_detail.linked_claim_id == "claim-1"
assert linked_detail.linked_claim_no == "RE-001"
assert service.unlink_receipts_for_claim("claim-1") == 1
unlinked_detail = service.get_receipt(receipt.id, current_user)
assert unlinked_detail.status == "unlinked"
assert unlinked_detail.linked_claim_id == ""
assert unlinked_detail.linked_claim_no == ""
assert unlinked_detail.linked_at is None
finally:
get_settings.cache_clear()

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
import pytest
from app.core.config import SERVER_DIR
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.risk_rule_dsl_examples import (
get_risk_rule_dsl_example,
@@ -166,6 +169,95 @@ def test_date_rule_uses_application_month_before_ticket_item_date() -> None:
assert condition["outside_dates"] == ["2026-02-20"]
def test_application_context_values_are_available_to_composite_rules() -> None:
claim = _claim(amount=Decimal("3000.00"))
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-ctx-1",
"application_claim_no": "AP-202606-CTX",
"application_detail": {
"application_amount": "3000",
"application_expense_type": "office",
},
}
]
manifest = {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"params": {
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
"conditions": [
{
"id": "application_present",
"operator": "exists_any",
"fields": ["application.id", "application.claim_no"],
}
],
"hit_logic": "application_present",
"condition_summary": "application exists",
},
}
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is not None
condition = result["evidence"]["conditions"][0]
assert condition["values"] == ["application-ctx-1", "AP-202606-CTX"]
@pytest.mark.parametrize(
("file_name", "expense_type", "amount"),
[
("risk.application.meal_high_value_without_preapproval.json", "meal", Decimal("501.00")),
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2001.00")),
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2001.00")),
],
)
def test_preapproval_amount_rules_hit_without_linked_application(
file_name: str,
expense_type: str,
amount: Decimal,
) -> None:
claim = _claim(amount=amount)
claim.expense_type = expense_type
manifest = _load_rule_manifest(file_name)
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is not None
assert result["evidence"]["condition_results"]["amount_exceeds_preapproval_threshold"] is True
assert result["evidence"]["condition_results"]["application_present"] is False
@pytest.mark.parametrize(
("file_name", "expense_type", "amount"),
[
("risk.application.meal_high_value_without_preapproval.json", "entertainment", Decimal("800.00")),
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2600.00")),
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2600.00")),
],
)
def test_preapproval_amount_rules_skip_when_application_is_linked(
file_name: str,
expense_type: str,
amount: Decimal,
) -> None:
claim = _claim(amount=amount)
claim.expense_type = expense_type
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": "application-linked-ok",
"application_claim_no": "AP-202606-OK",
}
]
manifest = _load_rule_manifest(file_name)
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
assert result is None
def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
claim = ExpenseClaim(
claim_no="TEST-RISK-RULE-DSL",
@@ -193,3 +285,8 @@ def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
)
]
return claim
def _load_rule_manifest(file_name: str) -> dict:
path = Path(SERVER_DIR) / "rules" / "risk-rules" / file_name
return json.loads(path.read_text(encoding="utf-8"))

View File

@@ -93,6 +93,38 @@ class EntertainmentFunctionCallingIntentAgent:
)
class ApplicationFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别出差申请",
"content": "模型识别到用户要发起北京出差申请,并且后续还有报销事项。",
}
],
"tasks": [
{
"task_type": "expense_application",
"title": "北京出差申请",
"summary": "明天前往北京出差3天支撑国网仿生产部署。",
"confidence": 0.94,
"ontology_fields": {
"time_range": "明天",
"location": "北京",
"expense_type": "差旅",
"reason": "支撑国网仿生产部署",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费",
@@ -136,6 +168,22 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
assert "没有说明出行方式" in gap_events[0].content
assert "火车、飞机或轮船" in gap_events[0].content
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
@@ -197,6 +245,10 @@ def test_steward_planner_treats_future_travel_without_apply_word_as_application(
assert result.tasks[0].ontology_fields["location"] == "北京"
assert result.tasks[0].ontology_fields["expense_type"] == "travel"
assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
assert "没有说明出行方式" in gap_events[0].content
assert result.tasks[1].assigned_agent == "reimbursement_assistant"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[1].ontology_fields["expense_type"] == "entertainment"

View File

@@ -0,0 +1,96 @@
from app.schemas.steward import StewardRuntimeDecisionRequest
from app.services.steward_runtime_decision_agent import (
STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
StewardRuntimeDecisionAgent,
)
class _FakeToolCall:
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
class _FakeRuntimeResult:
def __init__(self, tool_call=None):
self.tool_call = tool_call
def calls_as_dicts(self):
return [{"tool": self.tool_call.name if self.tool_call else ""}]
class _FakeRuntime:
def __init__(self, payload):
self.payload = payload
self.last_messages = []
self.last_tools = []
self.last_tool_choice = None
def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs):
self.last_messages = messages
self.last_tools = tools
self.last_tool_choice = tool_choice
if self.payload is None:
return _FakeRuntimeResult()
return _FakeRuntimeResult(_FakeToolCall(STEWARD_RUNTIME_DECISION_FUNCTION_NAME, self.payload))
def test_steward_runtime_decision_uses_function_calling_context():
runtime = _FakeRuntime(
{
"next_action": "submit_current_application",
"target_task_id": "task-application-beijing",
"target_message_id": "msg-application-preview",
"field_key": "",
"field_value": "",
"confirmation_required": False,
"question": "",
"response_text": "",
"rationale": "用户确认当前申请核对表无误。",
}
)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"waiting_for": "application_submit_confirmation",
"pending_application": {
"message_id": "msg-application-preview",
"task_id": "task-application-beijing",
"ready_to_submit": True,
},
"remaining_tasks": [
{"task_id": "task-reimbursement-meal", "task_type": "reimbursement"}
],
},
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "submit_current_application"
assert result.target_message_id == "msg-application-preview"
assert result.target_task_id == "task-application-beijing"
assert runtime.last_tool_choice["function"]["name"] == STEWARD_RUNTIME_DECISION_FUNCTION_NAME
assert "runtime_state" in runtime.last_messages[-1]["content"]
def test_steward_runtime_decision_fallback_keeps_current_context():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"pending_steward_action": {
"message_id": "msg-next-task",
"target_task_id": "task-reimbursement-meal",
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task"
assert result.target_task_id == "task-reimbursement-meal"

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from app.schemas.steward import StewardSlotDecisionRequest
from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatToolCall, RuntimeToolCallResult
from app.services.steward_slot_decision_agent import (
STEWARD_SLOT_DECISION_FUNCTION_NAME,
StewardSlotDecisionAgent,
)
class FakeSlotRuntime:
def __init__(self, arguments=None):
self.arguments = arguments
self.messages = None
def complete_with_tool_call(self, messages, **kwargs):
self.messages = messages
if self.arguments is None:
return RuntimeToolCallResult(tool_call=None, calls=[])
return RuntimeToolCallResult(
tool_call=RuntimeChatToolCall(
name=STEWARD_SLOT_DECISION_FUNCTION_NAME,
arguments=self.arguments,
),
calls=[
RuntimeChatCallTrace(
slot="main",
provider="OpenAI Compatible",
model="fake",
attempt=1,
status="succeeded",
)
],
)
def test_steward_slot_decision_uses_function_calling_result() -> None:
runtime = FakeSlotRuntime(
{
"next_action": "ask_user",
"required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"],
"missing_fields": ["transport_mode"],
"question": "请问你这次打算怎么出行?",
"options": [
{"field_key": "transport_mode", "label": "飞机", "value": "飞机"},
{"field_key": "transport_mode", "label": "火车", "value": "火车"},
],
"rationale": "出行方式会影响交通费用测算。",
}
)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="明天出差北京3天支撑国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert [item.value for item in result.options] == ["飞机", "火车"]
assert "出行方式会影响" in result.rationale
def test_steward_slot_decision_falls_back_to_intent_missing_fields_only() -> None:
runtime = FakeSlotRuntime(arguments=None)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="还需要补充:出行方式(例如高铁、飞机、自驾、出租车)",
ontology_fields={
"expense_type": "travel",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert [item.value for item in result.options] == ["火车", "飞机", "轮船"]
assert "高铁" not in result.required_fields
def test_steward_slot_decision_does_not_ask_user_for_application_profile_or_attachments() -> None:
runtime = FakeSlotRuntime(
{
"next_action": "ask_user",
"required_fields": [
"expense_type",
"time_range",
"location",
"reason",
"amount",
"attachments",
"employee_no",
],
"missing_fields": ["attachments", "employee_no"],
"question": "请补充附件和员工编号。",
"options": [],
"rationale": "附件/凭证和员工编号为合规必需字段。",
}
)
result = StewardSlotDecisionAgent(runtime).decide(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="明天出差北京3天支撑国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网仿生产部署",
},
missing_fields=["attachments", "employee_no"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "render_preview"
assert result.missing_fields == []
assert "attachments" not in result.required_fields
assert "employee_no" not in result.required_fields
assert result.options == []
assert "合规必需字段" not in result.rationale

View File

@@ -693,6 +693,66 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
assert second_response.draft_payload is None
def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None:
session_factory = build_session_factory()
with session_factory() as db:
existing_claim = ExpenseClaim(
id="application-overlap-1",
claim_no="AP-202606050001-OVERLAP",
employee_name="pytest",
department_name="技术部",
expense_type="travel_application",
reason="支撑国网部署",
location="北京",
amount=Decimal("2700.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[
{
"source": "application_detail",
"business_stage": "expense_application",
"application_detail": {
"application_type": "差旅费用申请",
"time": "2026-06-05 至 2026-06-07",
"location": "北京",
"reason": "支撑国网部署",
},
}
],
)
db.add(existing_claim)
db.commit()
response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={
"manager_name": "向万红",
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-06-06 至 2026-06-08",
"location": "北京",
"reason": "支撑国网仿生产部署",
"days": "3天",
"transportMode": "火车",
"amount": "2700元",
}
},
},
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "已存在申请单" in response.answer
assert "系统没有重复创建" in response.answer
assert existing_claim.claim_no in response.answer
assert response.draft_payload is None
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
session_factory = build_session_factory()
with session_factory() as db: