chore: 更新配置和构建脚本

This commit is contained in:
caoxiaozhu
2026-05-18 02:53:06 +00:00
parent 9b97f456cf
commit 8814fe7dfa
15 changed files with 1592 additions and 15 deletions

View File

@@ -18,8 +18,9 @@ dependencies = [
"pydantic-settings>=2.6.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0",
"email-validator>=2.2.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0",
"lightrag-hku>=1.4.16,<1.5.0",
"python-multipart>=0.0.20,<1.0.0",
"openpyxl>=3.1.5,<4.0.0",
"lightrag-hku>=1.4.16,<1.5.0",
"qdrant-client>=1.18.0,<2.0.0",
]

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
OUTPUT_PATH = Path(__file__).resolve().parents[1] / "rules" / "finance-rules" / "公司差旅费报销规则.xlsx"
ROWS = [
(1, "北京", "北京", "", "", 500, 450, 450, 500, ""),
(2, "天津", "6 个中心城区、滨海新区、东丽区、西青区、津南区、北辰区、武清区、宝坻区、静海区、蓟县", "", "", 380, 360, 350, 380, ""),
(2, "天津", "宁河区", "", "", 320, 300, 280, 320, ""),
(3, "河北", "石家庄", "", "", 350, 330, 300, 350, ""),
(3, "河北", "张家口、秦皇岛、廊坊、承德、保定", "张家口市;秦皇岛市;承德市", "张家口市7-9 月、11-3 月秦皇岛市7-8 月承德市7-9 月", 350, 300, 250, 350, 420),
(3, "河北", "雄安新区(不含雄县、安新县、容城县)", "", "", 450, 400, 350, 450, ""),
(3, "河北", "其他地区", "", "", 310, 290, 250, 310, ""),
(4, "山西", "太原", "", "", 350, 330, 300, 350, ""),
(4, "山西", "大同、晋城", "", "", 350, 300, 250, 350, ""),
(4, "山西", "临汾", "", "", 330, 300, 250, 330, ""),
(4, "山西", "阳泉、长治、晋中", "", "", 310, 290, 250, 310, ""),
(4, "山西", "其他地区", "", "", 240, 220, 200, 240, ""),
(5, "内蒙古", "呼和浩特", "", "", 350, 330, 300, 350, ""),
(5, "内蒙古", "海拉尔市、满洲里市、阿尔山市", "海拉尔市、满洲里市、阿尔山市", "7-9 月", 320, 300, 250, 320, 380),
(5, "内蒙古", "二连浩特市", "二连浩特市", "7-9 月", 320, 300, 250, 320, 380),
(5, "内蒙古", "额济纳市", "额济纳市", "9-10 月", 320, 300, 250, 320, 380),
(5, "内蒙古", "其他地区", "", "", 320, 300, 250, 320, ""),
(6, "辽宁", "沈阳", "", "", 350, 330, 300, 350, ""),
(6, "辽宁", "其他地区", "", "", 330, 300, 250, 330, ""),
(7, "大连", "大连", "大连", "7-9 月", 350, 300, 300, 350, 420),
(8, "吉林", "长春", "长春", "7-9 月", 350, 330, 300, 350, 420),
(8, "吉林", "吉林、延边州、长白山管理区", "吉林、延边州、长白山管理区", "7-9 月", 350, 300, 250, 350, 420),
(8, "吉林", "其他地区", "", "", 300, 280, 250, 300, ""),
(9, "黑龙江", "哈尔滨", "哈尔滨", "7-9 月", 350, 330, 300, 350, 420),
(9, "黑龙江", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "6-8 月", 300, 280, 250, 300, 360),
(9, "黑龙江", "其他地区", "", "", 300, 280, 250, 300, ""),
(10, "上海", "上海", "", "", 500, 450, 450, 500, ""),
(11, "江苏", "南京", "", "", 380, 350, 350, 380, ""),
(11, "江苏", "苏州、无锡、常州、镇江", "", "", 350, 300, 250, 380, ""),
(11, "江苏", "其他地区", "", "", 350, 300, 250, 360, ""),
(12, "浙江", "杭州", "", "", 400, 350, 350, 400, ""),
(12, "浙江", "其他地区", "", "", 340, 300, 250, 340, ""),
(13, "宁波", "宁波", "", "", 350, 300, 250, 350, ""),
(14, "安徽", "合肥", "", "", 350, 330, 300, 350, ""),
(14, "安徽", "其他地区", "", "", 350, 300, 250, 350, ""),
(15, "福建", "福州", "", "", 380, 350, 300, 380, ""),
(15, "福建", "泉州、平潭综合实验区", "", "", 350, 300, 250, 380, ""),
(15, "福建", "其他地区", "", "", 350, 300, 250, 350, ""),
(16, "厦门", "厦门", "", "", 400, 380, 350, 400, ""),
(17, "江西", "南昌", "", "", 350, 330, 300, 350, ""),
(17, "江西", "其他地区", "", "", 350, 300, 250, 350, ""),
(18, "山东", "济南", "", "", 380, 350, 300, 380, ""),
(18, "山东", "烟台、威海、日照", "烟台、威海、日照", "7-9 月", 350, 300, 250, 380, 450),
(18, "山东", "淄博、枣庄、东营、潍坊、济宁、泰安", "", "", 350, 300, 250, 380, ""),
(18, "山东", "其他地区", "", "", 350, 300, 250, 360, ""),
(19, "青岛", "青岛", "青岛", "7-9 月", 350, 300, 250, 380, 450),
(20, "河南", "郑州", "", "", 380, 350, 300, 380, ""),
(20, "河南", "洛阳", "洛阳", "4-5 月上旬", 330, 300, 250, 330, 390),
(20, "河南", "其他地区", "", "", 330, 300, 250, 330, ""),
(21, "湖北", "武汉", "", "", 350, 330, 300, 350, ""),
(21, "湖北", "其他地区", "", "", 320, 300, 250, 320, ""),
(22, "湖南", "长沙", "", "", 350, 330, 300, 350, ""),
(22, "湖南", "其他地区", "", "", 330, 300, 250, 330, ""),
(23, "广东", "广州", "", "", 450, 400, 400, 450, ""),
(23, "广东", "珠海", "", "", 450, 400, 350, 450, ""),
(23, "广东", "佛山、东莞、中山、江门", "", "", 350, 300, 250, 450, ""),
(23, "广东", "其他地区", "", "", 350, 300, 250, 420, ""),
(24, "深圳", "深圳", "", "", 450, 400, 400, 450, ""),
(25, "广西", "南宁", "", "", 350, 330, 300, 350, ""),
(25, "广西", "桂林、北海", "桂林、北海", "1-2 月、7-9 月", 330, 300, 250, 330, 390),
(25, "广西", "其他地区", "", "", 330, 300, 250, 330, ""),
(26, "海南", "海口、文昌、澄迈县", "海口、文昌、澄迈县", "11-2 月", 350, 330, 310, 350, 420),
(26, "海南", "琼海、万宁、陵水县、保亭县", "琼海、万宁、陵水县、保亭县", "11-3 月", 350, 330, 310, 350, 420),
(26, "海南", "三沙、儋州、五指山、东方、安定县、屯昌县、临高县、白沙县、昌江县、乐东县、琼中县、洋浦开发区", "", "", 350, 330, 310, 350, ""),
(26, "海南", "三亚", "三亚", "10-4 月", 400, 380, 350, 400, 480),
(27, "重庆", "9 个中心城区、北部新区", "", "", 370, 350, 330, 370, ""),
(27, "重庆", "其他地区", "", "", 300, 280, 260, 300, ""),
(28, "四川", "成都", "", "", 370, 350, 330, 370, ""),
(28, "四川", "阿坝州、甘孜州", "", "", 330, 300, 250, 330, ""),
(28, "四川", "绵阳、乐山、雅安", "", "", 320, 300, 250, 320, ""),
(28, "四川", "宜宾", "", "", 300, 280, 250, 300, ""),
(28, "四川", "凉山州", "", "", 330, 300, 250, 330, ""),
(28, "四川", "德阳、遂宁、巴中", "", "", 310, 290, 250, 310, ""),
(28, "四川", "其他地区", "", "", 300, 280, 250, 300, ""),
(29, "贵州", "贵阳", "", "", 370, 350, 300, 370, ""),
(29, "贵州", "其他地区", "", "", 300, 280, 250, 300, ""),
(30, "云南", "昆明", "", "", 380, 350, 300, 380, ""),
(30, "云南", "大理州、丽江市、迪庆州、西双版纳州", "", "", 350, 300, 250, 380, ""),
(30, "云南", "其他地区", "", "", 330, 300, 250, 330, ""),
(31, "西藏", "拉萨", "拉萨", "5-10 月", 350, 330, 300, 350, 420),
(31, "西藏", "其他地区", "其他地区", "5-10 月", 300, 280, 250, 300, 360),
(32, "陕西", "西安", "", "", 350, 330, 300, 350, ""),
(32, "陕西", "榆林、延安", "", "", 300, 280, 250, 300, ""),
(32, "陕西", "杨凌区", "", "", 260, 240, 220, 260, ""),
(32, "陕西", "咸阳、宝鸡", "", "", 260, 240, 220, 260, ""),
(32, "陕西", "渭南、韩城", "", "", 260, 240, 220, 260, ""),
(32, "陕西", "其他地区", "", "", 230, 210, 200, 230, ""),
(33, "甘肃", "兰州", "", "", 350, 330, 300, 350, ""),
(33, "甘肃", "其他地区", "", "", 310, 290, 250, 310, ""),
(34, "青海", "西宁", "西宁", "6-9 月", 350, 330, 300, 350, 420),
(34, "青海", "玉树州", "玉树州", "5-9 月", 300, 280, 250, 300, 360),
(34, "青海", "果洛州", "", "", 300, 280, 250, 300, ""),
(34, "青海", "海北州、黄南州", "海北州、黄南州", "5-9 月", 250, 230, 210, 250, 300),
(34, "青海", "海东、海南州", "海东、海南州", "5-9 月", 250, 230, 210, 250, 300),
(34, "青海", "海西州", "海西州", "5-9 月", 200, 200, 200, 200, 240),
(35, "宁夏", "银川", "", "", 350, 330, 300, 350, ""),
(35, "宁夏", "其他地区", "", "", 330, 300, 250, 330, ""),
(36, "新疆", "乌鲁木齐", "", "", 350, 330, 300, 350, ""),
(36, "新疆", "石河子、克拉玛依、昌吉州、伊犁州、阿勒泰地区、博州、吐鲁番、哈密地区、巴州、和田地区", "", "", 340, 300, 250, 340, ""),
(36, "新疆", "克州", "", "", 320, 300, 250, 320, ""),
(36, "新疆", "喀什地区", "", "", 300, 280, 250, 300, ""),
(36, "新疆", "阿克苏地区", "", "", 300, 280, 250, 300, ""),
(36, "新疆", "塔城地区", "", "", 300, 280, 250, 300, ""),
(37, "港澳台", "香港、澳门、台湾", "", "", 450, 400, 350, 500, ""),
(38, "国外", "国外", "", "", 700, 600, 500, 700, ""),
]
def build_workbook() -> Workbook:
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "差旅住宿费标准"
headers = [
"序号",
"地区",
"地区(城市)",
"旺季地区",
"旺季期间",
"公司级管理人员、高层经理P7及以上",
"中层经理、基层经理P4-P6、外聘专家",
"其他员工",
"超标限额",
"旺季超标限额",
]
worksheet.append(["差旅住宿费标准"])
worksheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
worksheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
worksheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
worksheet["A1"].alignment = Alignment(horizontal="center")
worksheet.append(headers)
for row in ROWS:
worksheet.append(row)
header_fill = PatternFill("solid", fgColor="D9EAF7")
thin = Side(style="thin", color="B7C9D6")
for cell in worksheet[2]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
for row in worksheet.iter_rows(min_row=3, max_row=worksheet.max_row):
for cell in row:
cell.alignment = Alignment(vertical="center", wrap_text=True)
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
for cell in row[5:]:
cell.alignment = Alignment(horizontal="center", vertical="center")
worksheet.freeze_panes = "A3"
worksheet.auto_filter.ref = f"A2:J{worksheet.max_row}"
widths = [8, 12, 42, 28, 28, 22, 26, 12, 12, 14]
for index, width in enumerate(widths, start=1):
worksheet.column_dimensions[get_column_letter(index)].width = width
worksheet.row_dimensions[1].height = 26
worksheet.row_dimensions[2].height = 42
for index in range(3, worksheet.max_row + 1):
worksheet.row_dimensions[index].height = 36
subsidy_sheet = workbook.create_sheet("出差补助标准")
subsidy_headers = [
"补助类型",
"项目",
"港澳台",
"直辖市/特区",
"西藏",
"新疆-乌鲁木齐",
"新疆-其他",
"其他地区",
"国外",
]
subsidy_rows = [
("伙食补助", "自行解决餐食", 75, 65, 65, 55, 55, 55, 140),
("基本补助", "基本出差补贴", 35, 35, 105, 75, 135, 35, 35),
("合计", "", 110, 100, 170, 130, 190, 90, 175),
]
subsidy_sheet.append(["出差补助标准"])
subsidy_sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(subsidy_headers))
subsidy_sheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
subsidy_sheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
subsidy_sheet["A1"].alignment = Alignment(horizontal="center")
subsidy_sheet.append(subsidy_headers)
for row in subsidy_rows:
subsidy_sheet.append(row)
subsidy_sheet.append(["备注", "注 1新疆分公司同事出差至乌鲁木齐外的其他新疆地区基本补助标准为 95 元。"])
subsidy_sheet.append(["备注", "注 2西藏分公司同事出差至拉萨市外的其他西藏地区基本补助标准为 35 元。"])
subsidy_sheet.merge_cells(start_row=6, start_column=2, end_row=6, end_column=len(subsidy_headers))
subsidy_sheet.merge_cells(start_row=7, start_column=2, end_row=7, end_column=len(subsidy_headers))
for cell in subsidy_sheet[2]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
for row in subsidy_sheet.iter_rows(min_row=3, max_row=7):
for cell in row:
cell.alignment = Alignment(vertical="center", wrap_text=True)
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
for cell in subsidy_sheet[5]:
cell.font = Font(bold=True)
cell.fill = PatternFill("solid", fgColor="E2F0D9")
subsidy_sheet.freeze_panes = "A3"
subsidy_sheet.auto_filter.ref = "A2:I5"
subsidy_widths = [14, 18, 12, 16, 12, 18, 16, 14, 12]
for index, width in enumerate(subsidy_widths, start=1):
subsidy_sheet.column_dimensions[get_column_letter(index)].width = width
subsidy_sheet.row_dimensions[1].height = 26
subsidy_sheet.row_dimensions[2].height = 36
for index in range(3, 8):
subsidy_sheet.row_dimensions[index].height = 28
source_sheet = workbook.create_sheet("来源说明")
source_sheet.append(["来源文件", "页码", "说明"])
source_sheet.append(
[
"远光软件2026费用报销说明手册.pdf",
"第 13-19 页",
"依据 PDF 附件 3《差旅住宿费标准》整理为默认支撑表。",
]
)
source_sheet.append(
[
"远光软件2026费用报销说明手册.pdf",
"第 20 页",
"依据 PDF 附件 4《出差补助标准》整理为默认支撑表。",
]
)
for row in source_sheet.iter_rows():
for cell in row:
cell.alignment = Alignment(wrap_text=True, vertical="center")
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
for cell in source_sheet[1]:
cell.font = Font(bold=True)
cell.fill = header_fill
source_sheet.column_dimensions["A"].width = 34
source_sheet.column_dimensions["B"].width = 14
source_sheet.column_dimensions["C"].width = 56
return workbook
def main() -> None:
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
workbook = build_workbook()
workbook.save(OUTPUT_PATH)
print(OUTPUT_PATH)
print(f"rows={len(ROWS)}")
if __name__ == "__main__":
main()

View File

@@ -240,7 +240,7 @@ run_bootstrap_python() {
}
dependencies_ready() {
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, openpyxl, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
}
pip_ready() {

View File

@@ -62,13 +62,39 @@ def get_current_user(
)
def require_admin_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
def require_admin_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
if current_user.is_admin or "manager" in current_user.role_codes:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以上传、删除或修改知识库文件。",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以上传、删除或修改知识库文件。",
)
def require_rule_editor_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
role_codes = {item.strip() for item in current_user.role_codes}
if current_user.is_admin or "manager" in role_codes or "finance" in role_codes:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有财务人员或高级管理人员可以编辑规则草稿。",
)
def require_rule_reviewer_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
role_codes = {item.strip() for item in current_user.role_codes}
if current_user.is_admin or "manager" in role_codes:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有高级管理人员可以审核、发布或恢复正式规则。",
)

View File

@@ -50,6 +50,16 @@ class AgentRunRepository:
self.db.refresh(tool_call)
return tool_call
def get_tool_call(self, tool_call_id: str) -> AgentToolCall | None:
stmt = select(AgentToolCall).where(AgentToolCall.id == tool_call_id)
return self.db.scalar(stmt)
def save_tool_call(self, tool_call: AgentToolCall) -> AgentToolCall:
self.db.add(tool_call)
self.db.commit()
self.db.refresh(tool_call)
return tool_call
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
self.db.add(semantic_parse)
self.db.commit()

View File

@@ -0,0 +1,478 @@
from __future__ import annotations
import hashlib
import json
import mimetypes
import re
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from io import BytesIO
from pathlib import Path
from xml.sax.saxutils import escape
from zipfile import ZIP_DEFLATED, ZipFile
from openpyxl import load_workbook
from app.core.config import SERVER_DIR, get_settings
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
re.DOTALL,
)
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
FINANCE_RULES_LIBRARY = "finance-rules"
RISK_RULES_LIBRARY = "risk-rules"
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
SPREADSHEET_MIME_TYPE = (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
@dataclass(slots=True)
class RuleSpreadsheetMeta:
file_name: str
storage_key: str
mime_type: str
size_bytes: int
checksum: str
updated_at: str
updated_by: str
source: str = "upload"
class AgentAssetSpreadsheetManager:
def __init__(
self,
storage_root: Path | None = None,
rule_root: Path | None = None,
) -> None:
settings = get_settings()
self.storage_root = Path(storage_root or settings.resolved_storage_root_dir).resolve()
self.asset_root = (self.storage_root / "agent_assets").resolve()
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
def ensure_rule_library_dirs(self) -> None:
for library in sorted(RULE_LIBRARY_NAMES):
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
def store_spreadsheet(
self,
*,
asset_id: str,
version: str,
file_name: str,
content: bytes,
actor_name: str,
source: str = "upload",
) -> RuleSpreadsheetMeta:
normalized_name = Path(str(file_name or "").strip()).name.strip()
if not normalized_name:
raise ValueError("规则表文件名不能为空。")
if not content:
raise ValueError("规则表文件内容不能为空。")
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
target_path = (self.storage_root / relative_path).resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
return RuleSpreadsheetMeta(
file_name=normalized_name,
storage_key=relative_path.as_posix(),
mime_type=mime_type,
size_bytes=len(content),
checksum=hashlib.sha256(content).hexdigest(),
updated_at=datetime.now(UTC).isoformat(),
updated_by=str(actor_name or "system").strip() or "system",
source=source,
)
def store_rule_library_spreadsheet(
self,
*,
library: str,
file_name: str,
content: bytes,
actor_name: str,
source: str = "rule-library",
) -> RuleSpreadsheetMeta:
normalized_library = str(library or "").strip()
if normalized_library not in RULE_LIBRARY_NAMES:
raise ValueError("规则库目录不合法。")
normalized_name = Path(str(file_name or "").strip()).name.strip()
if not normalized_name:
raise ValueError("规则表文件名不能为空。")
if not content:
raise ValueError("规则表文件内容不能为空。")
self.ensure_rule_library_dirs()
relative_path = Path("rules") / normalized_library / normalized_name
target_path = (SERVER_DIR / relative_path).resolve()
try:
target_path.relative_to(self.rule_root)
except ValueError:
raise ValueError("规则库文件路径不合法。")
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
return RuleSpreadsheetMeta(
file_name=normalized_name,
storage_key=relative_path.as_posix(),
mime_type=mime_type,
size_bytes=len(content),
checksum=hashlib.sha256(content).hexdigest(),
updated_at=datetime.now(UTC).isoformat(),
updated_by=str(actor_name or "system").strip() or "system",
source=source,
)
def resolve_storage_path(self, storage_key: str) -> Path:
normalized = Path(str(storage_key or "").strip())
if not normalized.parts:
raise FileNotFoundError("规则表文件不存在。")
if normalized.parts[0] == "rules":
resolved = (SERVER_DIR / normalized).resolve()
allowed_root = self.rule_root
else:
resolved = (self.storage_root / normalized).resolve()
allowed_root = self.storage_root
try:
resolved.relative_to(allowed_root)
except ValueError:
raise FileNotFoundError("规则表文件不存在。")
return resolved
@staticmethod
def parse_version_markdown(markdown: str) -> RuleSpreadsheetMeta | None:
match = RULE_SPREADSHEET_BLOCK_PATTERN.search(str(markdown or ""))
if match is None:
return None
try:
payload = json.loads(match.group(1))
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
return RuleSpreadsheetMeta(
file_name=str(payload.get("file_name") or "").strip(),
storage_key=str(payload.get("storage_key") or "").strip(),
mime_type=str(payload.get("mime_type") or SPREADSHEET_MIME_TYPE).strip()
or SPREADSHEET_MIME_TYPE,
size_bytes=int(payload.get("size_bytes") or 0),
checksum=str(payload.get("checksum") or "").strip(),
updated_at=str(payload.get("updated_at") or "").strip(),
updated_by=str(payload.get("updated_by") or "system").strip() or "system",
source=str(payload.get("source") or "upload").strip() or "upload",
)
@staticmethod
def build_version_markdown(
*,
rule_name: str,
version: str,
metadata: RuleSpreadsheetMeta,
) -> str:
sections = [
f"# {rule_name}",
"",
"## 规则载体",
"",
"- 详情类型Excel 表格",
f"- 当前规则版本:`{version}`",
f"- 表格文件:`{metadata.file_name}`",
f"- 最近更新人:{metadata.updated_by}",
f"- 最近更新时间:{metadata.updated_at}",
"",
"## 使用说明",
"",
"- 管理员可直接在规则中心内联编辑 Excel 表格,并通过 ONLYOFFICE 回写新版本。",
"- 上传新的 Excel 文件后,会自动生成新的规则版本快照。",
"- 切换到历史版本时仅提供预览,不允许直接覆盖历史快照。",
"",
"```rule-spreadsheet",
json.dumps(asdict(metadata), ensure_ascii=False, indent=2),
"```",
]
return "\n".join(sections)
@staticmethod
def build_rule_document_config(
metadata: RuleSpreadsheetMeta,
*,
asset_version: str,
) -> dict[str, object]:
return {
"kind": "spreadsheet",
"file_name": metadata.file_name,
"mime_type": metadata.mime_type,
"size_bytes": metadata.size_bytes,
"checksum": metadata.checksum,
"updated_at": metadata.updated_at,
"updated_by": metadata.updated_by,
"source": metadata.source,
"asset_version": asset_version,
}
@staticmethod
def build_company_travel_rule_template() -> bytes:
standard_rows = [
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
]
instruction_rows = [
["字段", "填写说明"],
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
["备注", "记录豁免条件、灰度口径或制度来源。"],
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
]
return _build_xlsx_bytes(
[
("差旅报销标准", standard_rows),
("填表说明", instruction_rows),
]
)
@staticmethod
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
return _build_xlsx_bytes([(sheet_name, [[""]])])
@staticmethod
def rebuild_from_uploaded_content(content: bytes) -> bytes:
if not content:
raise ValueError("待导入的表格内容不能为空。")
try:
workbook = load_workbook(
filename=BytesIO(content),
read_only=True,
data_only=False,
)
except Exception as exc: # noqa: BLE001
raise ValueError("无法解析上传的 Excel 表格。") from exc
sheets: list[tuple[str, list[list[object]]]] = []
for worksheet in workbook.worksheets:
rows = [
list(row)
for row in worksheet.iter_rows(values_only=True)
]
sheets.append((worksheet.title, _trim_empty_table(rows)))
if not sheets:
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
return _build_xlsx_bytes(sheets)
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
created_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
workbook_buffer = BytesIO()
with ZipFile(workbook_buffer, "w", ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", _build_content_types_xml(sheets))
archive.writestr("_rels/.rels", _build_root_rels_xml())
archive.writestr("docProps/app.xml", _build_app_xml(sheets))
archive.writestr("docProps/core.xml", _build_core_xml(created_at))
archive.writestr("xl/workbook.xml", _build_workbook_xml(sheets))
archive.writestr("xl/_rels/workbook.xml.rels", _build_workbook_rels_xml(sheets))
archive.writestr("xl/styles.xml", _build_styles_xml())
for index, (_, rows) in enumerate(sheets, start=1):
archive.writestr(
f"xl/worksheets/sheet{index}.xml",
_build_sheet_xml(rows),
)
return workbook_buffer.getvalue()
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
overrides = [
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
]
overrides.extend(
[
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
for index, _ in enumerate(sheets, start=1)
]
)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
f'{"".join(overrides)}'
"</Types>"
)
def _build_root_rels_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
"</Relationships>"
)
def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
titles = "".join(
[f'<vt:lpstr>{escape(name)}</vt:lpstr>' for name, _ in sheets]
)
sheet_count = len(sheets)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
'<Application>Microsoft Excel</Application>'
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>"
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
"</Properties>"
)
def _build_core_xml(created_at: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
"<dc:creator>X-Financial</dc:creator>"
"<cp:lastModifiedBy>X-Financial</cp:lastModifiedBy>"
f'<dcterms:created xsi:type="dcterms:W3CDTF">{created_at}</dcterms:created>'
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{created_at}</dcterms:modified>'
"</cp:coreProperties>"
)
def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
sheet_items = "".join(
[
f'<sheet name="{escape(name)}" sheetId="{index}" r:id="rId{index}"/>'
for index, (name, _) in enumerate(sheets, start=1)
]
)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
"<bookViews><workbookView/></bookViews>"
f"<sheets>{sheet_items}</sheets>"
"</workbook>"
)
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
relationships = "".join(
[
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
for index, _ in enumerate(sheets, start=1)
]
)
relationships += (
f'<Relationship Id="rId{len(sheets) + 1}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
'Target="styles.xml"/>'
)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
f"{relationships}"
"</Relationships>"
)
def _build_styles_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
'</styleSheet>'
)
def _build_sheet_xml(rows: list[list[object]]) -> str:
normalized_rows = rows or [[""]]
max_column_count = max((len(row) for row in normalized_rows), default=1)
worksheet_rows: list[str] = []
for row_index, row in enumerate(normalized_rows, start=1):
cells: list[str] = []
for column_index, cell in enumerate(row, start=1):
ref = f"{_column_letter(column_index)}{row_index}"
text = "" if cell is None else str(cell)
preserve = ' xml:space="preserve"' if text.strip() != text or "\n" in text else ""
cells.append(
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
)
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<dimension ref="{dimension}"/>'
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
"<sheetFormatPr defaultRowHeight=\"18\"/>"
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
"</worksheet>"
)
def _column_letter(index: int) -> str:
value = max(1, int(index))
result = ""
while value > 0:
value, remainder = divmod(value - 1, 26)
result = f"{chr(65 + remainder)}{result}"
return result
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
normalized_rows = [list(row) for row in rows]
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):
normalized_rows.pop()
if not normalized_rows:
return [[""]]
max_column = 0
for row in normalized_rows:
for index, cell in enumerate(row, start=1):
if cell not in (None, ""):
max_column = max(max_column, index)
if max_column <= 0:
return [[""]]
return [row[:max_column] for row in normalized_rows]

View File

@@ -1,12 +1,12 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentPermissionLevel, AgentRunStatus
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
from app.core.logging import get_logger
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.repositories.agent_run import AgentRunRepository
@@ -15,6 +15,8 @@ from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.agent_runs")
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
class AgentRunService:
def __init__(self, db: Session) -> None:
@@ -30,11 +32,13 @@ class AgentRunService:
limit: int = 20,
) -> 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]
def get_run(self, run_id: str) -> AgentRunRead | None:
self._ensure_ready()
self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
run = self.repository.get_by_run_id(run_id)
if run is None:
return None
@@ -174,6 +178,35 @@ class AgentRunService:
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
return AgentToolCallRead.model_validate(created)
def update_tool_call(
self,
tool_call_id: str,
*,
request_json: dict[str, Any] | None = None,
response_json: dict[str, Any] | None = None,
status: str | None = None,
duration_ms: int | None = None,
error_message: str | None = None,
) -> AgentToolCallRead:
self._ensure_ready()
tool_call = self.repository.get_tool_call(tool_call_id)
if tool_call is None:
raise LookupError("Tool call not found")
if request_json is not None:
tool_call.request_json = request_json
if response_json is not None:
tool_call.response_json = response_json
if status is not None:
tool_call.status = status
if duration_ms is not None:
tool_call.duration_ms = duration_ms
tool_call.error_message = error_message
updated = self.repository.save_tool_call(tool_call)
logger.info("Updated tool call id=%s status=%s", updated.id, updated.status)
return AgentToolCallRead.model_validate(updated)
def record_semantic_parse(
self,
*,
@@ -214,6 +247,73 @@ class AgentRunService:
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
def _reconcile_stale_knowledge_index_runs(self, *, target_run_id: str | None = None) -> None:
runs = self.repository.list(
agent=AgentName.HERMES.value,
status=AgentRunStatus.RUNNING.value,
limit=200,
)
now = datetime.now(UTC)
for run in runs:
if target_run_id is not None and run.run_id != target_run_id:
continue
route_json = dict(run.route_json or {})
if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
continue
heartbeat_at = self._parse_heartbeat_time(
str(route_json.get("heartbeat_at") or "").strip()
)
last_seen_at = heartbeat_at or run.started_at
if last_seen_at.tzinfo is None:
last_seen_at = last_seen_at.replace(tzinfo=UTC)
if now - last_seen_at <= KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT:
continue
stale_document_ids = [
str(document_id).strip()
for document_id in list(route_json.get("requested_document_ids") or [])
if str(document_id).strip()
]
if stale_document_ids:
from app.services.knowledge import (
KNOWLEDGE_INGEST_STATUS_FAILED,
KnowledgeService,
)
KnowledgeService(db=self.db).set_document_ingest_statuses(
stale_document_ids,
KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=run.run_id,
)
route_json.update(
{
"phase": "stale_failed",
"heartbeat_at": now.isoformat(),
}
)
run.route_json = route_json
run.status = AgentRunStatus.FAILED.value
run.result_summary = "知识归纳任务长时间无心跳,系统已自动标记失败。"
run.error_message = "Knowledge index heartbeat timed out."
run.finished_at = now
self.repository.save_run(run)
logger.warning("Marked stale knowledge index run as failed run_id=%s", run.run_id)
@staticmethod
def _parse_heartbeat_time(raw_value: str) -> datetime | None:
normalized = str(raw_value or "").strip()
if not normalized:
return None
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
@staticmethod
def _serialize_run(run: AgentRun) -> AgentRunRead:
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None

View File

@@ -598,13 +598,13 @@ class ExpenseRuleRuntimeService:
return catalog
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
current_version = str(asset.current_version or "").strip()
if not current_version:
published_version = str(asset.published_version or asset.current_version or "").strip()
if not published_version:
return None
return self.db.scalar(
select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset.id,
AgentAssetVersion.version == current_version,
AgentAssetVersion.version == published_version,
)
)

View File

@@ -14,6 +14,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
Requires-Dist: email-validator<3.0.0,>=2.2.0
Requires-Dist: python-multipart<1.0.0,>=0.0.20
Requires-Dist: openpyxl<4.0.0,>=3.1.5
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
Provides-Extra: dev

View File

@@ -76,6 +76,7 @@ src/app/schemas/settings.py
src/app/schemas/system_log.py
src/app/schemas/user_agent.py
src/app/services/__init__.py
src/app/services/agent_asset_spreadsheet.py
src/app/services/agent_assets.py
src/app/services/agent_conversations.py
src/app/services/agent_foundation.py
@@ -90,7 +91,10 @@ src/app/services/expense_rule_runtime.py
src/app/services/hermes_sync.py
src/app/services/knowledge.py
src/app/services/knowledge_index_tasks.py
src/app/services/knowledge_normalizer.py
src/app/services/knowledge_rag.py
src/app/services/knowledge_scheduler.py
src/app/services/knowledge_sync.py
src/app/services/model_connectivity.py
src/app/services/ocr.py
src/app/services/ontology.py
@@ -106,8 +110,11 @@ src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
tests/test_agent_asset_onlyoffice_key.py
tests/test_agent_asset_service.py
tests/test_agent_asset_spreadsheet_import.py
tests/test_agent_foundation_endpoints.py
tests/test_agent_runs_service.py
tests/test_auth_service.py
tests/test_config_settings_reload.py
tests/test_document_intelligence.py
@@ -115,12 +122,16 @@ tests/test_employee_service.py
tests/test_env_file_precedence.py
tests/test_expense_claim_service.py
tests/test_imports.py
tests/test_knowledge_normalizer.py
tests/test_knowledge_onlyoffice_config.py
tests/test_knowledge_rag_service.py
tests/test_knowledge_service.py
tests/test_ocr_endpoints.py
tests/test_ocr_service.py
tests/test_ontology_service.py
tests/test_openapi_schema.py
tests/test_reimbursement_endpoints.py
tests/test_runtime_chat_service.py
tests/test_server_start_dependencies.py
tests/test_settings_persistence.py
tests/test_settings_service.py

View File

@@ -8,6 +8,7 @@ pydantic-settings<3.0.0,>=2.6.0
python-dotenv<2.0.0,>=1.0.1
email-validator<3.0.0,>=2.2.0
python-multipart<1.0.0,>=0.0.20
openpyxl<4.0.0,>=3.1.5
lightrag-hku<1.5.0,>=1.4.16
qdrant-client<2.0.0,>=1.18.0