diff --git a/document/development/agent/agent plan/00_README.md b/document/development/agent/agent plan/00_README.md deleted file mode 100644 index 6e061c6..0000000 --- a/document/development/agent/agent plan/00_README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Agent Plan 文档索引 - -本目录描述 X-Financial 后续要建设的双 Agent 财务智能架构。 - -核心目标: - -- 建立一套共享的语义本体协议,统一理解用户问题、定时任务和规则触发上下文。 -- 建设两套职责边界清晰的 Agent: - - Hermes:后台数字员工,负责内循环定时任务、风险巡检、统计、知识维护。 - - 自建 Agent:用户流程助手,负责用户交互、流程操作、解释、查询、草稿生成。 -- 建设 Agent Orchestrator,统一负责路由、权限、工具调用、审计和失败处理。 -- 让规则中心、MCP、知识库、数据库查询和任务系统使用同一套语义协议。 - -## 与一周计划的关系 - -`document/development/agent week plan` 是一周开发路线图,只描述每天要完成的大方向和交付结果。 - -本目录是具体架构与实现依据,包含: - -- 架构设计。 -- 数据协议。 -- Agent 职责。 -- Orchestrator 流程。 -- OCR、知识库、规则生命周期。 -- 每天 daily 文档会引用到的设计依据。 - -执行时按这个顺序阅读: - -1. 先看 `document/development/agent week plan/MASTER_TODO.md`,确认今天做什么。 -2. 再看本目录的架构文档,理解为什么这样做。 -3. 最后进入 `document/development/agent week plan/` 对应 Day 文档,在同一份文档中按详细执行清单开发。 - -推荐阅读顺序: - -1. [01_overall_architecture.md](./01_overall_architecture.md) -2. [02_semantic_ontology.md](./02_semantic_ontology.md) -3. [03_agent_responsibilities.md](./03_agent_responsibilities.md) -4. [04_orchestrator_and_runtime_flow.md](./04_orchestrator_and_runtime_flow.md) -5. [05_development_roadmap.md](./05_development_roadmap.md) -6. [06_data_contracts_and_governance.md](./06_data_contracts_and_governance.md) -7. [07_capability_registry.md](./07_capability_registry.md) -8. [08_permission_confirmation.md](./08_permission_confirmation.md) -9. [09_observability_and_trace.md](./09_observability_and_trace.md) -10. [10_evaluation_and_testset.md](./10_evaluation_and_testset.md) -11. [11_ocr_invoice_architecture.md](./11_ocr_invoice_architecture.md) -12. [12_llm_wiki_knowledge_architecture.md](./12_llm_wiki_knowledge_architecture.md) -13. [13_rule_formation_lifecycle.md](./13_rule_formation_lifecycle.md) -14. [14_financial_document_canonical_model.md](./14_financial_document_canonical_model.md) -15. [15_feedback_learning_loop.md](./15_feedback_learning_loop.md) -16. [../agent week plan/00_README.md](<../agent week plan/00_README.md>) - -开发原则: - -- 先语义协议,后 Agent 能力。 -- 先只读和建议,后写入和流程动作。 -- 先人工确认,后有限自动化。 -- 所有财务关键动作必须可审计、可回滚、可追责。 -- 所有 Agent 能力必须注册、分级、可评测、可追踪。 diff --git a/document/development/agent/agent plan/01_overall_architecture.md b/document/development/agent/agent plan/01_overall_architecture.md deleted file mode 100644 index 6d737f0..0000000 --- a/document/development/agent/agent plan/01_overall_architecture.md +++ /dev/null @@ -1,163 +0,0 @@ -# 双 Agent 总体架构 - -## 1. 背景 - -X-Financial 后续需要同时支持两类智能化能力: - -1. 用户主动发起的交互式流程操作。 -2. 系统后台自动运行的定时巡检、统计、预警和知识维护。 - -如果用一个万能 Agent 同时处理这两类任务,风险会很高: - -- 用户流程操作需要权限、确认、上下文追问。 -- 定时巡检需要稳定批处理、失败重试、审计记录。 -- 财务系统不能让大模型直接决定审批、付款、规则上线。 - -因此建议建设双 Agent 架构: - -```text -Hermes Agent - 后台数字员工 - 面向系统内循环 - 定时、批量、巡检、统计、预警、知识候选 - -User Agent - 自建用户流程助手 - 面向用户交互 - 查询、解释、创建草稿、流程操作、审批辅助 -``` - -两套 Agent 共享一套语义本体协议,由 Agent Orchestrator 统一调度。 - -## 2. 总体架构图 - -```text - ┌──────────────────────┐ - │ 用户自然语言 / 定时任务 │ - └───────────┬──────────┘ - │ - ▼ - ┌──────────────────────┐ - │ Semantic Ontology │ - │ 语义本体解析层 │ - └───────────┬──────────┘ - │ - ▼ - ┌──────────────────────┐ - │ Agent Orchestrator │ - │ 路由 / 权限 / 审计 / 调度 │ - └───────┬─────────┬────┘ - │ │ - ┌─────────────▼─┐ ┌─▼──────────────┐ - │ Hermes Agent │ │ User Agent │ - │ 后台数字员工 │ │ 用户流程助手 │ - └───────┬───────┘ └───────┬────────┘ - │ │ - └──────────┬──────────┘ - │ - ┌────────────┬───────────┼───────────┬────────────┐ - ▼ ▼ ▼ ▼ ▼ - 规则中心 MCP 服务 业务数据库 知识库 任务系统 -``` - -## 3. 核心分层 - -### 3.1 语义本体层 - -负责把自然语言或任务配置转成结构化 JSON。 - -输出不是最终答案,而是统一协议: - -```json -{ - "domain": "reimbursement", - "scenario": "invoice_validation", - "intent": "explain_risk", - "entities": [], - "time_range": {}, - "constraints": {}, - "risk_signals": [], - "next_step": "run_rule" -} -``` - -### 3.2 编排层 - -Agent Orchestrator 负责: - -- 判断应该由 Hermes 还是 User Agent 处理。 -- 判断是否需要查数据库、跑规则、调 MCP、检索知识库。 -- 检查用户权限。 -- 记录审计日志。 -- 控制失败重试。 -- 对高风险动作要求用户或管理员确认。 - -### 3.3 Agent 层 - -Hermes 和 User Agent 不直接决定财务关键状态。 - -它们负责: - -- 理解任务。 -- 组织工具调用。 -- 汇总工具结果。 -- 生成建议、解释、报告、草稿。 - -### 3.4 能力层 - -能力层包括: - -- 规则中心:管理 `.md` 规则文件、审核、版本。 -- MCP:封装外部服务,如发票验真、银行流水、OCR、差旅平台。 -- 数据库查询:查询报销、报账、应收、应付、账款数据。 -- 知识库:制度文档、FAQ、历史解释、规则说明。 -- 任务系统:定时任务、批量任务、重试、运行日志。 - -## 4. 关键边界 - -Hermes 可以: - -- 定时读取数据。 -- 执行规则检查。 -- 调 MCP 查询外部状态。 -- 生成风险报告。 -- 生成知识候选。 -- 生成待处理工单。 - -Hermes 不可以: - -- 自动提交报销。 -- 自动发起付款。 -- 自动审批通过。 -- 自动发布知识库正式内容。 -- 自动上线规则。 - -User Agent 可以: - -- 帮用户查询状态。 -- 帮用户解释风险。 -- 帮用户创建报销或付款草稿。 -- 帮审批人生成审批意见。 -- 在用户确认后调用流程 API。 - -User Agent 不可以: - -- 绕过权限。 -- 未确认直接提交关键动作。 -- 自动最终审批。 -- 自动付款。 -- 修改规则审核状态。 - -## 5. 推荐建设顺序 - -```text -Step 1: 建立语义本体 JSON 协议 -Step 2: 建立规则中心的规则/技能/MCP/任务目录 -Step 3: 建立 Orchestrator 路由和审计 -Step 4: 建立 User Agent 的只读查询和解释能力 -Step 5: 建立 Hermes 的定时任务和报告能力 -Step 6: 接入 MCP 和业务数据库 -Step 7: 增加用户确认后的流程写入能力 -Step 8: 增加知识候选和规则优化闭环 -``` - diff --git a/document/development/agent/agent plan/02_semantic_ontology.md b/document/development/agent/agent plan/02_semantic_ontology.md deleted file mode 100644 index ee6ecf0..0000000 --- a/document/development/agent/agent plan/02_semantic_ontology.md +++ /dev/null @@ -1,457 +0,0 @@ -# 语义本体协议设计 - -## 1. 定位 - -语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。 - -它解决的问题是: - -- 用户到底在问哪个业务域? -- 这属于什么场景? -- 用户想做什么? -- 问题中涉及哪些对象? -- 有没有时间、金额、状态、部门等过滤条件? -- 是否涉及风险? -- 下一步应该查知识库、查数据库、跑规则、调 MCP,还是追问? - -## 2. 第一版核心字段 - -第一版建议只强制落 8 个字段。 - -```json -{ - "domain": "", - "scenario": "", - "intent": "", - "entities": [], - "time_range": {}, - "constraints": {}, - "risk_signals": [], - "next_step": "" -} -``` - -### 2.1 domain - -一级业务域。 - -建议枚举: - -```text -reimbursement -accounts_receivable -accounts_payable -general_finance -system_operation -``` - -含义: - -- `reimbursement`:报销、差旅、发票、补件。 -- `accounts_receivable`:应收账款、客户开票、收款、账龄。 -- `accounts_payable`:应付账款、供应商发票、付款、对账。 -- `general_finance`:通用财务知识、制度、统计。 -- `system_operation`:系统巡检、任务运行、规则维护、MCP 健康检查。 - -### 2.2 scenario - -细分场景。 - -报销: - -```text -travel_reimbursement -daily_expense -invoice_validation -attachment_review -policy_overrun -reimbursement_audit -``` - -应收: - -```text -customer_invoice -collection_followup -receivable_aging -payment_matching -bad_debt_risk -contract_receivable -``` - -应付: - -```text -vendor_invoice -payment_request -payable_aging -vendor_reconciliation -invoice_matching -cash_outflow_forecast -``` - -系统运营: - -```text -daily_risk_scan -daily_finance_statistics -knowledge_accumulation -mcp_health_check -rule_quality_review -``` - -### 2.3 intent - -用户或任务的意图。 - -建议枚举: - -```text -query -explain -create -validate -summarize -reconcile -monitor -predict -remind -generate -optimize -``` - -### 2.4 entities - -识别出的业务对象。 - -统一结构: - -```json -{ - "type": "invoice", - "value": "INV-202605001", - "normalized_value": "INV-202605001", - "role": "target", - "confidence": 0.92 -} -``` - -常见实体: - -```text -employee -department -customer -vendor -invoice -contract -reimbursement_request -payment_order -receipt -bank_transaction -cost_center -project -policy -approval_node -rule -task -``` - -### 2.5 time_range - -统一描述时间。 - -```json -{ - "raw": "上个月", - "start": "2026-04-01", - "end": "2026-04-30", - "granularity": "month" -} -``` - -Hermes 定时任务也使用同一字段。 - -例如每日风险巡检: - -```json -{ - "raw": "昨日", - "start": "2026-05-09", - "end": "2026-05-09", - "granularity": "day" -} -``` - -### 2.6 constraints - -查询、判断或执行条件。 - -```json -{ - "status": "overdue", - "aging_days": ">30", - "amount": { - "operator": ">", - "value": 50000, - "currency": "CNY" - }, - "department": "销售部", - "risk_level": ["medium", "high"] -} -``` - -### 2.7 risk_signals - -风险信号。 - -建议枚举: - -```text -duplicate_invoice -missing_attachment -policy_overrun -over_budget -overdue_receivable -bad_debt_risk -vendor_payment_risk -payment_mismatch -contract_mismatch -cashflow_pressure -mcp_unavailable -rule_quality_issue -``` - -### 2.8 next_step - -下一步动作。 - -建议枚举: - -```text -answer -ask_clarification -query_database -run_rule -call_mcp -search_knowledge -create_draft -create_task -generate_report -notify_user -escalate_to_human -``` - -## 3. 扩展字段 - -后续可以增加: - -```json -{ - "schema_version": "1.1", - "confidence": 0.86, - "ambiguity": [], - "missing_slots": [], - "required_capabilities": [], - "normalized_query": "", - "permission_scope": {}, - "audit_tags": [] -} -``` - -## 4. 混合语义解析架构 - -第一版可上线实现不应只依赖关键词和正则。 - -推荐采用: - -```text -输入上下文装配 - 用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要 - ↓ -预抽取 - 时间、金额、单号、显式对象 - ↓ -LLM 结构化解析 - 输出 scenario / intent / entities / missing_slots / ambiguity - ↓ -Schema 校验 - JSON 解析、字段枚举、必填校验、类型归一化 - ↓ -规则兜底 - 模型失败、低置信度或字段缺失时回退到规则解析 - ↓ -澄清追问 - 低置信度、歧义、缺槽位时不允许直接查库 -``` - -设计原则: - -- 模型优先负责“理解意图和场景”。 -- 规则优先负责“校验、补全和兜底”。 -- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。 -- 所有语义输出都必须标记置信度和来源。 - -## 5. 推荐新增字段 - -为支持模型优先解析,建议在扩展字段中至少增加: - -```json -{ - "missing_slots": [], - "ambiguity": [], - "field_confidence": {}, - "field_source": {}, - "attachment_context": [], - "parse_strategy": "llm_primary_with_rule_fallback" -} -``` - -字段说明: - -- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。 -- `ambiguity`:当前可能混淆的理解结果。 -- `field_confidence`:字段级置信度,而不是只给整体分数。 -- `field_source`:字段来自 `llm`、`rule`、`ocr`、`vlm` 还是 `user_context`。 -- `attachment_context`:本次可供语义解析使用的附件摘要。 -- `parse_strategy`:标记本次是模型主解析还是规则回退。 - -## 6. 叙述型财务输入 - -语义层必须支持“不是查询句”的自然叙述。 - -典型样例: - -```text -我今天去客户现场,招待了客户,花销了1000元 -我垫付了打车费和餐费,帮我看看怎么报 -上传了三张票,帮我整理成报销草稿 -``` - -这类输入不能默认识别成 `query`。 - -建议默认策略: - -- 优先识别为 `reimbursement` 域。 -- 场景优先落到 `daily_expense`、`travel_reimbursement` 或 `attachment_review`。 -- 意图优先落到 `create`、`generate` 或 `validate`。 -- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。 - -## 7. 模糊短句与澄清规则 - -以下输入应优先追问: - -```text -我要报销 -这个为什么还没处理 -帮我看一下这个 -上传好了,下一步呢 -``` - -处理原则: - -- 不允许直接执行工具。 -- 不允许直接落到应收、应付查询。 -- 必须生成澄清问题。 -- 必须在审计中记录触发追问的原因。 - -扩展原则: - -- 先不要把所有字段都做成数据库列。 -- 语义结果建议存 JSONB。 -- 使用 `schema_version` 管理版本。 -- Orchestrator 只依赖稳定字段。 -- 新字段以可选方式加入,不影响老任务。 - -## 4. 示例 - -### 4.1 用户查询应收账龄 - -用户问: - -```text -上个月哪些客户应收逾期超过 30 天? -``` - -解析: - -```json -{ - "domain": "accounts_receivable", - "scenario": "receivable_aging", - "intent": "query", - "entities": [ - { - "type": "customer", - "value": "客户", - "role": "group_by" - } - ], - "time_range": { - "raw": "上个月", - "start": "2026-04-01", - "end": "2026-04-30", - "granularity": "month" - }, - "constraints": { - "aging_days": ">30", - "status": "overdue" - }, - "risk_signals": ["overdue_receivable"], - "next_step": "query_database" -} -``` - -### 4.2 用户解释发票拦截 - -用户问: - -```text -这张发票为什么报销被拦截? -``` - -解析: - -```json -{ - "domain": "reimbursement", - "scenario": "invoice_validation", - "intent": "explain", - "entities": [ - { - "type": "invoice", - "value": "这张发票", - "role": "target" - } - ], - "time_range": {}, - "constraints": {}, - "risk_signals": ["unknown"], - "next_step": "run_rule" -} -``` - -### 4.3 Hermes 每日风险巡检 - -任务配置: - -```json -{ - "domain": "reimbursement", - "scenario": "daily_risk_scan", - "intent": "monitor", - "entities": [], - "time_range": { - "raw": "昨日" - }, - "constraints": { - "risk_level": ["medium", "high"] - }, - "risk_signals": [ - "duplicate_invoice", - "missing_attachment", - "policy_overrun" - ], - "next_step": "run_rule" -} -``` diff --git a/document/development/agent/agent plan/03_agent_responsibilities.md b/document/development/agent/agent plan/03_agent_responsibilities.md deleted file mode 100644 index 6d5382f..0000000 --- a/document/development/agent/agent plan/03_agent_responsibilities.md +++ /dev/null @@ -1,178 +0,0 @@ -# Hermes 与自建 Agent 职责边界 - -## 1. 两套 Agent 的定位 - -### 1.1 Hermes - -Hermes 定位为后台数字员工。 - -它不直接面向用户聊天,而是在系统后台做内循环工作。 - -关键词: - -```text -定时 -批量 -巡检 -统计 -预警 -知识维护 -规则质量复盘 -``` - -### 1.2 自建 Agent - -自建 Agent 定位为用户流程助手。 - -它直接面对员工、财务人员、审批人和管理员。 - -关键词: - -```text -用户触发 -会话式 -流程操作 -查询解释 -草稿生成 -审批辅助 -用户确认 -``` - -## 2. Hermes 职责 - -Hermes 负责: - -1. 每日风险巡检。 -2. 每日报销、报账、账款统计。 -3. 应收逾期预警。 -4. 应付付款风险预警。 -5. 规则命中质量复盘。 -6. MCP 健康检查。 -7. 知识库候选内容生成。 -8. 高风险工单生成。 -9. 任务运行报告生成。 - -Hermes 输出的内容包括: - -```text -risk_report -risk_work_items -daily_finance_snapshot -knowledge_candidates -rule_improvement_items -mcp_health_report -task_run_log -``` - -Hermes 不允许: - -1. 自动审批通过。 -2. 自动发起付款。 -3. 自动提交用户申请。 -4. 自动发布正式知识库。 -5. 自动上线规则。 -6. 直接修改核心财务状态。 - -## 3. 自建 Agent 职责 - -自建 Agent 负责: - -1. 查询报销单进度。 -2. 创建报销或付款草稿。 -3. 解释规则拦截原因。 -4. 生成审批意见。 -5. 检索制度知识。 -6. 查询应收应付数据。 -7. 帮用户对账。 -8. 引导用户补充缺失信息。 -9. 在用户确认后调用流程 API。 - -自建 Agent 输出的内容包括: - -```text -natural_language_answer -form_draft -approval_opinion_draft -clarification_question -query_result_summary -next_action_suggestion -``` - -自建 Agent 不允许: - -1. 未经用户确认提交关键动作。 -2. 跳过权限校验。 -3. 自动最终审批。 -4. 自动付款。 -5. 修改规则上线状态。 - -## 4. 权限边界 - -| 动作 | Hermes | 自建 Agent | -|---|---|---| -| 查询制度知识 | 可以 | 可以 | -| 查询业务数据 | 可以,按任务权限 | 可以,按用户权限 | -| 跑规则 | 可以 | 可以 | -| 调 MCP | 可以 | 可以 | -| 生成报告 | 可以 | 可以 | -| 生成草稿 | 不建议 | 可以 | -| 提交流程 | 不可以 | 用户确认后可以 | -| 审批通过 | 不可以 | 不可以直接做 | -| 发起付款 | 不可以 | 高权限确认后才可做草稿 | -| 发布知识 | 不可以 | 不可以 | -| 上线规则 | 不可以 | 不可以 | - -## 5. 共享能力 - -两套 Agent 共享: - -- 语义本体协议。 -- 规则中心。 -- MCP 服务。 -- 知识库。 -- 数据库查询服务。 -- 审计日志。 -- 权限系统。 - -不共享: - -- 运行队列。 -- 调度策略。 -- 用户会话状态。 -- 任务重试状态。 - -## 6. 示例 - -### 6.1 Hermes 场景 - -每日 02:00 自动运行: - -```text -每日风险巡检 - 读取昨日报销、报账、发票、账款数据 - 执行规则 - 调用发票验真 MCP - 调用账款流水 MCP - 生成风险报告 - 生成风险工单 -``` - -### 6.2 自建 Agent 场景 - -用户问: - -```text -帮我看一下这张差旅报销为什么没通过。 -``` - -处理: - -```text -解析语义 -查询报销单 -读取规则命中 -检索制度条款 -组织解释 -给出补件建议 -``` - diff --git a/document/development/agent/agent plan/04_orchestrator_and_runtime_flow.md b/document/development/agent/agent plan/04_orchestrator_and_runtime_flow.md deleted file mode 100644 index 693a583..0000000 --- a/document/development/agent/agent plan/04_orchestrator_and_runtime_flow.md +++ /dev/null @@ -1,385 +0,0 @@ -# Agent Orchestrator 与运行流程 - -## 1. Orchestrator 定位 - -Agent Orchestrator 是双 Agent 架构的调度中心。 - -它不负责生成最终答案,而是负责: - -- 接收用户请求或定时任务。 -- 调用语义解析。 -- 判断处理方。 -- 选择工具。 -- 检查权限。 -- 记录审计。 -- 管理失败重试。 -- 控制高风险动作确认。 - -## 2. 运行主流程 - -```text -输入 - 用户消息 / 页面按钮 / 定时任务 / 系统事件 - ↓ -上下文装配 - 页面对象 / 附件名称 / OCR 摘要 / VLM 摘要 / 用户角色 / conversation_id / draft_claim_id - ↓ -语义解析 - LLM 主解析 + 规则兜底,输出 ontology_json - ↓ -语义校验 - confidence / missing_slots / ambiguity / permission 初判 - ↓ -Orchestrator 决策 - 判断 agent = hermes | user_agent - 判断 tool = rule | mcp | db | knowledge | task - ↓ -权限检查 - 用户权限 / 任务权限 / 数据范围 - ↓ -业务写入 - 报销草稿创建 / 报销草稿更新 / 用户确认后提交 - ↓ -工具执行 - 规则中心 / MCP / 数据库 / 知识库 / 任务系统 - ↓ -Agent 汇总 - Hermes 报告 / User Agent 回答 - ↓ -审计记录 - 保存输入、语义、工具、结果、动作 -``` - -## 3. 路由规则 - -### 3.1 Hermes 路由 - -满足以下条件之一,进入 Hermes: - -```text -source = schedule -source = system_event -intent = monitor -intent = summarize and no active user session -next_step = generate_report and task_type is batch -scenario in daily_risk_scan / knowledge_accumulation / mcp_health_check -``` - -补充约束: - -- 这里的 Hermes 指系统后台真实 Hermes 进程或 Hermes CLI,不是前端概念上的 “Hermes 模式”。 -- Orchestrator 负责路由、权限、审计和 Trace,不负责替代 Hermes 自身执行。 -- 当前阶段允许保留本地 fallback,但必须预留真实 Hermes 进程调用入口。 - -### 3.2 User Agent 路由 - -满足以下条件之一,进入自建 Agent: - -```text -source = user_message -source = page_action -intent = query / explain / create / validate / reconcile -requires_user_context = true -next_step = ask_clarification -next_step = create_draft -``` - -### 3.3 工具路由 - -```text -next_step = query_database - 调用数据库查询服务 - -next_step = run_rule - 调用规则中心 - -next_step = call_mcp - 调用 MCP 服务 - -next_step = search_knowledge - 调用知识库检索 - -next_step = create_task - 调用任务系统 - -next_step = create_expense_claim_draft - 创建 expense_claims / expense_claim_items 草稿 - -next_step = update_expense_claim_draft - 回写报销主表、明细和附件关联 - -next_step = submit_expense_claim - 用户确认后更新 expense_claims.status = submitted - -next_step = ask_clarification - 返回追问 -``` - -### 3.4 低置信度与缺槽位保护 - -当满足以下任一条件时,不允许直接进入数据库、MCP 或高风险流程: - -```text -confidence < threshold -missing_slots 非空 -ambiguity 非空 -输入为叙述型报销,但缺少关键报销信息 -``` - -处理方式: - -```text -next_step = ask_clarification -selected_agent = user_agent -tool_count = 0 -``` - -### 3.5 叙述型报销输入保护 - -像下面这类文本: - -```text -我今天去客户现场,招待了客户,花销了1000元 -我垫付了交通费和午餐费 -我上传了票据,帮我整理一下 -``` - -不能因为出现“客户”就落到应收查询。 - -Orchestrator 应依赖语义层返回的 `scenario + intent + missing_slots` 做决策,而不是二次猜测文本关键词。 - -### 3.6 报销建单与状态流转边界 - -当 `scenario = expense` 且已满足最小建单槽位时: - -```text -next_step = create_expense_claim_draft -status = draft -``` - -当用户继续补充金额、地点、客户、参与人、附件时: - -```text -next_step = update_expense_claim_draft -status 保持 draft -``` - -当用户明确说“提交报销”并完成确认时: - -```text -next_step = submit_expense_claim -status = submitted -requires_confirmation = true -``` - -以下状态不应由 User Agent 直接改写: - -```text -approved -rejected -paid -``` - -这些状态应由审批流、财务支付流或受控后台同步更新。 - -### 3.7 结构化核对回路 - -当 `scenario = expense` 且当前仍存在缺槽位、附件待核对或票据需拆单时,不直接返回一段自由文本,而是返回结构化核对结果: - -```text -result.review_payload - intent_summary - body_message - slot_cards - risk_briefs - document_cards - claim_groups - confirmation_actions = 取消 / 修改 / 保存草稿或下一步 - edit_fields -``` - -前端正文区只展示简洁提示,右侧展示字段、风险、票据与分单明细。 - -### 3.8 会话续接与重识别 - -用户对话不是无状态调用。Orchestrator 需要携带以下会话字段继续当前报销流程: - -```text -conversation_id -draft_claim_id -conversation_history -review_action -review_form_values -``` - -其中: - -```text -review_action = edit_review - 表示用户基于结构化模板修改识别结果,需要重新进入语义识别 - -review_action = save_draft - 表示信息未补齐,但允许先保存报销草稿 - -review_action = next_step - 表示用户确认当前识别结果,可进入下一步流转 -``` - -## 4. 用户流程示例 - -用户输入: - -```text -上个月哪些客户应收逾期超过 30 天? -``` - -流程: - -```text -Step 1: User Agent 接收消息 -Step 2: semantic_parser 输出 ontology_json -Step 3: Orchestrator 识别 domain = accounts_receivable -Step 4: next_step = query_database -Step 5: 权限检查用户是否可看应收数据 -Step 6: 查询应收账龄表 -Step 7: User Agent 汇总结果 -Step 8: 返回客户清单、金额、逾期天数、风险说明 -``` - -## 5. Hermes 任务示例 - -任务: - -```text -每日风险巡检 -``` - -流程: - -```text -Step 1: 任务调度器在 02:00 触发 -Step 2: Orchestrator 构造 ontology_json -Step 3: 路由给 Hermes -Step 4: Hermes 拉取昨日业务快照 -Step 5: 执行规则中心规则 -Step 6: 调用 MCP 验真、账款流水 -Step 7: 生成风险报告 -Step 8: 写入风险工单 -Step 9: 记录任务日志 -Step 10: 通知财务风控组 -``` - -### 5.1 Hermes 后台执行方式 - -推荐最小形态: - -```text -任务系统 / 手动触发 API - ↓ -Orchestrator 生成 run_id、任务上下文、权限信息 - ↓ -后端调用系统 Hermes CLI 或 Hermes 后台进程 - ↓ -Hermes 执行知识同步 / 风险巡检 / 规则草稿形成 - ↓ -结果回写 AgentRun / ToolCall / 审计日志 -``` - -约束: - -- Hermes 运行使用系统级配置,不在任务代码里再写一套模型配置。 -- Hermes 运行失败要记录 stderr 或等价错误摘要。 -- Hermes 输出的知识候选和规则草稿必须回写为结构化结果,不只保留终端文本。 - -## 5A. 用户报销建单示例 - -用户输入: - -```text -我今天去客户现场,招待了客户,花销了1000元 -``` - -流程: - -```text -Step 1: User Agent 接收消息 -Step 2: semantic_parser 输出 ontology_json -Step 3: Orchestrator 判断 scenario = expense, intent = draft -Step 4: 若缺客户、参与人、附件,则 next_step = ask_clarification -Step 5: 补齐最小槽位后,next_step = create_expense_claim_draft -Step 6: 创建 expense_claims / expense_claim_items -Step 7: 若有附件,则挂接 document_assets / expense_item_documents -Step 8: 用户确认提交后,next_step = submit_expense_claim -Step 9: 更新 expense_claims.status = submitted -Step 10: 写入 AgentRun、ToolCall、AuditLog -``` - -## 6. 审计日志 - -每次 Agent 运行都应该写入审计。 - -建议字段: - -```json -{ - "id": "", - "source": "user_message | schedule | system_event", - "agent": "hermes | user_agent", - "user_id": "", - "task_id": "", - "ontology_json": {}, - "tools_called": [], - "permission_scope": {}, - "result_summary": "", - "action_taken": "", - "requires_confirmation": false, - "created_at": "" -} -``` - -建议补充 Trace 字段: - -```json -{ - "semantic_provider": "", - "semantic_model": "", - "semantic_prompt_version": "", - "semantic_parse_strategy": "llm_primary | rule_fallback", - "semantic_fallback_reason": "", - "semantic_latency_ms": 0 -} -``` - -## 7. 失败处理 - -### 7.1 用户交互失败 - -```text -数据库查询失败 - 返回“暂时无法查询”,记录错误 - -缺少关键字段 - 返回追问 - -权限不足 - 返回无权限说明 - -MCP 不可用 - 返回降级说明,必要时生成待处理项 -``` - -### 7.2 Hermes 任务失败 - -```text -任务失败 - 自动重试 3 次 - -部分 MCP 失败 - 标记 partial_success - -数据不完整 - 生成异常任务日志 - -连续失败 - 通知管理员 -``` diff --git a/document/development/agent/agent plan/05_development_roadmap.md b/document/development/agent/agent plan/05_development_roadmap.md deleted file mode 100644 index 3727b47..0000000 --- a/document/development/agent/agent plan/05_development_roadmap.md +++ /dev/null @@ -1,458 +0,0 @@ -# 分阶段开发计划 - -## Phase 0:准备阶段 - -目标:统一概念和边界,不写复杂功能。 - -### Step 0.1 明确术语 - -产出: - -- 规则:`.md` 审查规则文件。 -- 技能:可复用的 Agent 能力,如审批意见生成、风险解释。 -- MCP:外部服务连接。 -- 任务:定时或批量运行的后台作业。 -- Hermes:后台数字员工。 -- User Agent:用户流程助手。 -- Orchestrator:调度和路由层。 -- Ontology:语义本体协议。 - -### Step 0.2 冻结第一版语义字段 - -第一版只强制 8 个字段: - -```text -domain -scenario -intent -entities -time_range -constraints -risk_signals -next_step -``` - -### Step 0.3 建立设计文档 - -产出: - -- 本目录所有文档。 -- 后续数据库表设计草案。 -- API 合同草案。 - -## Phase 1:任务规则中心基础建设 - -目标:先把管理后台搭起来。 - -### Step 1.1 完成前端信息架构 - -页签: - -```text -规则 / 技能 / MCP / 任务 -``` - -规则详情: - -- Markdown 编辑器。 -- 审核人。 -- 审核状态。 -- 版本列表。 -- 版本切换确认。 - -技能详情: - -- 技能配置。 -- 输入上下文。 -- 输出契约。 -- 测试样例。 -- 依赖能力。 - -MCP 详情: - -- 服务地址。 -- 鉴权方式。 -- 权限范围。 -- 健康检查。 -- 调用记录。 - -任务详情: - -- Cron。 -- 运行窗口。 -- 输入范围。 -- 产出对象。 -- 最近运行。 - -### Step 1.2 建立后端基础模型 - -建议表: - -```text -agent_rules -agent_skills -agent_mcp_services -agent_tasks -agent_asset_versions -agent_asset_reviews -``` - -第一阶段可以先不做完整执行,只做 CRUD。 - -### Step 1.3 规则版本与审核 - -规则上线流程: - -```text -草稿 - ↓ -提交审核 - ↓ -审核通过 - ↓ -上线 -``` - -关键约束: - -- 没有审核人不能上线。 -- 没有审核通过不能上线。 -- 上线必须生成新版本。 -- 历史版本只读。 - -## Phase 2:OCR 与财务单据标准模型 - -目标:让发票、附件、报销单和账款流水先标准化。 - -### Step 2.1 附件上传与文件分类 - -识别: - -- 发票。 -- 行程单。 -- 合同。 -- 付款凭证。 -- 审批截图。 - -### Step 2.2 OCR MCP 接入 - -把附件转成结构化字段。 - -### Step 2.3 Invoice 标准模型 - -统一 OCR、MCP、用户填写和业务系统字段。 - -### Step 2.4 人工修正 - -允许财务人员修正 OCR 字段,并写入反馈池。 - -### Step 2.5 规则中心接入 OCR 结果 - -重复发票、附件完整性、金额不一致等规则开始使用标准模型。 - -## Phase 3:语义本体服务 - -目标:用户问题和任务配置都能转成 ontology_json。 - -### Step 3.1 建立 semantic_parser API - -接口: - -```text -POST /api/v1/semantic/parse -``` - -输入: - -```json -{ - "source": "user_message", - "text": "上个月哪些客户应收逾期超过 30 天?", - "context": {} -} -``` - -输出: - -```json -{ - "domain": "accounts_receivable", - "scenario": "receivable_aging", - "intent": "query", - "entities": [], - "time_range": {}, - "constraints": {}, - "risk_signals": [], - "next_step": "query_database" -} -``` - -### Step 3.2 建立模型优先解析器 - -要求: - -- 使用运行时模型配置,而不是写死单一 provider。 -- 输入包括文本、上下文、附件摘要和预抽取字段。 -- 输出必须是结构化 JSON,而不是自由文本。 -- 输出必须经过 Schema 校验。 -- 模型失败时必须回退到规则解析。 - -### Step 3.3 建立 ontology schema 表 - -建议表: - -```text -semantic_ontology_schemas -semantic_parse_logs -``` - -字段: - -```text -id -schema_version -schema_json -status -created_at -updated_at -``` - -### Step 3.4 建立字段级校验与澄清策略 - -至少支持: - -- 缺少费用类型时追问。 -- 缺少业务对象时追问。 -- 短句或模糊句时追问。 -- 叙述型报销输入默认走 create/generate,而不是 query。 -- 低置信度时禁止工具执行。 - -### Step 3.5 建立解析测试集 - -至少覆盖: - -- 报销规则解释。 -- 差旅报销创建。 -- 叙述型报销创建。 -- 发票验真。 -- 应收逾期查询。 -- 应付付款状态。 -- 每日风险巡检。 -- 知识库维护。 -- 模糊短句追问。 -- 附件输入解析。 - -## Phase 4:LLM Wiki 知识库 - -目标:让制度文档、FAQ、审批经验可被 Agent 检索和引用。 - -### Step 4.1 文档解析与分块 - -上传 PDF、Word、Excel 后抽取正文并 chunk。 - -### Step 4.2 元数据与向量索引 - -为知识块打 domain、scenario、tags、版本。 - -### Step 4.3 知识检索 API - -User Agent 可以基于语义本体查询知识。 - -### Step 4.4 知识候选审核 - -Hermes 生成 FAQ 或条款候选,人工审核后发布。 - -## Phase 5:Orchestrator 基础版 - -目标:基于 ontology_json 做确定性路由。 - -### Step 5.1 建立路由规则 - -输入: - -```text -source -domain -scenario -intent -next_step -``` - -输出: - -```text -agent = hermes | user_agent -tools = [] -permission_required = [] -``` - -### Step 5.2 建立工具网关 - -第一批工具: - -```text -rule_engine.run -knowledge.search -database.query -mcp.call -task.create -``` - -### Step 5.3 建立审计日志 - -所有请求都记录: - -- 原始输入。 -- 语义 JSON。 -- 路由结果。 -- 工具调用。 -- 输出摘要。 -- 错误信息。 - -## Phase 6:User Agent 第一版 - -目标:先做只读和解释,不做强写入。 - -### Step 6.1 支持制度问答 - -流程: - -```text -用户问题 - -> semantic_parse - -> search_knowledge - -> User Agent 生成回答 -``` - -### Step 6.2 支持规则解释 - -流程: - -```text -用户问为什么被拦截 - -> semantic_parse - -> run_rule - -> search_knowledge - -> User Agent 解释风险原因 -``` - -### Step 6.3 支持业务查询 - -先支持: - -- 报销单状态查询。 -- 应收账龄查询。 -- 应付付款状态查询。 - -### Step 6.4 支持草稿生成 - -只生成草稿,不直接提交。 - -```text -用户确认前不写核心状态 -``` - -## Phase 7:Hermes 第一版 - -目标:让后台数字员工开始跑任务。 - -### Step 7.1 每日风险巡检 - -输入: - -- 昨日单据。 -- 发票。 -- 附件。 -- 付款流水。 - -输出: - -- 风险报告。 -- 风险工单。 -- 风险统计。 - -### Step 7.2 每日财务统计 - -统计: - -- 报销金额。 -- 报账金额。 -- 应收账龄。 -- 应付账龄。 -- 付款状态。 -- 账款异常。 - -### Step 7.3 知识候选积累 - -来源: - -- 审批意见。 -- 驳回原因。 -- 高频问答。 -- 规则误报反馈。 - -输出: - -- FAQ 候选。 -- 规则优化建议。 -- 制度变更摘要。 - -## Phase 8:MCP 接入 - -目标:让 Agent 能安全调用外部系统。 - -优先接入: - -1. 发票验真 MCP。 -2. 附件 OCR MCP。 -3. 银行流水 MCP。 -4. 差旅平台 MCP。 -5. ERP/付款状态 MCP。 - -每个 MCP 必须有: - -- 服务地址。 -- 鉴权方式。 -- 权限范围。 -- 超时设置。 -- 降级策略。 -- 健康检查。 -- 调用日志。 - -## Phase 9:规则形成与反馈闭环 - -目标:让系统持续变聪明,但不失控。 - -闭环: - -```text -Hermes 发现问题 - -> 生成规则优化建议 - -> 管理员审核 - -> 更新规则 - -> User Agent 使用新规则解释 - -> 反馈继续进入 Hermes -``` - -关键限制: - -- Hermes 只生成候选。 -- 管理员审核后才能发布。 -- 所有规则变更有版本。 -- 所有上线动作有审核人。 - -### Step 9.1 规则候选池 - -Hermes 从制度、风险案例、反馈中生成规则候选。 - -### Step 9.2 规则测试样例 - -每条规则上线前必须有测试样例。 - -### Step 9.3 反馈池 - -收集 OCR 修正、规则误报、Agent 回答反馈。 - -### Step 9.4 质量看板 - -统计误报率、修正率、回答满意度、MCP 失败率。 diff --git a/document/development/agent/agent plan/06_data_contracts_and_governance.md b/document/development/agent/agent plan/06_data_contracts_and_governance.md deleted file mode 100644 index d3234de..0000000 --- a/document/development/agent/agent plan/06_data_contracts_and_governance.md +++ /dev/null @@ -1,445 +0,0 @@ -# 数据契约与治理要求 - -## 1. 推荐数据表 - -### 1.1 语义本体 - -```text -semantic_ontology_schemas -``` - -字段: - -```text -id -schema_version -schema_json -status -created_by -created_at -updated_at -``` - -```text -semantic_parse_logs -``` - -字段: - -```text -id -source -user_id -raw_text -ontology_json -confidence -parse_strategy -created_at -``` - -### 1.2 Agent 资产 - -```text -agent_rules -agent_skills -agent_mcp_services -agent_tasks -``` - -通用字段: - -```text -id -code -name -description -status -owner -reviewer -config_json -created_at -updated_at -``` - -### 1.3 版本与审核 - -```text -agent_asset_versions -``` - -字段: - -```text -id -asset_type -asset_id -version -content -change_note -created_by -created_at -``` - -```text -agent_asset_reviews -``` - -字段: - -```text -id -asset_type -asset_id -version -reviewer -review_status -review_note -reviewed_at -``` - -### 1.4 运行日志 - -```text -agent_runs -``` - -字段: - -```text -id -agent -source -task_id -user_id -ontology_json -status -started_at -finished_at -result_summary -error_message -``` - -```text -agent_tool_calls -``` - -字段: - -```text -id -run_id -tool_type -tool_name -request_json -response_json -status -duration_ms -created_at -``` - -### 1.5 财务业务主表 - -```text -expense_claims -expense_claim_items -accounts_receivable -accounts_payable -approval_records -``` - -治理要求: - -- `expense_claims` 作为报销主表,不再继续扩张 `reimbursement_requests`。 -- `expense_claim_items` 作为报销明细最小粒度,OCR 匹配、风险识别、票据挂接都优先挂到该粒度。 -- `accounts_receivable` 与 `accounts_payable` 保持独立,避免因为 Agent 语义层接入而混用口径。 - -### 1.6 票据与文件资产表 - -```text -document_assets -document_asset_versions -document_derivatives -expense_item_documents -document_access_logs -``` - -职责: - -- `document_assets`:原始附件主索引 -- `document_asset_versions`:原件版本留痕 -- `document_derivatives`:预览件、缩略图、脱敏件、逐页图片 -- `expense_item_documents`:报销明细与票据关联 -- `document_access_logs`:预览、下载、导出审计 - -### 1.7 OCR、验真与风险表 - -```text -document_ocr_results -invoice_structured_records -invoice_verification_records -risk_events -risk_actions -``` - -职责: - -- `document_ocr_results`:每次 OCR 执行快照 -- `invoice_structured_records`:标准化发票字段 -- `invoice_verification_records`:发票验真结果留痕 -- `risk_events`:风险命中事实 -- `risk_actions`:风险处置动作 - -## 2. API 契约 - -### 2.1 语义解析 - -```text -POST /api/v1/semantic/parse -``` - -请求: - -```json -{ - "source": "user_message", - "text": "这张发票为什么被拦截?", - "context": { - "user_id": "emp_001", - "current_page": "reimbursement_detail" - } -} -``` - -响应: - -```json -{ - "domain": "reimbursement", - "scenario": "invoice_validation", - "intent": "explain", - "entities": [], - "time_range": {}, - "constraints": {}, - "risk_signals": ["unknown"], - "parse_strategy": "llm_primary", - "next_step": "run_rule" -} -``` - -### 2.2 Orchestrator 执行 - -```text -POST /api/v1/agent/orchestrate -``` - -请求: - -```json -{ - "source": "user_message", - "ontology": {}, - "context": {} -} -``` - -响应: - -```json -{ - "agent": "user_agent", - "tools_called": [], - "answer": "", - "requires_confirmation": false, - "audit_id": "" -} -``` - -### 2.3 文件上传契约 - -```text -POST /api/v1/documents/upload -``` - -请求: - -```json -{ - "biz_domain": "expense", - "biz_object_type": "expense_claim", - "biz_object_id": "claim_001", - "upload_source": "user_workbench", - "files": [ - { - "filename": "invoice.jpg", - "mime_type": "image/jpeg" - } - ] -} -``` - -响应: - -```json -{ - "documents": [ - { - "document_id": "", - "version_no": 1, - "storage_status": "stored", - "ocr_status": "pending" - } - ] -} -``` - -### 2.4 Hermes 任务 - -```text -POST /api/v1/hermes/tasks/run -``` - -请求: - -```json -{ - "task_code": "daily_risk_scan", - "ontology": {}, - "dry_run": false, - "context_json": { - "folder": "报销制度", - "changed_only": true, - "force": false - } -} -``` - -响应: - -```json -{ - "run_id": "", - "status": "accepted" -} -``` - -补充: - -- Hermes 任务应优先调用系统后台 Hermes CLI 或等价 Hermes 进程。 -- `changed_only=true` 时,只处理知识库中发生变化的文档。 -- 文档变化判断至少包含 `original_name`、`stored_name`、`sha256`、`version_number`、`updated_at`。 -- 若文档无变化,应返回 `unchanged_skipped`,而不是重新形成 LLM Wiki。 - -## 3. 安全原则 - -### 3.1 最小权限 - -Agent 调工具时不能使用超级权限。 - -权限来源: - -- 用户权限 -- 任务权限 -- 服务账号权限 - -### 3.2 高风险动作确认 - -以下动作必须确认: - -- 提交报销 -- 发起付款 -- 生成正式审批意见 -- 发布规则 -- 发布知识库 -- 创建外部通知 - -### 3.3 审计不可省略 - -必须记录: - -- 谁触发 -- 输入是什么 -- 解析结果是什么 -- 调了哪些工具 -- 输出是什么 -- 是否确认 - -### 3.4 文件存储治理 - -必须遵守: - -- 原始文件二进制不落业务主表,不存入大字段 blob。 -- 所有文件必须有 `storage_provider`、`storage_key`、`sha256`、`file_size_bytes`、`mime_type`。 -- 原件不可覆盖,只能新增版本。 -- 删除默认是解除业务关联或逻辑删除,物理删除必须走审计流程。 -- 对象存储访问必须使用签名 URL 或后端代理,不直接暴露固定公网地址。 - -### 3.5 敏感数据治理 - -对于发票、行程单、合同、付款凭证中的敏感信息: - -- 应支持脱敏衍生件 -- 应记录查看与下载行为 -- 应区分申请人、审批人、财务、管理员可见范围 -- 应支持争议单据 `legal_hold` 保留策略 - -### 3.6 AI 证据治理 - -Agent 和 OCR 相关能力必须遵守: - -- 未经 OCR/VLM 实际解析,不得假设附件内容已知。 -- Agent 输出若引用发票金额、号码、日期,必须能追溯到 `invoice_structured_records` 或人工修正记录。 -- 风险解释若引用“重复报销”“金额不一致”等判断,必须能追溯到 `risk_events.evidence_json`。 - -## 4. 数据质量要求 - -### 4.1 关键唯一性 - -- `expense_claims.claim_no` 唯一 -- `document_assets.sha256` 可重复但必须可检索 -- `document_asset_versions(document_id, version_no)` 唯一 -- `invoice_structured_records.duplicate_fingerprint` 必须可索引 - -### 4.2 时间与状态字段 - -- 所有业务主表必须有 `created_at`、`updated_at` -- 文件上传、OCR、验真、风控、处置必须有独立时间戳 -- 状态字段应使用受控枚举,不允许前端自由拼写 - -### 4.3 可追溯性 - -任一笔报销单、发票或风险结论,至少应能追到: - -- 原始输入文本 -- 原始附件 -- 结构化结果 -- 规则或模型判断 -- 人工修正动作 - -## 5. 实施优先级 - -第一优先级: - -- `expense_claims` -- `expense_claim_items` -- `document_assets` -- `document_asset_versions` -- `expense_item_documents` - -第二优先级: - -- `document_ocr_results` -- `invoice_structured_records` -- `invoice_verification_records` -- `document_derivatives` - -第三优先级: - -- `risk_events` -- `risk_actions` -- `document_access_logs` - -实施原则: - -- 先确保“能收、能存、能找回原件” -- 再确保“能识别、能验真、能回填” -- 最后做“能解释、能审计、能批量巡检” diff --git a/document/development/agent/agent plan/07_capability_registry.md b/document/development/agent/agent plan/07_capability_registry.md deleted file mode 100644 index 2923f59..0000000 --- a/document/development/agent/agent plan/07_capability_registry.md +++ /dev/null @@ -1,198 +0,0 @@ -# Capability Registry 能力注册中心 - -## 1. 为什么需要能力注册中心 - -双 Agent 架构里会出现很多能力: - -- 规则文件。 -- 技能。 -- MCP 服务。 -- 数据库查询。 -- 知识库检索。 -- 定时任务。 -- 报告生成。 - -如果 Orchestrator 直接在代码里硬编码这些能力,会导致: - -- 能力越来越多后难维护。 -- 无法统一权限。 -- 无法统一版本。 -- 无法统一输入输出格式。 -- Hermes 和 User Agent 复用困难。 - -因此建议建立 Capability Registry。 - -它的定位是: - -```text -所有可被 Agent 调用的能力目录 -``` - -## 2. 能力类型 - -建议第一版支持: - -```text -rule -skill -mcp -task -database_query -knowledge_search -report_generator -notification -``` - -含义: - -- `rule`:审查规则,通常是 `.md` 文件或规则配置。 -- `skill`:智能能力,如审批意见生成、风险解释。 -- `mcp`:外部服务连接。 -- `task`:定时或批量任务。 -- `database_query`:受控数据库查询能力。 -- `knowledge_search`:知识库检索能力。 -- `report_generator`:报告生成能力。 -- `notification`:通知能力。 - -## 3. 能力注册结构 - -建议结构: - -```json -{ - "id": "cap_rule_duplicate_invoice", - "code": "duplicate_invoice_rule", - "name": "重复报销识别规则", - "capability_type": "rule", - "domain": "reimbursement", - "scenarios": ["invoice_validation", "reimbursement_audit"], - "intents": ["validate", "explain", "monitor"], - "input_schema": {}, - "output_schema": {}, - "permission_required": ["reimbursement:read", "risk:write"], - "risk_level": "high", - "owner": "财务风控组", - "version": "v1.9", - "status": "active", - "requires_confirmation": false, - "created_at": "", - "updated_at": "" -} -``` - -## 4. 与语义本体的匹配关系 - -Orchestrator 根据 ontology_json 匹配能力。 - -示例: - -```json -{ - "domain": "reimbursement", - "scenario": "invoice_validation", - "intent": "explain", - "risk_signals": ["duplicate_invoice"], - "next_step": "run_rule" -} -``` - -可以匹配: - -```text -重复报销识别规则 -发票验真 MCP -风险解释技能 -制度知识库检索 -``` - -## 5. 能力匹配优先级 - -建议顺序: - -```text -Step 1: next_step 决定能力大类 -Step 2: domain 限定业务域 -Step 3: scenario 限定场景 -Step 4: risk_signals 匹配具体规则 -Step 5: intent 匹配技能 -Step 6: permission_required 校验权限 -Step 7: status 必须 active -Step 8: version 使用当前上线版本 -``` - -## 6. 数据表建议 - -```text -agent_capabilities -``` - -字段: - -```text -id -code -name -capability_type -domain -scenario_json -intent_json -input_schema_json -output_schema_json -permission_json -risk_level -owner -current_version -status -requires_confirmation -config_json -created_at -updated_at -``` - -## 7. 开发步骤 - -### Step 1: 先注册静态能力 - -先把现有规则、技能、MCP、任务写入 Registry。 - -不需要一开始做复杂 UI。 - -### Step 2: Orchestrator 改为查 Registry - -从: - -```text -if next_step = run_rule then call duplicate_invoice_rule -``` - -改为: - -```text -query capabilities where type = rule and scenario = invoice_validation -``` - -### Step 3: 加权限过滤 - -只返回当前用户或任务有权限调用的能力。 - -### Step 4: 加版本选择 - -默认使用 active 版本。 - -历史版本只用于回放和调试。 - -### Step 5: 加健康状态 - -MCP、任务、数据库查询能力应有健康状态。 - -不可用时 Orchestrator 走降级策略。 - -## 8. 治理要求 - -- 所有能力必须有 owner。 -- 高风险能力必须有 reviewer。 -- 所有能力必须有输入输出 schema。 -- 所有能力必须有状态。 -- 下线能力不能被 Orchestrator 调用。 -- 能力版本变更必须写入审计。 - diff --git a/document/development/agent/agent plan/08_permission_confirmation.md b/document/development/agent/agent plan/08_permission_confirmation.md deleted file mode 100644 index 0414b02..0000000 --- a/document/development/agent/agent plan/08_permission_confirmation.md +++ /dev/null @@ -1,214 +0,0 @@ -# 权限与确认引擎 - -## 1. 目标 - -Agent 不能只靠提示词判断能不能执行动作。 - -财务系统需要独立的权限与确认引擎: - -```text -Permission Engine -Confirmation Engine -``` - -它们负责: - -- 判断用户是否能看某类数据。 -- 判断任务是否能调用某个能力。 -- 判断动作是否需要确认。 -- 判断动作是否禁止自动执行。 - -## 2. 动作风险分级 - -建议按 L0-L5 分级。 - -### L0 只读查询 - -例子: - -- 查询制度。 -- 查询单据状态。 -- 查询规则说明。 -- 查询任务运行记录。 - -要求: - -- 需要权限。 -- 不需要确认。 - -### L1 生成建议 - -例子: - -- 生成审批意见建议。 -- 生成风险解释。 -- 生成规则优化建议。 - -要求: - -- 需要权限。 -- 不写业务状态。 -- 不需要确认,但要标记为建议。 - -### L2 生成草稿 - -例子: - -- 生成报销草稿。 -- 生成付款申请草稿。 -- 生成知识库候选。 - -要求: - -- 需要权限。 -- 写入草稿区。 -- 不进入正式流程。 - -### L3 用户确认后提交 - -例子: - -- 用户确认后提交报销。 -- 审批人确认后写入审批意见。 -- 用户确认后发起补件。 - -要求: - -- 必须二次确认。 -- 必须记录确认人。 -- 必须记录确认前后内容。 - -### L4 管理员确认后发布 - -例子: - -- 发布规则。 -- 发布知识库。 -- 启用 MCP。 -- 启用任务。 - -要求: - -- 必须管理员确认。 -- 必须有审核记录。 -- 必须有版本。 - -### L5 禁止自动执行 - -例子: - -- 自动最终审批。 -- 自动付款。 -- 自动绕过风控。 -- 自动修改核心财务状态。 - -要求: - -- Agent 永远不能直接执行。 - -## 3. 权限判断输入 - -```json -{ - "user_id": "emp_001", - "agent": "user_agent", - "source": "user_message", - "action": "create_reimbursement_draft", - "domain": "reimbursement", - "resource": { - "type": "reimbursement_request", - "id": "" - }, - "capability": "travel_reimbursement_create" -} -``` - -## 4. 权限判断输出 - -```json -{ - "allowed": true, - "risk_level": "L2", - "requires_confirmation": false, - "reason": "", - "permission_scope": { - "departments": ["current_user"], - "data_masking": false - } -} -``` - -## 5. 确认弹窗策略 - -需要确认的动作必须显示: - -- 动作名称。 -- 影响对象。 -- 关键字段。 -- 执行后果。 -- 是否可撤销。 -- 确认人。 - -示例: - -```json -{ - "title": "确认提交报销申请", - "action": "submit_reimbursement", - "summary": "将提交差旅报销单 TR-202605001,金额 ¥3,280。", - "risk_level": "L3", - "confirm_button": "确认提交" -} -``` - -## 6. Hermes 权限 - -Hermes 使用服务账号,不使用个人账号。 - -建议拆分权限: - -```text -hermes:risk_scan -hermes:finance_statistics -hermes:knowledge_candidate -hermes:mcp_health_check -``` - -Hermes 默认只允许: - -- 读脱敏快照。 -- 跑规则。 -- 调只读 MCP。 -- 写报告、候选、工单。 - -Hermes 不允许: - -- 写正式审批状态。 -- 写正式付款状态。 -- 发布规则。 -- 发布知识。 - -## 7. User Agent 权限 - -User Agent 继承当前用户权限。 - -例如: - -- 员工只能看自己的报销。 -- 部门负责人可以看本部门。 -- 财务可以看授权范围内数据。 -- 管理员可以管理规则、任务、MCP。 - -User Agent 不能扩大用户权限。 - -## 8. 开发步骤 - -```text -Step 1: 定义 action risk level -Step 2: 建立 Permission Engine 接口 -Step 3: 所有工具调用前接入权限判断 -Step 4: L3/L4 动作接入确认弹窗 -Step 5: 审计记录确认内容 -Step 6: 增加权限测试用例 -``` - diff --git a/document/development/agent/agent plan/09_observability_and_trace.md b/document/development/agent/agent plan/09_observability_and_trace.md deleted file mode 100644 index b21fac3..0000000 --- a/document/development/agent/agent plan/09_observability_and_trace.md +++ /dev/null @@ -1,186 +0,0 @@ -# 可观测性与 Agent Run Trace - -## 1. 目标 - -Agent 系统必须可追踪、可回放、可解释。 - -财务系统中尤其需要回答: - -- 为什么 Agent 得出这个结论? -- 用了哪个模型? -- 用了哪个规则版本? -- 调用了哪些 MCP? -- 查了哪些数据? -- 谁确认了动作? -- 失败在哪里? - -## 2. Agent Run Trace - -每次 Agent 运行都生成一个 run_id。 - -建议结构: - -```json -{ - "run_id": "", - "source": "user_message", - "agent": "user_agent", - "user_id": "emp_001", - "raw_input": "", - "ontology_json": {}, - "route_decision": {}, - "permission_result": {}, - "tool_calls": [], - "final_output": "", - "status": "success", - "started_at": "", - "finished_at": "" -} -``` - -## 3. 需要记录的版本 - -每次运行都要记录: - -```text -ontology_schema_version -semantic_parser_prompt_version -model_name -model_version -rule_version -skill_version -mcp_version -knowledge_snapshot_version -orchestrator_version -``` - -原因: - -用户可能问: - -```text -为什么昨天和今天的结论不一样? -``` - -只有记录版本,才能解释。 - -## 4. Tool Call Trace - -每个工具调用都记录: - -```json -{ - "tool_call_id": "", - "run_id": "", - "tool_type": "mcp", - "tool_name": "invoice_verify", - "request_json": {}, - "response_json": {}, - "status": "success", - "duration_ms": 820, - "error_message": "" -} -``` - -敏感字段应脱敏。 - -## 5. 运行状态 - -建议枚举: - -```text -pending -running -success -partial_success -failed -cancelled -waiting_confirmation -``` - -## 6. Hermes 可观测性 - -Hermes 任务需要额外记录: - -```text -task_code -schedule_time -data_snapshot_id -records_scanned -rules_executed -mcp_calls -risk_items_generated -knowledge_candidates_generated -retry_count -``` - -示例: - -```json -{ - "task_code": "daily_risk_scan", - "records_scanned": 2146, - "rules_executed": 8, - "mcp_calls": 436, - "risk_items_generated": 19, - "status": "success" -} -``` - -## 7. User Agent 可观测性 - -User Agent 需要额外记录: - -```text -conversation_id -page_context -user_confirmation -draft_created -business_object_id -``` - -## 8. 前端审计视图 - -建议后续增加“Agent 运行记录”页面。 - -展示: - -- 运行时间。 -- Agent 类型。 -- 用户或任务。 -- 语义解析结果。 -- 调用工具。 -- 运行状态。 -- 耗时。 -- 错误。 - -详情页展示: - -- 原始输入。 -- 本体 JSON。 -- 路由决策。 -- 工具调用链。 -- 最终输出。 - -## 9. 告警 - -需要告警的情况: - -- Hermes 任务连续失败。 -- MCP 健康检查失败。 -- 语义解析低置信度比例过高。 -- 某规则误报率过高。 -- Agent 调用耗时异常。 -- 权限拒绝次数异常。 - -## 10. 开发步骤 - -```text -Step 1: 增加 agent_runs 表 -Step 2: 增加 agent_tool_calls 表 -Step 3: Orchestrator 每次执行创建 run_id -Step 4: 工具网关记录 tool call -Step 5: 前端增加运行记录页面 -Step 6: 增加异常告警规则 -``` - diff --git a/document/development/agent/agent plan/10_evaluation_and_testset.md b/document/development/agent/agent plan/10_evaluation_and_testset.md deleted file mode 100644 index 4be7c37..0000000 --- a/document/development/agent/agent plan/10_evaluation_and_testset.md +++ /dev/null @@ -1,198 +0,0 @@ -# 评测集与质量控制 - -## 1. 为什么需要评测集 - -语义解析、本体字段、Agent 路由、规则命中都不能只靠人工感觉。 - -每次修改 prompt、模型、规则或路由逻辑,都应该运行评测集。 - -目标: - -- 检查 domain 是否识别正确。 -- 检查 scenario 是否识别正确。 -- 检查 intent 是否识别正确。 -- 检查 next_step 是否正确。 -- 检查是否应该追问。 -- 检查是否错误调用高风险工具。 - -## 2. 第一版评测集规模 - -建议第一版至少 300 条。 - -```text -报销问题:80 条 -应收问题:60 条 -应付问题:60 条 -制度问答:40 条 -风险解释:30 条 -定时任务:20 条 -模糊问题:10 条 -叙述型报销:20 条 -附件输入:10 条 -``` - -## 3. 评测样例结构 - -```json -{ - "id": "eval_001", - "input": "上个月哪些客户应收逾期超过 30 天?", - "expected": { - "domain": "accounts_receivable", - "scenario": "receivable_aging", - "intent": "query", - "next_step": "query_database" - }, - "required_entities": ["customer"], - "notes": "应识别为应收账龄查询" -} -``` - -## 4. 评测指标 - -### 4.1 字段准确率 - -```text -domain_accuracy -scenario_accuracy -intent_accuracy -next_step_accuracy -field_level_f1 -clarification_accuracy -``` - -### 4.2 工具路由准确率 - -```text -tool_route_accuracy -permission_decision_accuracy -confirmation_decision_accuracy -narrative_misroute_rate -``` - -### 4.3 安全指标 - -```text -unsafe_action_rate -missing_confirmation_rate -permission_bypass_rate -low_confidence_unsafe_tool_rate -``` - -这些指标必须接近 0。 - -## 5. 低置信度处理 - -语义解析输出应包含: - -```json -{ - "confidence": 0.62, - "missing_slots": ["time_range"], - "ambiguity": ["应收逾期还是审批逾期"] -} -``` - -当置信度低于阈值: - -```text -confidence < 0.75 - 不执行工具 - 返回追问 -``` - -## 6. 模糊问题样例 - -用户问: - -```text -这个为什么还没处理? -``` - -不能直接执行查询。 - -应该追问: - -```text -你是想查询报销单、应收款还是付款申请的处理状态? -``` - -叙述型报销样例: - -```json -{ - "id": "eval_reimbursement_narrative_001", - "input": "我今天去客户现场,招待了客户,花销了1000元", - "expected": { - "domain": "reimbursement", - "scenario": "daily_expense", - "intent": "create", - "next_step": "ask_clarification" - }, - "required_entities": ["amount"], - "notes": "不能错误路由到应收查询" -} -``` - -## 7. 回归测试流程 - -每次改动以下内容都要跑评测: - -- semantic parser 模型或 provider。 -- semantic parser prompt。 -- ontology schema。 -- Orchestrator 路由。 -- 规则中心匹配逻辑。 -- MCP 能力注册。 -- 模型版本。 - -流程: - -```text -Step 1: 加载评测集 -Step 2: 批量调用 semantic_parse -Step 3: 批量调用 route_decision -Step 4: 对比 expected -Step 5: 输出准确率报告 -Step 6: 阻止低于阈值的发布 -``` - -## 8. 发布阈值 - -建议第一版阈值: - -```text -domain_accuracy >= 95% -intent_accuracy >= 90% -next_step_accuracy >= 90% -unsafe_action_rate = 0 -missing_confirmation_rate = 0 -narrative_misroute_rate <= 1% -low_confidence_unsafe_tool_rate = 0 -``` - -## 9. 评测数据管理 - -建议文件结构: - -```text -server/tests/fixtures/semantic_eval/ - reimbursement.jsonl - accounts_receivable.jsonl - accounts_payable.jsonl - risk_explain.jsonl - scheduled_tasks.jsonl -``` - -每行一个样例。 - -## 10. 开发步骤 - -```text -Step 1: 建立 JSONL 评测集格式 -Step 2: 写 50 条人工样例 -Step 3: 接入 semantic_parse 批测脚本 -Step 4: 输出 markdown/html 评测报告 -Step 5: 扩展到 300 条 -Step 6: 接入 CI 或手动发布检查 -``` diff --git a/document/development/agent/agent plan/11_ocr_invoice_architecture.md b/document/development/agent/agent plan/11_ocr_invoice_architecture.md deleted file mode 100644 index f21fc3b..0000000 --- a/document/development/agent/agent plan/11_ocr_invoice_architecture.md +++ /dev/null @@ -1,376 +0,0 @@ -# OCR 票据识别架构 - -## 1. 定位 - -OCR 票据识别不是一个简单的图片转文字功能。 - -它在 X-Financial 中承担四件事: - -1. 把用户上传的附件变成结构化票据信息。 -2. 为规则中心提供可判断的字段。 -3. 为 User Agent 和 Hermes 提供可解释的证据。 -4. 为后续审计、复核、争议处理保留可回溯原件。 - -因此 OCR 应作为独立能力纳入 Capability Registry。 - -```text -capability_type = mcp | document_processor -capability_code = invoice_ocr -``` - -## 2. 总体链路 - -```text -附件上传 - ↓ -文件落盘 / 对象存储 - ↓ -文件分类 - ↓ -OCR 识别 - ↓ -字段结构化 - ↓ -票据类型归一化 - ↓ -发票验真 MCP - ↓ -与报销明细匹配 - ↓ -规则中心检查 - ↓ -人工修正 - ↓ -修正结果沉淀 -``` - -关键原则: - -- 文件先持久化,再做 OCR,不允许只在内存里跑完就丢。 -- 原件不可覆盖,只能新增版本。 -- Agent 不得假设图片内容已知;只有 OCR/VLM 实际解析后才能引用附件内容。 - -## 3. 阶段拆分 - -### Phase A:附件接入与文件分类 - -目标:先识别上传的是什么。 - -输入: - -- 图片 -- PDF -- Excel -- Word -- 压缩包 - -输出: - -```json -{ - "document_type": "invoice", - "mime_type": "image/png", - "page_count": 1, - "confidence": 0.91 -} -``` - -分类结果: - -```text -invoice -itinerary -contract -payment_receipt -approval_screenshot -other -``` - -### Phase B:OCR 字段提取 - -目标:从图片或 PDF 中提取票据字段。 - -结构: - -```json -{ - "invoice_code": "", - "invoice_number": "", - "seller_name": "", - "seller_tax_no": "", - "buyer_name": "", - "buyer_tax_no": "", - "issue_date": "", - "total_amount": 0, - "tax_amount": 0, - "currency": "CNY", - "ocr_confidence": 0.88 -} -``` - -### Phase C:字段归一化 - -目标:不同 OCR 服务返回不同字段名,必须统一。 - -示例: - -```text -发票号码 / invoiceNo / invoice_number - -> invoice_number -``` - -金额统一: - -```json -{ - "raw": "¥1,280.00", - "value": 1280.00, - "currency": "CNY" -} -``` - -### Phase D:验真与状态检查 - -调用发票验真 MCP。 - -输出: - -```json -{ - "verify_status": "verified", - "voided": false, - "red_reversed": false, - "verified_at": "" -} -``` - -### Phase E:与报销明细匹配 - -对比: - -- 发票金额 vs 报销金额 -- 开票日期 vs 费用日期 -- 销售方 vs 商户 -- 发票类型 vs 费用类型 - -输出: - -```json -{ - "match_status": "matched", - "mismatch_fields": [], - "match_confidence": 0.94 -} -``` - -### Phase F:人工修正与回流 - -OCR 结果必须允许人工修正。 - -修正内容进入反馈池: - -```json -{ - "field": "invoice_number", - "before": "12345B", - "after": "123456", - "corrected_by": "finance_user", - "corrected_at": "" -} -``` - -## 4. 文件存储策略 - -### 4.1 为什么不能直接把文件塞进数据库 - -- 原始票据、合同、行程单体积大,数据库行膨胀明显。 -- 预览件、缩略图、逐页图片、脱敏件都属于衍生文件,不适合和业务行混存。 -- 财务原件需要版本留痕和不可变追溯,文件系统或对象存储更适合。 - -结论: - -- 文件二进制存文件系统或对象存储。 -- 数据库仅保存元数据、索引、版本、OCR 结果、验真结果、访问审计和业务关联。 - -### 4.2 开发环境目录方案 - -根目录使用后端配置中的 `STORAGE_ROOT_DIR`。 - -建议目录: - -```text -/ - finance-documents/ - expense_claim/ - 2026/ - 05/ - / - / - v1/ - original/ - source.jpg - preview/ - preview.pdf - pages/ - page-1.png - thumbs/ - thumb.webp - ocr/ - ocr-1.json - verify/ - verify-1.json -``` - -说明: - -- `claim_id` 为空时,可先挂到 `draft///...`,待正式建单后再回填业务关联。 -- `v1`、`v2` 表示文件版本,不允许直接覆盖 `v1`。 -- 原始文件名用于展示,真实定位依赖 `storage_key` 和 `sha256`。 - -### 4.3 生产环境存储方案 - -生产环境建议使用: - -- MinIO -- S3 -- 阿里云 OSS -- 腾讯云 COS - -对象存储推荐键名: - -```text -finance-documents/expense_claim/2026/05///v1/original/source.jpg -finance-documents/expense_claim/2026/05///v1/preview/preview.pdf -finance-documents/expense_claim/2026/05///v1/thumbs/thumb.webp -``` - -数据库必须保存: - -```text -storage_provider -storage_bucket -storage_key -sha256 -file_size_bytes -mime_type -current_version_no -``` - -### 4.4 原件、版本与衍生件规则 - -- 原件不可变:上传后不得覆盖。 -- 替换附件只能新增 `document_asset_versions` 记录。 -- OCR 原始输出、验真响应、预览件、缩略图都作为衍生件管理。 -- 删除操作默认只允许逻辑删除业务关联,不允许物理删除原件。 -- 命中审计或争议流程的单据可切换到 `legal_hold` 保留策略,暂停清理。 - -### 4.5 去重与追溯 - -- 每个原始文件必须计算 `sha256`。 -- 同一个 `sha256` 可提示重复上传,但不能自动覆盖旧版本。 -- 发票查重不能只靠文件哈希,还要结合 `invoice_code + invoice_number + issue_date + total_amount`。 - -## 5. 数据模型建议 - -推荐配套表: - -```text -document_assets -document_asset_versions -document_derivatives -document_ocr_results -invoice_structured_records -invoice_verification_records -expense_item_documents -document_access_logs -``` - -各表职责: - -- `document_assets`:文件主索引 -- `document_asset_versions`:原件版本 -- `document_derivatives`:缩略图、预览、逐页图片、脱敏件 -- `document_ocr_results`:每次 OCR 执行结果 -- `invoice_structured_records`:标准化票据字段 -- `invoice_verification_records`:验真结果 -- `expense_item_documents`:报销明细与票据挂接 -- `document_access_logs`:文件查看、下载、导出审计 - -## 6. 与规则中心关系 - -OCR 输出供规则使用: - -```text -重复报销识别规则 -作废发票检查规则 -发票抬头异常规则 -附件完整性规则 -金额不一致规则 -OCR 低置信度补录规则 -``` - -规则读取原则: - -- 读标准化字段,不直接依赖某个 OCR 服务的原始字段名。 -- 需要追证时,从 `document_assets` 和 `document_asset_versions` 找原件。 -- 需要解释时,从 `document_ocr_results` 和 `invoice_verification_records` 给证据。 - -## 7. 与 Agent 关系 - -User Agent 使用 OCR: - -- 解释发票为什么被拦截 -- 帮用户补充发票信息 -- 提醒上传清晰附件 -- 根据 OCR 结果自动回填报销草稿 - -Hermes 使用 OCR: - -- 夜间批量验真 -- 扫描重复票据 -- 统计发票异常趋势 -- 回刷历史低置信度票据 - -## 8. 安全与审计要求 - -### 8.1 访问控制 - -- 原始票据预览、下载应按用户角色控制。 -- 财务、审批人、申请人看到的文件范围可以不同。 -- 对象存储不要暴露永久公网链接,统一走签名 URL 或后端代理下载。 - -### 8.2 敏感信息处理 - -- 身份证、银行卡、手机号等敏感字段如被识别,应支持脱敏预览件。 -- 对外展示尽量用衍生件,不直接暴露原件。 - -### 8.3 审计要求 - -必须记录: - -- 谁上传了原件 -- 谁触发了 OCR -- 谁查看或下载了原件 -- 谁修正了 OCR 结果 -- 谁发起了验真 -- 哪次风险判断引用了哪些票据 - -## 9. 开发阶段建议 - -```text -Step 1: 附件上传与 document_assets / document_asset_versions 落库 -Step 2: 本地文件目录方案打通 -Step 3: 接入 OCR MCP 或 OCR 服务 -Step 4: 结构化字段归一化 -Step 5: 发票验真 MCP -Step 6: 与 expense_claim_items 匹配 -Step 7: 风险规则中心接入 -Step 8: 人工修正界面 -Step 9: Hermes 夜间批量 OCR 与验真巡检 -``` - -当前阶段优先级: - -- 先把“文件原件可存、可找、可追溯”做实。 -- 再把 OCR 和验真接进来。 -- 最后再做大规模自动巡检和脱敏导出。 diff --git a/document/development/agent/agent plan/12_llm_wiki_knowledge_architecture.md b/document/development/agent/agent plan/12_llm_wiki_knowledge_architecture.md deleted file mode 100644 index 9e10ca1..0000000 --- a/document/development/agent/agent plan/12_llm_wiki_knowledge_architecture.md +++ /dev/null @@ -1,221 +0,0 @@ -# LLM Wiki 知识库架构 - -## 1. 定位 - -LLM Wiki 不是简单的文件库。 - -它是给 Agent 使用的知识底座,负责把制度、FAQ、审批经验、规则说明转成可检索、可引用、可版本化的知识。 - -## 2. 总体链路 - -```text -文档上传 - ↓ -格式解析 - ↓ -正文抽取 - ↓ -分块 Chunking - ↓ -元数据标注 - ↓ -向量索引 - ↓ -条款抽取 - ↓ -知识候选 - ↓ -人工审核 - ↓ -发布 Wiki - ↓ -Agent 检索引用 -``` - -## 2.1 目录约束 - -LLM Wiki 解析产物不能与原始制度文件混放。 - -推荐目录: - -```text -/app/server/storage/knowledge// 原始知识文件 -/app/server/storage/knowledge/.llm_wiki/ 解析产物根目录 -/app/server/storage/knowledge/.llm_wiki/documents// - document.json - text.md - chunks.json - clauses.json - knowledge_candidates.json - rule_candidates.json -/app/server/storage/knowledge/.llm_wiki/index.json -/app/server/storage/knowledge/.llm_wiki/sync_runs.json -``` - -约束: - -- 原始文件目录只存原件,不存解析碎片。 -- LLM Wiki 目录只存结构化产物,不反向覆盖原件。 -- 所有解析产物必须能按 `document_id` 回溯到原始文件。 - -## 3. 知识类型 - -```text -policy_document -faq -rule_explanation -approval_case -risk_case -operation_manual -system_notice -``` - -## 4. 知识块结构 - -```json -{ - "chunk_id": "", - "document_id": "", - "title": "", - "content": "", - "domain": "reimbursement", - "scenario": "travel_reimbursement", - "tags": ["差旅", "住宿标准"], - "effective_date": "", - "version": "v1.0", - "source_page": 4, - "embedding_id": "", - "status": "published" -} -``` - -## 5. 条款抽取 - -Hermes 可以从制度文档中抽取条款候选。 - -示例: - -```json -{ - "clause_type": "amount_limit", - "domain": "reimbursement", - "scenario": "travel_reimbursement", - "condition": { - "city_tier": "一线城市", - "employee_grade": "P5" - }, - "limit": { - "amount": 800, - "currency": "CNY", - "period": "night" - }, - "source": "差旅制度 2026 第 4 页" -} -``` - -该结果不直接变成规则,先进入规则候选池。 - -## 5.1 增量形成策略 - -LLM Wiki 不应按天无脑全量重建。 - -每个文档都应维护签名: - -```json -{ - "document_id": "", - "original_name": "", - "stored_name": "", - "sha256": "", - "version_number": 1, - "updated_at": "" -} -``` - -只有在以下任一条件发生时,才重建对应文档的 LLM Wiki: - -- `original_name` 变更。 -- `stored_name` 变更。 -- `sha256` 变更。 -- `version_number` 变更。 -- `updated_at` 变更,视为人工修改。 - -如果以上均未变化: - -- 本次文档状态应记为 `unchanged_skipped`。 -- 不重新抽取正文。 -- 不重新分块。 -- 不重新生成知识候选或规则候选。 - -## 6. Wiki 发布流程 - -```text -草稿知识 - ↓ -Hermes 生成候选 - ↓ -知识管理员审核 - ↓ -发布 - ↓ -Agent 可检索 -``` - -## 7. 与 User Agent 的关系 - -User Agent 用 Wiki: - -- 回答制度问题。 -- 给风险解释提供条款依据。 -- 给审批意见生成引用。 -- 帮用户理解流程。 - -## 8. 与 Hermes 的关系 - -Hermes 用 Wiki: - -- 每日知识候选生成。 -- 发现制度与规则不一致。 -- 生成规则优化建议。 -- 生成 FAQ 候选。 - -补充要求: - -- Hermes 对制度文档的处理默认是增量同步,不是每日全量重建。 -- Hermes 应先检查知识库签名,再决定是否需要重建某个文档的 Wiki。 -- Hermes 形成的是候选与草稿,不是正式发布内容。 - -## 9. 数据模型建议 - -```text -knowledge_documents -knowledge_chunks -knowledge_embeddings -knowledge_candidates -knowledge_reviews -knowledge_versions -``` - -当前最小落地允许先以文件索引实现: - -```text -.llm_wiki/index.json -.llm_wiki/sync_runs.json -.llm_wiki/documents//document.json -``` - -后续再平滑迁移到数据库或向量库。 - -## 10. 开发阶段建议 - -```text -Step 1: 文档上传和文件管理 -Step 2: 文本抽取和分块 -Step 3: 元数据标注 -Step 4: 向量索引 -Step 5: 知识检索 API -Step 6: User Agent 问答引用 -Step 7: Hermes 知识候选生成 -Step 8: 人工审核发布 -Step 9: 条款抽取和规则候选 -``` diff --git a/document/development/agent/agent plan/13_rule_formation_lifecycle.md b/document/development/agent/agent plan/13_rule_formation_lifecycle.md deleted file mode 100644 index 414aad8..0000000 --- a/document/development/agent/agent plan/13_rule_formation_lifecycle.md +++ /dev/null @@ -1,194 +0,0 @@ -# 规则形成生命周期 - -## 1. 定位 - -规则不是凭空写出来的。 - -它应来自: - -- 制度文档。 -- 历史审批。 -- 风险案例。 -- OCR 识别结果。 -- MCP 验真结果。 -- 用户反馈。 -- Hermes 分析。 - -## 2. 总体闭环 - -```text -制度文档 / 历史审批 / 风险案例 / 用户反馈 - ↓ -Hermes 分析 - ↓ -规则候选 - ↓ -人工审核 - ↓ -规则 .md - ↓ -测试样例 - ↓ -版本发布 - ↓ -规则执行 - ↓ -命中反馈 - ↓ -规则优化 -``` - -## 3. 规则候选结构 - -```json -{ - "candidate_id": "", - "source_type": "policy_document", - "domain": "reimbursement", - "scenario": "invoice_validation", - "risk_signal": "duplicate_invoice", - "suggested_rule_name": "重复报销识别规则", - "rule_markdown_draft": "", - "evidence": [], - "confidence": 0.86, - "created_by": "hermes" -} -``` - -补充约束: - -- `rule_markdown_draft` 不能是任意自由文本,必须符合固定模板。 -- 规则候选应同时携带机器可读 JSON 草稿,例如 `runtime_rule`。 -- JSON 草稿只能从受控模板族中选择,不允许 Hermes 自创字段结构后直接进入规则中心。 - -## 4. 规则 Markdown 推荐结构 - -```markdown -# 规则名称 - -## 目标 - -## 适用范围 - -## 输入字段 - -## 判断规则 - -## 输出 - -## 测试样例 - -## 管理员备注 -``` - -推荐再补一段模板元信息: - -```markdown -## 模板信息 - -- 模板键:`travel_standard_v1` -- 来源文档:公司支出管理办法(2024) -- Hermes 置信度:0.86 -- 审核人:张三 -``` - -## 4.1 规则 JSON 推荐结构 - -规则中心不应只有 Markdown。 - -应同时提供可执行 JSON 编辑区,至少支持: - -```json -{ - "kind": "policy_rule_draft", - "version": 1, - "template_key": "travel_standard_v1", - "rule_name": "差旅住宿标准草稿规则", - "scenario": "travel_reimbursement", - "review_required": true, - "conditions": {}, - "actions": {}, - "source_document_name": "公司支出管理办法(2024)" -} -``` - -治理要求: - -- Markdown 负责给人看。 -- JSON 负责给运行时和规则引擎看。 -- 两者必须成对维护,不能只改其中一份。 -- JSON 变更也必须走版本和审核。 - -## 4.2 模板族约束 - -Hermes 只能从白名单模板中选,不允许自由生成任意规则结构。 - -第一版建议模板: - -```text -travel_standard_v1 -expense_amount_limit_v1 -attachment_requirement_v1 -general_policy_v1 -``` - -如果制度条款不适合自动规则化: - -- 允许只生成 `knowledge_candidate` -- 或只生成 `general_policy_v1` 草稿并要求人工补齐 -- 不能为了“有结果”而编造可执行规则 - -## 5. 审核要求 - -规则上线必须满足: - -- 有审核人。 -- 有版本。 -- 有测试样例。 -- 有来源依据。 -- 有回滚方案。 - -补充: - -- Hermes 生成规则默认只能是 `draft`。 -- Hermes 不能直接覆盖当前 `active` 线上规则。 -- Hermes 如发现制度更新,应优先生成新的候选或草稿版本,仍需人工审核后再上线。 - -## 6. 规则执行反馈 - -每次规则运行应记录: - -```text -rule_id -rule_version -input_snapshot -hit_result -risk_level -operator_feedback -false_positive -false_negative -``` - -## 7. 规则优化来源 - -```text -误报反馈 -漏报反馈 -审批人修改意见 -Hermes 每日复盘 -制度文档更新 -MCP 新字段可用 -``` - -## 8. 开发阶段建议 - -```text -Step 1: 规则 .md 编辑和版本 -Step 2: 规则审核上线 -Step 3: 规则运行日志 -Step 4: 人工反馈误报/漏报 -Step 5: Hermes 生成规则候选 -Step 6: 规则候选审核 -Step 7: 规则测试样例管理 -Step 8: 规则质量看板 -``` diff --git a/document/development/agent/agent plan/14_financial_document_canonical_model.md b/document/development/agent/agent plan/14_financial_document_canonical_model.md deleted file mode 100644 index 4a4ede2..0000000 --- a/document/development/agent/agent plan/14_financial_document_canonical_model.md +++ /dev/null @@ -1,646 +0,0 @@ -# 财务单据标准模型 - -## 1. 为什么需要标准模型 - -OCR、MCP、用户填写、业务数据库可能都描述同一张发票,但字段名和格式不同。 - -如果没有标准模型: - -- 规则无法复用。 -- Agent 难以解释。 -- Hermes 难以批量统计。 -- MCP 返回结果难以合并。 - -这里要区分三层: - -- 标准模型:定义 Agent、规则、MCP、OCR、数据库之间统一交换的数据结构。 -- 业务数据库表:定义 MVP 阶段真正落库存储、查询和统计所依赖的业务表。 -- 文件存储对象:定义原始票据、预览件、OCR 中间产物、验真结果附件的存储位置与版本规则。 - -如果只有标准模型,没有业务表和文件资产表,User Agent 无法真正发起报销;如果只有数据库表,没有统一标准模型,语义解析、规则解释、OCR 回填和 Hermes 巡检会越来越混乱。 - -## 2. 标准对象 - -第一版建议定义这些对象: - -```text -Invoice -Receipt -ExpenseClaim -PaymentRequest -AccountsReceivableRecord -AccountsPayableRecord -BankTransaction -Contract -Customer -Vendor -Employee -CostCenter -DocumentAsset -RiskEvent -``` - -说明: - -- 对外语义层建议统一使用 `ExpenseClaim` 概念,不再把“报销申请”和“报销单据”拆成两个平行主概念。 -- 现有代码中仍有 `reimbursement_requests` 表,MVP 阶段不建议再继续扩张该表,而应以 `expense_claims` 作为报销主表。 -- `reimbursement_requests` 可保留用于兼容旧页面或审批联动,但新能力默认挂到 `expense_claims`。 - -## 3. Invoice 标准模型 - -```json -{ - "invoice_id": "", - "invoice_code": "", - "invoice_number": "", - "invoice_type": "", - "seller_name": "", - "seller_tax_no": "", - "buyer_name": "", - "buyer_tax_no": "", - "issue_date": "", - "total_amount": 0, - "tax_amount": 0, - "currency": "CNY", - "verify_status": "", - "ocr_confidence": 0, - "source_document_id": "" -} -``` - -## 4. ExpenseClaim 标准模型 - -```json -{ - "claim_id": "", - "claim_no": "", - "employee_id": "", - "employee_name": "", - "department_id": "", - "department_name": "", - "cost_center_code": "", - "project_code": "", - "expense_type": "", - "reason": "", - "location": "", - "amount": 0, - "currency": "CNY", - "status": "", - "occurred_at": "", - "submitted_at": "", - "approval_stage": "", - "items": [], - "attachments": [], - "risk_flags": [] -} -``` - -说明: - -- `reason`、`location`、`occurred_at` 是报销语义判断、规则解释、风险识别的最小必要字段。 -- 一张报销单通常包含多条费用明细,标准模型中允许聚合,数据库层必须拆到明细表。 -- `attachments` 指向文件资产,不直接嵌入二进制文件。 - -## 5. AccountsReceivableRecord 标准模型 - -```json -{ - "ar_id": "", - "document_no": "", - "customer_id": "", - "customer_name": "", - "contract_no": "", - "invoice_no": "", - "amount_receivable": 0, - "amount_received": 0, - "amount_outstanding": 0, - "currency": "CNY", - "due_date": "", - "posting_date": "", - "status": "", - "aging_days": 0, - "risk_flags": [] -} -``` - -## 6. AccountsPayableRecord 标准模型 - -```json -{ - "ap_id": "", - "document_no": "", - "vendor_id": "", - "vendor_name": "", - "invoice_no": "", - "amount_payable": 0, - "amount_paid": 0, - "amount_outstanding": 0, - "currency": "CNY", - "due_date": "", - "posting_date": "", - "status": "", - "aging_days": 0, - "risk_flags": [] -} -``` - -## 7. BankTransaction 标准模型 - -```json -{ - "transaction_id": "", - "bank_account": "", - "transaction_date": "", - "amount": 0, - "currency": "CNY", - "counterparty_name": "", - "summary": "", - "matched_object_type": "", - "matched_object_id": "", - "match_status": "" -} -``` - -## 8. MVP 真实业务表设计 - -标准模型不等于数据库表,但 MVP 至少要有以下真实表,才能支撑 Day 5 用户报销对话、Day 6 风险巡检和后续审批/验真闭环。 - -### 8.1 设计原则 - -- 报销主数据统一落在 `expense_claims`,不再新建第三套“报销主表”。 -- 原始票据文件二进制不进数据库,只存元数据和关联信息。 -- OCR 结果、发票结构化结果、验真结果、风险事件要分表存,避免把所有字段塞进一个 JSON。 -- 所有表都要能被 Agent 解释,也要能被 Hermes 批量扫表。 -- `reimbursement_requests` 进入兼容态,不作为新能力主干表继续扩展。 - -### 8.2 报销主表 `expense_claims` - -用途: - -- 作为用户报销会话最终落单的主业务对象。 -- 承接语义层补槽后的草稿、提交、审批、打回、归档状态。 - -建议字段: - -```text -id string(36) PK -claim_no string(50) UK, 报销单号 -source string(30) 来源: agent/web/import/api -title string(200) 报销标题 -employee_id string(64) 申请人 ID -employee_name string(100) 申请人姓名 -department_id string(64) 部门 ID -department_name string(100) 部门名 -company_code string(50) 公司编码 -cost_center_code string(50) 成本中心 -project_code string(50) 项目编码 -expense_type string(50) 费用大类 -reason text 事由 -location string(100) 地点 -amount numeric(12,2) 报销总金额 -currency string(10) 币种 -invoice_count int 附件票据数 -attachment_count int 附件总数 -occurred_start_at timestamptz 发生开始时间 -occurred_end_at timestamptz 发生结束时间 -submitted_at timestamptz 提交时间 -status string(30) draft/submitted/approved/rejected/paid -status_changed_at timestamptz 最近状态变更时间 -status_changed_by string(64) 最近状态变更人 -status_change_note text 状态变更备注 -approval_stage string(50) 当前审批节点 -risk_level string(20) none/low/medium/high -risk_flags_json json 风险标记快照 -conversation_id string(64) 对话会话 ID -created_by string(64) 创建人 -updated_by string(64) 更新人 -created_at timestamptz -updated_at timestamptz -``` - -说明: - -- 现有模型已有一部分字段,后续只做增量扩展即可。 -- `occurred_start_at`、`occurred_end_at` 比单一 `occurred_at` 更适合差旅、接待等跨时段报销。 - -### 8.2.1 报销状态流转建议 - -建议状态: - -```text -draft -submitted -approved -rejected -paid -``` - -建议流转: - -```text -语义补槽完成 - -> 创建 expense_claims 草稿 - -> status = draft - -用户继续补充字段 / 上传附件 - -> 更新 expense_claims / expense_claim_items / expense_item_documents - -> status 仍为 draft - -用户明确确认提交 - -> status = submitted - -> 写入 submitted_at / status_changed_at / status_changed_by - -审批流结果回写 - -> status = approved 或 rejected - -付款完成回写 - -> status = paid -``` - -边界: - -- User Agent 可以创建 `draft`,也可以在用户确认后提交到 `submitted`。 -- User Agent 不应直接把状态改为 `approved`、`rejected`、`paid`。 -- 所有状态变化都应写审计日志,必要时保留 `status_change_note`。 - -### 8.3 报销明细表 `expense_claim_items` - -用途: - -- 表达一单多明细。 -- 作为 OCR 发票比对、重复报销识别、风险定位的最小粒度。 - -建议字段: - -```text -id string(36) PK -claim_id string(36) FK -> expense_claims.id -line_no int 明细序号 -item_date date 费用发生日期 -item_type string(50) 费用小类 -item_reason text 明细事由 -item_location string(100) 明细地点 -merchant_name string(200) 商户/酒店/餐厅 -customer_name string(200) 客户单位 -participants_json json 参与人员 -transport_type string(50) 交通方式 -item_amount numeric(12,2) 明细金额 -tax_amount numeric(12,2) 税额 -currency string(10) -invoice_match_status string(30) unmatched/partial/matched -risk_level string(20) -risk_flags_json json -remark text -created_at timestamptz -updated_at timestamptz -``` - -说明: - -- 现有 `invoice_id` 单字段不足以覆盖多张附件挂同一明细的情况,后续应改为关联表。 - -### 8.4 票据资产主表 `document_assets` - -用途: - -- 作为所有原始附件的主索引表。 -- 支持报销单、报销明细、审批、验真、风控证据等多对象挂载。 - -建议字段: - -```text -id string(36) PK -biz_domain string(30) expense/ap/ar/common -biz_object_type string(50) expense_claim/expense_item/approval_record -biz_object_id string(36) 业务对象 ID -document_type string(50) invoice/receipt/itinerary/contract/other -document_subtype string(50) vat_special/taxi/train/hotel/meal 等 -source string(30) upload/agent/import/system -original_filename string(255) -mime_type string(100) -file_ext string(20) -page_count int -file_size_bytes bigint -sha256 string(64) 去重与追溯 -storage_provider string(30) local/minio/s3/oss/cos -storage_bucket string(100) 本地模式可为空 -storage_key string(500) 指向当前有效版本原件 -current_version_no int -classification_status string(30) pending/success/failed -ocr_status string(30) pending/running/success/failed -virus_scan_status string(30) pending/clean/infected -retention_policy string(30) finance_default/legal_hold/manual -uploaded_by string(64) -uploaded_at timestamptz -created_at timestamptz -updated_at timestamptz -``` - -### 8.5 票据版本表 `document_asset_versions` - -用途: - -- 保留原始文件和后续重新上传版本。 -- 允许“修正”但不允许覆盖原始证据。 - -建议字段: - -```text -id string(36) PK -document_id string(36) FK -> document_assets.id -version_no int 1,2,3... -is_current bool -change_reason string(100) replace/rotate/desensitize/reupload -original_filename string(255) -mime_type string(100) -file_size_bytes bigint -sha256 string(64) -storage_provider string(30) -storage_bucket string(100) -storage_key string(500) -uploaded_by string(64) -uploaded_at timestamptz -created_at timestamptz -``` - -### 8.6 衍生文件表 `document_derivatives` - -用途: - -- 存储缩略图、预览 PDF、逐页图片、脱敏件等衍生产物。 - -建议字段: - -```text -id string(36) PK -document_version_id string(36) FK -> document_asset_versions.id -derivative_type string(50) thumb/preview/page_image/desensitized -page_no int 可空 -mime_type string(100) -file_size_bytes bigint -storage_provider string(30) -storage_bucket string(100) -storage_key string(500) -created_by string(64) -created_at timestamptz -``` - -### 8.7 OCR 结果表 `document_ocr_results` - -用途: - -- 保留每次 OCR 原始结果、模型版本、置信度和错误信息。 -- 支持后续重跑 OCR 与人工纠错对比。 - -建议字段: - -```text -id string(36) PK -document_id string(36) FK -> document_assets.id -document_version_id string(36) FK -> document_asset_versions.id -ocr_engine string(50) paddle/aliyun/tencent/openai 等 -ocr_model string(100) -run_no int 第几次 OCR -status string(30) success/failed/partial -language string(20) -raw_text text -raw_result_json json -structured_result_json json -confidence numeric(5,4) -error_message text -started_at timestamptz -finished_at timestamptz -created_at timestamptz -``` - -### 8.8 发票结构化表 `invoice_structured_records` - -用途: - -- 将发票核心字段标准化后独立存储,便于查重、验真、规则计算。 - -建议字段: - -```text -id string(36) PK -document_id string(36) FK -> document_assets.id -ocr_result_id string(36) FK -> document_ocr_results.id -invoice_code string(50) -invoice_number string(50) -invoice_type string(50) -seller_name string(200) -seller_tax_no string(50) -buyer_name string(200) -buyer_tax_no string(50) -issue_date date -total_amount numeric(12,2) -tax_amount numeric(12,2) -currency string(10) -check_code string(100) -is_red_invoice bool -is_electronic bool -ocr_confidence numeric(5,4) -normalized_status string(30) normalized/manual_corrected -duplicate_fingerprint string(100) 发票号+代码+金额+日期 -created_at timestamptz -updated_at timestamptz -``` - -### 8.9 发票验真记录表 `invoice_verification_records` - -用途: - -- 保留每次调用税局/第三方验真服务的结果,支持追溯。 - -建议字段: - -```text -id string(36) PK -invoice_record_id string(36) FK -> invoice_structured_records.id -verification_channel string(50) tax_mcp/third_party/manual -request_payload_json json -response_payload_json json -verify_status string(30) verified/unverified/voided/error -voided bool -red_reversed bool -verified_amount numeric(12,2) -verified_issue_date date -error_code string(50) -error_message text -verified_by string(64) -verified_at timestamptz -created_at timestamptz -``` - -### 8.10 明细与票据关联表 `expense_item_documents` - -用途: - -- 解决一条明细可关联多张票据、一张票据也可能支撑多条拆分明细的场景。 - -建议字段: - -```text -id string(36) PK -claim_id string(36) FK -> expense_claims.id -claim_item_id string(36) FK -> expense_claim_items.id -document_id string(36) FK -> document_assets.id -relation_type string(30) evidence/invoice/boarding_pass/receipt -allocated_amount numeric(12,2) 分摊到该明细的金额 -match_status string(30) unmatched/partial/matched -match_confidence numeric(5,4) -created_at timestamptz -updated_at timestamptz -``` - -### 8.11 风险事件表 `risk_events` - -用途: - -- 记录风险命中,而不是只在主表里塞一个 `risk_flags_json`。 -- 作为 Agent 解释“为什么拦截”的核心依据。 - -建议字段: - -```text -id string(36) PK -biz_domain string(30) expense/ap/ar -biz_object_type string(50) expense_claim/expense_item/invoice -biz_object_id string(36) -risk_code string(50) duplicate_invoice/amount_mismatch 等 -risk_name string(100) -risk_level string(20) low/medium/high -hit_source string(30) rule/agent/hermes/manual -evidence_json json -status string(30) open/confirmed/resolved/ignored -detected_at timestamptz -detected_by string(64) -resolved_at timestamptz -resolved_by string(64) -resolution_note text -created_at timestamptz -updated_at timestamptz -``` - -### 8.12 风险处置表 `risk_actions` - -用途: - -- 记录每次人工确认、驳回、忽略、要求补件等处置动作。 - -建议字段: - -```text -id string(36) PK -risk_event_id string(36) FK -> risk_events.id -action_type string(30) confirm/reject/ignore/request_more -action_note text -operator_id string(64) -operator_name string(100) -created_at timestamptz -``` - -### 8.13 文件访问审计表 `document_access_logs` - -用途: - -- 记录谁看过、下载过、导出过原始票据。 -- 支撑财务审计和数据安全追溯。 - -建议字段: - -```text -id string(36) PK -document_id string(36) FK -> document_assets.id -document_version_id string(36) FK -> document_asset_versions.id -action string(30) preview/download/export/delete -operator_id string(64) -operator_name string(100) -operator_role string(50) -client_ip string(64) -user_agent string(255) -trace_id string(64) -created_at timestamptz -``` - -## 9. 表关系建议 - -```text -expense_claims - └─ expense_claim_items - └─ expense_item_documents - └─ document_assets - └─ document_asset_versions - └─ document_derivatives - └─ document_ocr_results - └─ invoice_structured_records - └─ invoice_verification_records - -risk_events -> 可指向 expense_claims / expense_claim_items / invoice_structured_records -risk_actions -> risk_events -document_access_logs -> document_assets / document_asset_versions -``` - -原则: - -- 主业务对象和文件资产解耦。 -- OCR、验真、风险都挂在文件资产或业务对象之上,不把责任塞到一个巨表。 -- 文件版本和业务关系分离,避免替换附件时把历史证据冲掉。 - -## 10. 与现有表的衔接策略 - -当前代码中已经存在: - -- `expense_claims` -- `expense_claim_items` -- `reimbursement_requests` - -建议策略: - -- `expense_claims` 继续作为未来报销主表。 -- `expense_claim_items` 增量扩字段并替换当前单一 `invoice_id` 直连方式。 -- `reimbursement_requests` 暂不删除,但冻结扩表。 -- 如旧流程仍引用 `reimbursement_requests`,可在过渡期建立: - - `request_no -> claim_no` 对照字段 - - 或由 `approval_records` 同时支持两类来源对象 - -不建议做法: - -- 再新建第四张“报销申请主表”。 -- 把原始发票图片以 blob 方式存进 `expense_claims`。 -- 把 OCR、验真、风控结果全塞进一个 JSON 大字段。 - -## 11. 实施顺序建议 - -Phase 1: - -- 扩展 `expense_claims` -- 扩展 `expense_claim_items` -- 新增 `document_assets` -- 新增 `document_asset_versions` -- 新增 `expense_item_documents` - -Phase 2: - -- 新增 `document_ocr_results` -- 新增 `invoice_structured_records` -- 新增 `invoice_verification_records` -- 新增 `document_derivatives` - -Phase 3: - -- 新增 `risk_events` -- 新增 `risk_actions` -- 新增 `document_access_logs` - -Phase 4: - -- 逐步弱化 `reimbursement_requests` -- 将 Agent 草稿、审批、OCR、验真、风控全收敛到 `expense_claims` 体系 - -## 12. 对 Agent 的直接收益 - -- 用户说“我要报销”时,Agent 能先创建 `expense_claims` 草稿,再持续补槽。 -- 用户上传票据后,系统有明确的 `document_assets` 与 `expense_item_documents` 可挂载。 -- OCR 和验真结果不是一次性临时输出,而是可追溯、可回放、可审计的长期资产。 -- Agent 回答“为什么被拦截”时,可以直接引用 `risk_events` 和票据证据,不再靠拼字符串解释。 diff --git a/document/development/agent/agent plan/15_feedback_learning_loop.md b/document/development/agent/agent plan/15_feedback_learning_loop.md deleted file mode 100644 index 471aa43..0000000 --- a/document/development/agent/agent plan/15_feedback_learning_loop.md +++ /dev/null @@ -1,119 +0,0 @@ -# 反馈闭环与持续学习 - -## 1. 定位 - -Agent 系统必须能从人工反馈中持续变好。 - -反馈来源: - -- OCR 人工修正。 -- 规则误报/漏报。 -- 审批人修改意见。 -- 用户对回答的反馈。 -- Hermes 风险复盘。 -- MCP 调用失败和降级。 - -## 2. 反馈类型 - -```text -ocr_correction -rule_false_positive -rule_false_negative -agent_answer_feedback -approval_opinion_edit -knowledge_answer_feedback -mcp_failure_feedback -task_result_feedback -``` - -## 3. 反馈结构 - -```json -{ - "feedback_id": "", - "feedback_type": "rule_false_positive", - "source_object_type": "rule_run", - "source_object_id": "", - "before": {}, - "after": {}, - "comment": "", - "created_by": "", - "created_at": "" -} -``` - -## 4. 反馈流向 - -```text -人工反馈 - ↓ -反馈池 - ↓ -Hermes 聚类分析 - ↓ -候选改进项 - ↓ -人工审核 - ↓ -更新规则 / 知识 / OCR 映射 / Prompt -``` - -## 5. 反馈不直接自动生效 - -反馈只能生成候选,不直接修改线上规则。 - -必须人工审核: - -- 规则修改。 -- 知识发布。 -- Prompt 修改。 -- OCR 字段映射调整。 - -## 6. Hermes 每日反馈复盘 - -Hermes 每日任务: - -```text -读取昨日反馈 -聚类相似问题 -统计误报高发规则 -统计低评分回答 -生成优化候选 -``` - -输出: - -```text -rule_improvement_candidates -knowledge_update_candidates -ocr_mapping_candidates -prompt_improvement_notes -``` - -## 7. 质量指标 - -建议监控: - -```text -ocr_correction_rate -rule_false_positive_rate -rule_false_negative_rate -agent_answer_like_rate -agent_answer_rewrite_rate -knowledge_no_hit_rate -mcp_failure_rate -``` - -## 8. 开发阶段建议 - -```text -Step 1: 增加反馈按钮和反馈表 -Step 2: OCR 修正写入反馈池 -Step 3: 规则误报/漏报反馈 -Step 4: Agent 回答反馈 -Step 5: Hermes 每日反馈聚类 -Step 6: 生成优化候选 -Step 7: 人工审核发布 -Step 8: 建立质量看板 -``` - diff --git a/document/development/agent/agent week plan/00_README.md b/document/development/agent/agent week plan/00_README.md deleted file mode 100644 index 8099ff4..0000000 --- a/document/development/agent/agent week plan/00_README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Agent Week Plan 一周开发路线图 - -本目录现在同时承接: - -- 一周路线图 -- 每天 daily 文档 -- 每天的详细执行清单 - -原独立执行细则目录已合并进各 Day 文档,不再单独维护。 - -## 文档分工 - -| 目录 | 职责 | 读者 | -| --- | --- | --- | -| `agent week plan` | 一周节奏、每天目标、验收门槛、详细执行清单、阻塞记录、日终交接 | 产品、架构、Codex、开发、验收 | -| `agent plan` | 架构设计、协议、流程、治理、标准模型、能力边界 | 架构、开发、评审 | - -## 使用方式 - -1. 先读 [MASTER_TODO.md](./MASTER_TODO.md),确认 7 天节奏和当前状态。 -2. 打开当天 daily 文档。 -3. 在同一份 daily 文档里按顺序阅读: - 今天的大开发点 -> 当前完成情况 -> 当天验收门槛 -> 详细执行清单 -> 阻塞记录 -> 日终交接。 -4. 如需设计依据,再跳到 `agent plan` 对应架构文档。 -5. 完成一个最小项后,再把该项改成完成态,而不是代码写完就直接算过。 - -## 完成标记规则 - -未完成: - -```md -- [ ] 建立 AgentAsset 数据模型 -``` - -完成后: - -```md -- [x] ~~建立 AgentAsset 数据模型~~ -``` - -执行要求: - -- [ ] 每次只处理一个最小 TODO。 -- [ ] 完成后先自测,再改成 `[x]`。 -- [ ] 改成 `[x]` 时,同时用 `~~` 画线。 -- [ ] 不能因为代码写完就标完成,必须满足该 TODO 的验收证据。 -- [ ] 遇到阻塞时,在当天文档的“阻塞记录”下新增说明。 -- [ ] 每天收尾时更新当天文档的“日终交接”。 - -## 一周总体目标 - -- Day 1:先把资产、版本、审核、运行日志、审计日志等基础地基建好。 -- Day 2:把任务规则中心和后端资产体系打通。 -- Day 3:建立语义本体 MVP,让用户问题能变成稳定结构。 -- Day 4:建立 Orchestrator,让请求能被统一路由、审计、降级。 -- Day 5:建立 User Agent MVP,处理用户查询、解释和草稿生成。 -- Day 6:建立 Hermes MVP,处理定时巡检、统计、知识和规则草稿。 -- Day 7:做加固、测试、演示、验收和下一阶段交接。 - -## 一周暂不完成 - -- 完整 OCR 生产识别引擎。 -- 完整发票验真 MCP 深度接入。 -- 完整 LLM Wiki 向量检索。 -- 全量财务域数据打通。 -- 规则自动上线。 -- 完整 CI/CD 质量门禁。 - -## 生产底线 - -- 所有写操作必须有审计日志。 -- 所有 Agent 执行必须生成 `run_id`。 -- 所有规则必须有版本。 -- 未审核规则不能上线。 -- 高风险动作只能生成草稿或建议,不能自动提交。 -- 外部能力失败必须有降级结果。 -- 语义解析结果必须可回放。 diff --git a/document/development/agent/agent week plan/MASTER_TODO.md b/document/development/agent/agent week plan/MASTER_TODO.md deleted file mode 100644 index cbdc81b..0000000 --- a/document/development/agent/agent week plan/MASTER_TODO.md +++ /dev/null @@ -1,73 +0,0 @@ -# Agent Week Plan 总控 - -本文件是本周总览和执行索引。 - -每个 Day 文档现在同时包含: - -- 路线图 -- 当前完成情况 -- 验收门槛 -- 详细执行清单 - -不再跳转独立执行细则目录。 - -## 快速浏览 - -- HTML 总览:[agent_week_plan_html/index.html](<../agent_week_plan_html/index.html>) -- Day 1 HTML:[agent_week_plan_html/day-1.html](<../agent_week_plan_html/day-1.html>) - -## 执行方式 - -1. 先看本文件,确认今天做哪一天、当前状态和依赖顺序。 -2. 再打开当天 daily 文档,直接在同一份文档里推进开发。 -3. 完成一个最小 TODO 后,再改成 `[x] ~~...~~`。 -4. 每天结束时回填阻塞记录、验收结果和日终交接。 - -## 一周节奏 - -| Day | 状态 | 主题 | 主要交付 | Markdown | HTML | -| --- | --- | --- | --- | --- | --- | -| Day 1 | 已完成(2026-05-11) | 基础模型与工程骨架 | 资产、版本、审核、运行日志、审计日志、基础 API、最小财务数据源 | [Day 1](./day_1_foundation_models.md) | [HTML](<../agent_week_plan_html/day-1.html>) | -| Day 2 | 已完成,待补浏览器走查记录 | 任务规则中心联调 | 规则/技能/MCP/任务列表与详情、Markdown、版本、审核 | [Day 2](./day_2_rule_center_integration.md) | [HTML](<../agent_week_plan_html/day-2.html>) | -| Day 3 | 已完成主体功能,待补评测样本扩充 | 语义本体 MVP | 8 字段语义解析、日志、评测入口、OCR 摘要与最小会话上下文带入 | [Day 3](./day_3_semantic_ontology_mvp.md) | [HTML](<../agent_week_plan_html/day-3.html>) | -| Day 4 | 已完成主干与会话串联,待接通提交/附件持久化链路 | Orchestrator 运行时 | 统一入口、路由、权限、工具调用、报销单写入路由、会话 Trace | [Day 4](./day_4_orchestrator_runtime.md) | [HTML](<../agent_week_plan_html/day-4.html>) | -| Day 5 | 已完成问答主链路、草稿创建/补全与会话上下文,待接通提交状态流转 | User Agent MVP | 用户问答、报销单草稿创建/补全/提交、财务查询、规则解释、附件/OCR 带入 | [Day 5](./day_5_user_agent_mvp.md) | [HTML](<../agent_week_plan_html/day-5.html>) | -| Day 6 | 未开始 | Hermes MVP | 定时任务、风险巡检、日报、知识候选、规则草稿 | [Day 6](./day_6_hermes_mvp.md) | [HTML](<../agent_week_plan_html/day-6.html>) | -| Day 7 | 未开始 | 加固、演示和验收 | 回归、测试、演示脚本、交付说明 | [Day 7](./day_7_hardening_demo_acceptance.md) | [HTML](<../agent_week_plan_html/day-7.html>) | - -## 当前完成情况 - -- Day 1 已完成,后端基础模型、审计和最小财务数据源已可供后续能力复用。 -- Day 2 已完成主要前后端联调,当前仅剩浏览器人工走查记录待补。 -- Day 3 主体已完成,`/api/v1/ontology/parse`、8 字段返回、缺槽位追问、权限判断和前端调试入口均已落地;OCR 摘要、附件上下文和最小会话历史已进入语义层,前端浏览器时间上下文也已接入相对时间换算,当前主要剩叙述型报销、附件/OCR 带入样本和模糊追问样本继续扩充。 -- Day 4 主干已完成,Orchestrator 已具备统一入口、User Agent / Hermes 路由、权限阻断、ToolCall 记录、Trace、降级和 `conversation_id` 会话串联;`expense_claims` 草稿建单/改单与 ToolCall / Audit 已接通,但提交、附件持久化和更细的 ToolCall Trace 仍未接通。 -- Day 5 问答主链路已完成,个人工作台和报销对话框已能把文本、附件名称、OCR 摘要、页面上下文和会话 ID 带入 Orchestrator,并返回回答、规则引用、风险说明、结构化草稿和识别核对面板;核对 UI 已调整为“右侧只看识别结果、主对话负责待补与风险、底部负责动作”,但附件 / OCR 结果落库及 `draft -> submitted` 仍未完成。 - -## Day 1 - Day 5 未完成补齐清单 - -- Day 1:当前周计划范围内无新增遗留项,基础资产、日志、审计和最小财务表已完成;文件资产、OCR 结果表和风险事件表作为 Day 5 真落库前置底座,设计已完成但代码未落地。 -- Day 2:仍缺一轮浏览器人工走查记录,需补充规则中心真实页面联调截图或缺陷清单。 -- Day 3:仍需补充叙述型报销长句样本、附件/OCR 摘要带入样本、模糊短句追问样本,并把这些样本纳入自动评测。 -- Day 4:仍需接通 `submit_expense_claim` 真服务,补齐附件挂接服务注册、ToolCall 更细粒度记录和前端 Trace 展示。 -- Day 5:仍需把附件和 OCR 识别结果真正落到 `document_assets`、`document_asset_versions`、`expense_item_documents`、`document_ocr_results`,并完成 `draft -> submitted` 状态流转、前端确认动作回写和提交流程确认。 - -## 关键依赖顺序 - -1. Day 1 必须先完成,因为后面所有能力都依赖资产、版本、审核、日志。 -2. Day 2 必须在 Day 3 前完成,因为语义和 Agent 需要读取规则、技能、MCP、任务资产。 -3. Day 3 必须在 Day 4 前完成,因为 Orchestrator 依赖语义本体做路由。 -4. Day 4 必须在 Day 5 / Day 6 前完成,因为 User Agent 和 Hermes 都应该由 Orchestrator 调用。 -5. Day 5 和 Day 6 可以部分并行,但都必须遵守权限、审计、Trace。 -6. Day 7 不新增大功能,只做加固、验收和交接。 - -## 最终验收 - -- 任务规则中心能看到规则、技能、MCP、任务。 -- 规则详情能编辑 Markdown、查看最近 5 个版本、切换版本。 -- 未审核规则不能上线。 -- 用户问题能解析出语义本体 8 字段。 -- Orchestrator 能路由到 User Agent 和 Hermes。 -- User Agent 能完成查询、解释、报销单草稿创建、字段补全和提交前确认。 -- Hermes 能执行一次风险巡检或日报任务。 -- AgentRun、ToolCall、AuditLog 都能追溯。 -- 有演示脚本和下一阶段交接文档。 diff --git a/document/development/agent/agent week plan/day_1_foundation_models.md b/document/development/agent/agent week plan/day_1_foundation_models.md deleted file mode 100644 index 6d932de..0000000 --- a/document/development/agent/agent week plan/day_1_foundation_models.md +++ /dev/null @@ -1,221 +0,0 @@ -# Day 1:基础模型与工程骨架 - -## 当前状态 - -- [x] ~~Day 1 已完成(2026-05-11)。~~ -- [x] ~~后端基础模型、API 骨架、种子数据、审计能力和 Day 2 联调入口均已落地。~~ - -## 今天的大开发点 - -Day 1 只做地基,不做复杂 Agent 智能。 - -核心是把后面 6 天都会用到的基础对象建出来:资产、版本、审核、运行日志、工具调用日志、语义解析日志、审计日志,以及最小财务业务数据来源。 - -## 为什么第一天做这个 - -如果没有稳定的数据模型,后面的任务规则中心、语义本体、Orchestrator、User Agent、Hermes 都会各自临时造结构,后期会很难合并。 - -## 今天主要交付 - -- [x] ~~统一资产模型:规则、技能、MCP、任务。~~ -- [x] ~~版本模型:规则 Markdown 和其他资产配置快照。~~ -- [x] ~~审核模型:未审核不能上线。~~ -- [x] ~~Agent 运行日志:所有 Agent 执行都有 `run_id`。~~ -- [x] ~~工具调用日志:MCP、数据库、LLM、OCR、规则引擎调用都可追踪。~~ -- [x] ~~语义解析日志:后续语义本体结果可回放。~~ -- [x] ~~审计日志:所有写操作可追责。~~ -- [x] ~~最小财务业务数据来源:报销、应收、应付。~~ - -## 实际落地结果 - -- [x] ~~新增 `AgentAsset`、`AgentAssetVersion`、`AgentAssetReview`、`AgentRun`、`AgentToolCall`、`SemanticParseLog`、`AuditLog`、`ExpenseClaim`、`ExpenseClaimItem`、`AccountsReceivableRecord`、`AccountsPayableRecord`。~~ -- [x] ~~新增 `/api/v1/agent-assets`、`/api/v1/agent-runs`、`/api/v1/audit-logs` 相关接口。~~ -- [x] ~~种子数据已覆盖 3 条规则、2 条技能、2 条 MCP、3 条任务,以及报销 / 应收 / 应付示例数据。~~ -- [x] ~~旧开发库启动时会自动补齐新增资产和版本,不需要手动清库。~~ - -相关架构文档: - -- [整体架构](<../agent plan/01_overall_architecture.md>) -- [语义本体](<../agent plan/02_semantic_ontology.md>) -- [数据契约与治理](<../agent plan/06_data_contracts_and_governance.md>) -- [能力注册](<../agent plan/07_capability_registry.md>) -- [权限与确认](<../agent plan/08_permission_confirmation.md>) -- [观测与 Trace](<../agent plan/09_observability_and_trace.md>) -- [财务单据标准模型](<../agent plan/14_financial_document_canonical_model.md>) - -## 当天验收门槛 - -- [x] ~~数据库或等价存储能创建基础对象。~~ -- [x] ~~API 服务能启动。~~ -- [x] ~~资产列表能返回规则、技能、MCP、任务。~~ -- [x] ~~规则资产能关联 Markdown 当前版本。~~ -- [x] ~~未审核规则不能上线。~~ -- [x] ~~AgentRun 能保存一条运行记录。~~ -- [x] ~~AuditLog 能保存一条写操作记录。~~ - -## Day 2 联调入口 - -- `GET /api/v1/agent-assets` -- `GET /api/v1/agent-assets/{asset_id}` -- `GET /api/v1/agent-assets/{asset_id}/versions?limit=5` -- `POST /api/v1/agent-assets/{asset_id}/reviews` -- `POST /api/v1/agent-assets/{asset_id}/activate` -- `GET /api/v1/audit-logs` - -## 今天不做 - -- 不做完整 Agent 对话。 -- 不做完整 Hermes 调度。 -- 不做真实 OCR。 -- 不做复杂规则推理。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 0. 开始前检查 - -- [x] ~~确认后端目录为 `/app/server`,模型、路由、启动入口和测试目录已定位。~~ -- [x] ~~确认本次改动以增量方式落到现有 FastAPI + SQLAlchemy 工程,不回退无关文件。~~ - -验收证据: - -- [x] ~~模型注册位于 `server/src/app/db/base.py`,路由注册位于 `server/src/app/api/v1/router.py`,启动入口位于 `server/src/app/main.py`,测试位于 `server/tests`。~~ - -## 1. 统一命名和边界 - -- [x] ~~统一枚举:`rule | skill | mcp | task`、`draft | review | active | disabled`、`pending | approved | rejected`、`orchestrator | user_agent | hermes`。~~ -- [x] ~~统一运行来源、权限级别、内容类型、运行状态和工具类型命名,避免出现第二套并行语义。~~ - -验收证据: - -- [x] ~~`server/src/app/core/agent_enums.py` 已成为模型、Schema 和服务层的统一枚举入口。~~ - -## 2. 设计最小财务业务数据模型 - -- [x] ~~建立 `expense_claims`、`expense_claim_items`、`accounts_receivable`、`accounts_payable`。~~ -- [x] ~~字段覆盖时间、地点、理由、金额、员工、部门、状态,以及应收 / 应付的金额、到期日、账龄、风险标记。~~ - -验收证据: - -- [x] ~~`server/src/app/models/financial_record.py` 与 `document/development/agent plan/14_financial_document_canonical_model.md` 形成直接映射。~~ - -## 3. 建立 AgentAsset 模型 - -- [x] ~~建立 `AgentAsset`,包含 `asset_type`、`code`、`name`、`description`、`domain`、`scenario_json`、`owner`、`reviewer`、`status`、`current_version`、`config_json` 等核心字段。~~ -- [x] ~~对 `code`、`asset_type`、`status`、`domain` 建立唯一约束或索引。~~ - -验收证据: - -- [x] ~~资产列表可按 `rule`、`skill`、`mcp`、`task` 四类过滤返回。~~ - -## 4. 建立 AgentAssetVersion 模型 - -- [x] ~~建立 `AgentAssetVersion`,规则版本保存 Markdown,其余资产版本保存 JSON 快照。~~ -- [x] ~~对 `asset_id + version` 建立唯一约束,并支持按资产读取最近版本列表。~~ - -验收证据: - -- [x] ~~规则详情接口可返回 `current_version_content` 和 `recent_versions`。~~ - -## 5. 建立 AgentAssetReview 模型 - -- [x] ~~建立 `AgentAssetReview`,保存版本、审核人、审核状态、审核备注和审核时间。~~ -- [x] ~~服务层实现规则版本未 `approved` 时禁止上线。~~ - -验收证据: - -- [x] ~~`POST /api/v1/agent-assets/{asset_id}/activate` 对待审规则返回 400 拦截。~~ - -## 6. 建立 AgentRun 模型 - -- [x] ~~建立 `AgentRun`,包含 `run_id`、`agent`、`source`、`ontology_json`、`route_json`、`permission_level`、`status`、`result_summary`、`error_message` 等字段。~~ -- [x] ~~所有运行记录统一生成 `run_id`,并允许失败态保存错误信息。~~ - -验收证据: - -- [x] ~~`AgentRunService.create_run()` 会自动生成 `run_` 前缀标识,并可回读失败摘要。~~ - -## 7. 建立 AgentToolCall 模型 - -- [x] ~~建立 `AgentToolCall`,可记录工具类型、工具名、请求 / 响应 JSON、耗时和错误信息。~~ -- [x] ~~同一个 `run_id` 下支持多次工具调用追踪。~~ - -验收证据: - -- [x] ~~种子运行数据已覆盖数据库查询、MCP 调用和权限规则引擎调用。~~ - -## 8. 建立 SemanticParseLog 模型 - -- [x] ~~建立 `SemanticParseLog`,覆盖场景、意图、实体、时间范围、指标、约束、风险、权限和置信度。~~ -- [x] ~~支持按 `run_id` 回放 Day 3 语义结果。~~ - -验收证据: - -- [x] ~~`GET /api/v1/agent-runs/{run_id}` 已能携带 `semantic_parse` 返回。~~ - -## 9. 建立 AuditLog 模型 - -- [x] ~~建立 `AuditLog` 和统一 `AuditLogService`。~~ -- [x] ~~资产创建、版本保存、审核、上线等写操作都会留下审计记录。~~ - -验收证据: - -- [x] ~~`GET /api/v1/audit-logs` 可返回种子审计日志,服务层新建资产也会落审计。~~ - -## 10. 建立 Schema / DTO - -- [x] ~~建立 `AgentAssetCreate / Update / Read / ListItem`、`AgentAssetVersionRead`、`AgentAssetReviewRead`、`RuleMarkdownUpdate`、`AgentRunRead`、`AgentToolCallRead`、`SemanticParseRead`。~~ -- [x] ~~所有 JSON 字段以结构化对象返回,不回传字符串化 JSON。~~ - -验收证据: - -- [x] ~~列表 DTO 不返回大块 Markdown,详情 DTO 返回当前版本正文和最近版本。~~ - -## 11. 建立 API 骨架 - -- [x] ~~建立 `GET/POST/PATCH /api/v1/agent-assets`、`GET /api/v1/agent-assets/{asset_id}`、`GET/POST /api/v1/agent-assets/{asset_id}/versions`、`POST /api/v1/agent-assets/{asset_id}/reviews`、`POST /api/v1/agent-assets/{asset_id}/activate`。~~ -- [x] ~~建立 `GET /api/v1/agent-runs`、`GET /api/v1/agent-runs/{run_id}`、`GET /api/v1/audit-logs`。~~ - -验收证据: - -- [x] ~~所有接口已挂到 `server/src/app/api/v1/router.py`,并通过 `create_app()` 自动暴露。~~ - -## 12. 建立种子数据 - -- [x] ~~种子资产补齐到 3 条规则、2 条技能、2 条 MCP、3 条任务。~~ -- [x] ~~三条规则都具备至少 2 个版本,并覆盖 `approved / pending / rejected` 三种审核样本。~~ -- [x] ~~旧开发数据库启动时会自动增量补齐新增资产和版本,不要求手动清库。~~ - -验收证据: - -- [x] ~~Smoke:`GET /api/v1/agent-assets` 返回 10 条资产,`GET /api/v1/agent-runs` 返回 3 条运行日志,`GET /api/v1/audit-logs` 返回 4 条审计日志。~~ - -## 13. 最小测试 - -- [x] ~~新增 Day 1 服务层与接口层测试,覆盖种子完整性、版本历史、未审核不能上线、运行日志生成和审计日志写入。~~ -- [x] ~~Ruff 校验通过,Day 1 新增文件保持可检查状态。~~ - -验收证据: - -- [x] ~~`/app/server/.venv/bin/pytest -q /app/server/tests/test_agent_asset_service.py /app/server/tests/test_agent_foundation_endpoints.py` -> `11 passed`。~~ -- [x] ~~`/app/server/.venv/bin/pytest -q tests` 已通过全量后端测试。~~ - -## 14. Day 1 验收 - -- [x] ~~数据库能创建所有新增表或等价结构。~~ -- [x] ~~API 服务能启动,OpenAPI 能看到新增接口。~~ -- [x] ~~资产列表接口返回规则、技能、MCP、任务;规则详情带 Markdown 当前版本和最近版本列表。~~ -- [x] ~~未审核规则不能上线;AgentRun 和 AuditLog 均可保存记录。~~ -- [x] ~~所有 Day 1 TODO 已改为完成态。~~ - -## 阻塞记录 - -- [x] ~~暂无阻塞。~~ - -## 日终交接 - -- [x] ~~已完成模型:资产、版本、审核、运行日志、工具调用、语义解析、审计、报销、应收、应付。~~ -- [x] ~~已完成 API:`/api/v1/agent-assets`、`/api/v1/agent-runs`、`/api/v1/audit-logs`。~~ -- [x] ~~Day 2 前端联调应优先使用 `GET /api/v1/agent-assets`、`GET /api/v1/agent-assets/{asset_id}`、`GET /api/v1/agent-assets/{asset_id}/versions?limit=5`、`POST /api/v1/agent-assets/{asset_id}/reviews`、`POST /api/v1/agent-assets/{asset_id}/activate`。~~ -- [x] ~~后续 Day 4 及以后运行时方向按用户要求转向 `LangChain + LangGraph`,Hermes 继续作为内部数字员工入口;Day 1 保留为数据与治理底座。~~ diff --git a/document/development/agent/agent week plan/day_2_rule_center_integration.md b/document/development/agent/agent week plan/day_2_rule_center_integration.md deleted file mode 100644 index 5ba8c66..0000000 --- a/document/development/agent/agent week plan/day_2_rule_center_integration.md +++ /dev/null @@ -1,296 +0,0 @@ -# Day 2:任务规则中心联调 - -## 今天的大开发点 - -把任务规则中心从静态页面改成可和后端资产体系联动的生产形态。 - -重点是规则、技能、MCP、任务四类资产的列表和详情,以及规则 Markdown、版本、审核、上线约束。 - -## 为什么第二天做这个 - -任务规则中心是业务人员管理 Agent 能力的入口。后续语义本体、Orchestrator、User Agent、Hermes 都要读取这里注册的规则、技能、MCP 和任务。 - -## 今天主要交付 - -- 规则、技能、MCP、任务四个页签对接资产 API。 -- 列表支持搜索、筛选、状态展示。 -- 规则详情展示 Markdown 内容。 -- 管理员可编辑规则 Markdown。 -- 规则版本展示最近 5 个版本。 -- 版本切换需要弹窗确认。 -- 审核者信息放在标题区域。 -- 右侧只保留版本信息。 -- 未审核规则上线时被后端拦截。 - -## 当前完成情况 - -- [x] ~~四个页签已切到真实资产 API。~~ -- [x] ~~规则 Markdown、版本切换、审核、上线动作已联调。~~ -- [x] ~~前端构建已通过。~~ -- [ ] 浏览器手动走查记录待补。 - -相关架构文档: - -- [能力注册](<../agent plan/07_capability_registry.md>) -- [规则形成生命周期](<../agent plan/13_rule_formation_lifecycle.md>) -- [数据契约与治理](<../agent plan/06_data_contracts_and_governance.md>) - -## 当天验收门槛 - -- 四个页签可切换并有真实 API 或 Mock API 数据。 -- 规则详情可编辑 Markdown。 -- Markdown 保存后刷新不丢失。 -- 版本卡片可切换版本。 -- 未审核规则不能上线。 -- 前端构建通过。 - -## 今天不做 - -- 不做规则自动生成。 -- 不做完整 MCP 真实调用。 -- 不做复杂权限矩阵。 -- 不重做 UI 风格,只在现有风格上微调。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 0. 开始前检查 - -- [x] ~~确认 Day 1 API 已可访问。~~ -- [x] ~~确认前端任务规则中心文件位置。~~ -- [x] ~~确认现有路由名称和导航名称。~~ -- [x] ~~确认现有 UI 风格,不重新做大改版。~~ -- [x] ~~确认当前页面已有页签:规则、技能、MCP、任务。~~ -- [x] ~~确认详情页隐藏顶部 title bar 的逻辑仍然有效。~~ -- [x] ~~确认返回列表栏高度没有被重新拉高。~~ - -## 1. API Client - -- [x] ~~新增或扩展资产列表请求函数。~~ -- [x] ~~新增资产详情请求函数。~~ -- [x] ~~新增版本列表请求函数。~~ -- [x] ~~新增规则 Markdown 保存请求函数。~~ -- [x] ~~新增审核请求函数。~~ -- [x] ~~新增上线请求函数。~~ -- [x] ~~新增运行日志请求函数。~~ -- [x] ~~给所有请求增加加载态。~~ -- [x] ~~给所有请求增加错误态。~~ -- [x] ~~给所有写请求增加成功提示。~~ - -验收证据: - -- [x] ~~前端不再只依赖本地硬编码资产数据。~~ -- [x] ~~后端不可用时页面有明确错误提示。~~ - -## 2. 列表页数据接入 - -- [x] ~~规则页签请求 `asset_type=rule`。~~ -- [x] ~~技能页签请求 `asset_type=skill`。~~ -- [x] ~~MCP 页签请求 `asset_type=mcp`。~~ -- [x] ~~任务页签请求 `asset_type=task`。~~ -- [x] ~~搜索框传递关键词或本地过滤。~~ -- [x] ~~类型下拉和搜索框可以同时生效。~~ -- [x] ~~状态筛选可以过滤 `draft | review | active | disabled`。~~ -- [x] ~~列表卡片展示名称。~~ -- [x] ~~列表卡片展示摘要。~~ -- [x] ~~列表卡片展示状态。~~ -- [x] ~~列表卡片展示负责人。~~ -- [x] ~~列表卡片展示最近更新时间。~~ -- [x] ~~空数据时展示空态。~~ -- [x] ~~加载中时展示骨架或加载状态。~~ - -验收证据: - -- [x] ~~四个页签都能切换。~~ -- [x] ~~四个页签都有数据或空态。~~ -- [x] ~~搜索和筛选不会互相覆盖。~~ - -## 3. 规则详情页主信息 - -- [x] ~~打开规则资产时请求详情 API。~~ -- [x] ~~Hero title 展示规则名称。~~ -- [x] ~~Hero title 下方展示审核者。~~ -- [x] ~~Hero title 下方展示审核状态。~~ -- [x] ~~Hero title 下方展示上线条件。~~ -- [x] ~~Hero title 高度保持紧凑。~~ -- [x] ~~详情页不显示外层顶部 title bar。~~ -- [x] ~~返回列表栏高度保持原有紧凑高度。~~ - -验收证据: - -- [x] ~~用户能一眼看到该规则是否已审核。~~ -- [x] ~~用户不会看到两层 title。~~ - -## 4. Markdown 编辑器 - -- [x] ~~从当前版本读取 Markdown 内容。~~ -- [x] ~~Markdown 编辑框高度和右侧版本卡片底部对齐。~~ -- [x] ~~Markdown 编辑框支持长内容滚动。~~ -- [x] ~~Markdown 编辑框保存时调用 API。~~ -- [x] ~~保存后创建新版本或更新草稿版本,按后端约定执行。~~ -- [x] ~~保存成功后刷新版本列表。~~ -- [x] ~~保存失败时保留用户输入。~~ -- [x] ~~编辑器禁用态覆盖 `active` 且无编辑权限的情况。~~ -- [x] ~~编辑器底部展示最后保存时间。~~ - -验收证据: - -- [x] ~~编辑 Markdown 后刷新页面内容仍存在。~~ -- [x] ~~保存失败不会丢内容。~~ -- [x] ~~左右卡片底部视觉对齐。~~ - -## 5. 版本卡片 - -- [x] ~~右侧只保留版本信息卡片。~~ -- [x] ~~版本卡片宽度足够展示版本号、日期、状态。~~ -- [x] ~~展示最近 5 个版本。~~ -- [x] ~~当前版本有明显但不突兀的标识。~~ -- [x] ~~当前版本标识居中显示。~~ -- [x] ~~选中状态只变色,不改变内容对齐。~~ -- [x] ~~日期列和其他版本日期对齐。~~ -- [x] ~~点击非当前版本时弹出确认弹窗。~~ -- [x] ~~弹窗展示目标版本号。~~ -- [x] ~~弹窗展示切换风险提示。~~ -- [x] ~~确认后切换当前展示内容。~~ -- [x] ~~取消后不改变当前版本。~~ - -验收证据: - -- [x] ~~版本切换不会造成列表文字位移。~~ -- [x] ~~当前版本背景能完全覆盖内容区域。~~ -- [x] ~~版本卡片不贴右侧边界。~~ - -## 6. 审核与上线 - -- [x] ~~详情中展示审核者姓名。~~ -- [x] ~~详情中展示审核时间。~~ -- [x] ~~详情中展示审核意见。~~ -- [x] ~~未审核规则显示不能上线原因。~~ -- [x] ~~点击上线时调用后端上线接口。~~ -- [x] ~~后端拒绝时展示拒绝原因。~~ -- [x] ~~审核通过后上线按钮可用。~~ -- [x] ~~审核动作写入审计日志。~~ -- [x] ~~上线动作写入审计日志。~~ - -验收证据: - -- [x] ~~pending 规则无法上线。~~ -- [x] ~~approved 规则可以上线。~~ -- [x] ~~rejected 规则无法上线。~~ - -## 7. 技能详情 - -- [x] ~~技能页签列表展示能力名称。~~ -- [x] ~~技能详情展示能力说明。~~ -- [x] ~~技能详情展示输入参数。~~ -- [x] ~~技能详情展示输出参数。~~ -- [x] ~~技能详情展示依赖能力。~~ -- [x] ~~技能详情展示适用场景。~~ -- [x] ~~技能详情展示负责人。~~ -- [x] ~~技能详情展示版本。~~ -- [x] ~~技能详情不使用规则 Markdown 编辑器。~~ - -验收证据: - -- [x] ~~技能和规则详情不会混用 UI。~~ - -## 8. MCP 详情 - -- [x] ~~MCP 页签列表展示外部服务名称。~~ -- [x] ~~MCP 详情展示服务类型。~~ -- [x] ~~MCP 详情展示调用地址或能力名。~~ -- [x] ~~MCP 详情展示鉴权方式。~~ -- [x] ~~MCP 详情展示超时配置。~~ -- [x] ~~MCP 详情展示降级策略。~~ -- [x] ~~MCP 详情展示最近调用状态。~~ -- [x] ~~MCP 详情展示负责人。~~ - -验收证据: - -- [x] ~~MCP 被定义为外部服务,而不是技能规则。~~ - -## 9. 任务详情 - -- [x] ~~任务页签展示定时任务名称。~~ -- [x] ~~任务详情展示 cron 或调度周期。~~ -- [x] ~~任务详情展示执行 Agent,默认 Hermes。~~ -- [x] ~~任务详情展示任务目标。~~ -- [x] ~~任务详情展示风险等级。~~ -- [x] ~~任务详情展示最近执行时间。~~ -- [x] ~~任务详情展示最近执行结果。~~ -- [x] ~~任务详情展示启停状态。~~ - -验收证据: - -- [x] ~~定时任务用户可见名称为“任务”。~~ -- [x] ~~技术字段可保留 `schedule`,但 UI 不显示“定时任务”。~~ - -## 10. 前端质量 - -- [x] ~~页面在 1366 宽度下无横向滚动。~~ -- [x] ~~页面在 1920 宽度下右侧卡片不过宽。~~ -- [x] ~~页面在窄屏下详情区域可滚动。~~ -- [x] ~~所有按钮有禁用态。~~ -- [x] ~~所有弹窗有取消按钮。~~ -- [x] ~~所有表单错误有提示。~~ -- [x] ~~所有日期格式统一。~~ -- [x] ~~状态颜色和现有系统一致。~~ - -验收证据: - -- [x] ~~`npm run build` 通过。~~ -- [ ] 任务规则中心手动走查通过。 - -## 11. Day 2 验收 - -- [x] ~~规则、技能、MCP、任务四个页签可用。~~ -- [x] ~~搜索框和筛选下拉可用。~~ -- [x] ~~规则详情展示 Markdown。~~ -- [x] ~~规则 Markdown 可保存。~~ -- [x] ~~右侧只保留版本信息。~~ -- [x] ~~版本可切换且有弹窗确认。~~ -- [x] ~~审核者信息在标题下方。~~ -- [x] ~~未审核规则不能上线。~~ -- [x] ~~前端构建通过。~~ -- [x] ~~所有完成项已按完成态标记。~~ - -## 阻塞记录 - -- [x] ~~暂无。~~ - -## 日终交接 - -- [x] ~~写明已接入的 API。~~ -- [x] ~~写明仍然使用 Mock 的字段。~~ -- [x] ~~写明 UI 未完成项。~~ -- [x] ~~写明 Day 3 语义本体需要复用的资产数据。~~ - -已接入的 API: - -- `GET /api/v1/agent-assets?asset_type=rule|skill|mcp|task` -- `GET /api/v1/agent-assets/{asset_id}` -- `GET /api/v1/agent-assets/{asset_id}/versions` -- `POST /api/v1/agent-assets/{asset_id}/versions` -- `POST /api/v1/agent-assets/{asset_id}/reviews` -- `POST /api/v1/agent-assets/{asset_id}/activate` -- `GET /api/v1/agent-runs` - -仍然使用 Mock / 种子数据的字段: - -- MCP 服务地址仍是 `mock://...` 种子地址,用于占位联调。 -- MCP 最近调用状态、任务最近执行结果来自 Day 1 注入的 `AgentRun` 种子数据。 -- 技能、MCP、任务详情仍以只读方式展示,未开放编辑表单。 - -UI 未完成项: - -- 未做浏览器内人工走查记录,当前仅完成构建验证与代码层联调。 -- 技能、MCP、任务的编辑能力仍留待后续 Day 3 / Day 4 之后按权限开放。 - -Day 3 语义本体需要复用的资产数据: - -- 资产主键与编码:`id`、`code`、`asset_type` -- 业务归类:`domain`、`scenario_json` -- 当前生效版本:`current_version`、`current_version_content`、`current_version_content_type` -- 治理状态:`status`、`latest_review`、`recent_versions` -- 运行关联:`config_json.agent`、`config_json.cron`、`AgentRun.task_id`、`tool_calls` diff --git a/document/development/agent/agent week plan/day_3_semantic_ontology_mvp.md b/document/development/agent/agent week plan/day_3_semantic_ontology_mvp.md deleted file mode 100644 index bb48609..0000000 --- a/document/development/agent/agent week plan/day_3_semantic_ontology_mvp.md +++ /dev/null @@ -1,304 +0,0 @@ -# Day 3:语义本体 MVP - -## 今天的大开发点 - -建立模型优先的语义解析层,把自然语言问题转换成统一的 8 个核心字段。 - -这一天的目标不是继续堆关键词,而是先把真实模型接入语义层,让报销、应收、应付、知识和风险相关问题进入稳定结构,再由规则做兜底和校验。 - -## 为什么第三天做这个 - -Orchestrator 不能直接根据原始文本做可靠路由。它需要先拿到结构化语义,再决定调用 User Agent、Hermes、规则、MCP 或知识库。 - -## 今天主要交付 - -- 语义本体 8 字段结构。 -- 场景识别:报销、应收、应付、知识、未知。 -- 意图识别:查询、解释、对比、风险检查、草稿、操作。 -- 业务对象提取:员工、客户、供应商、部门、项目、单据、金额。 -- 时间范围解析。 -- 指标和约束解析。 -- 风险信号和权限级别判断。 -- LLM 结构化解析 Prompt。 -- Schema 校验与 JSON 清洗。 -- 规则回退解析。 -- 低置信度追问和缺槽位追问。 -- 语义解析 API。 -- 解析日志和最小评测集。 - -## 当前完成情况 - -- [x] ~~`/api/v1/ontology/parse` 已上线,8 字段语义结构、缺槽位、歧义、权限和澄清问题均可返回。~~ -- [x] ~~语义层已切到“模型优先 + 规则回退”,并把结果写入 `AgentRun` / `SemanticParseLog`。~~ -- [x] ~~附件名称、附件数量、OCR 摘要和 OCR 文档摘要已能作为上下文带入语义层。~~ -- [x] ~~最小会话历史、上一轮场景/意图和 `draft_claim_id` 已能作为上下文带入语义层,用于识别“改成 800”“继续补充”这类追问。~~ -- [x] ~~叙述型报销语义已补强:`客户 + 吃饭/请客/宴请/招待` 优先归类为业务招待费,不再误打到应收查询。~~ -- [x] ~~相对时间已支持标准化展示:前端会透传浏览器本地时间上下文,`今天 / 昨天 / 本月 / 4 月` 会换算成绝对日期;展示层默认优先显示绝对日期,原始表达仅作为辅助信息。~~ -- [x] ~~前端调试入口与核心评测测试已完成并通过。~~ -- [ ] 叙述型报销样本、附件/OCR 带入样本和模糊短句追问样本仍需继续扩充。 - -相关架构文档: - -- [语义本体](<../agent plan/02_semantic_ontology.md>) -- [财务单据标准模型](<../agent plan/14_financial_document_canonical_model.md>) -- [数据契约与治理](<../agent plan/06_data_contracts_and_governance.md>) - -## 当天验收门槛 - -- 输入自然语言问题能返回 8 个字段。 -- 模型解析失败时能自动回退到规则解析。 -- 低置信度问题能返回澄清问题。 -- 越权动作不会被标记为可直接执行。 -- 解析结果能写入日志。 -- 至少覆盖报销、应收、应付三个场景。 -- 叙述型报销输入不会被错误路由到应收或应付。 - -## 今天不做 - -- 不做复杂多轮对话记忆。 -- 不做完整 Agent 自主规划。 -- 不做自动执行业务流程。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 0. 开始前检查 - -- [x] ~~确认 Day 1 的 `SemanticParseLog` 可用。~~ -- [x] ~~确认 Day 1 的 `AgentRun` 可用。~~ -- [x] ~~确认 Day 2 的资产 API 可用。~~ -- [x] ~~找到后端服务层目录。~~ -- [x] ~~找到现有 LLM 调用或 Mock 调用方式。~~ -- [x] ~~确认当前是否允许真实调用 LLM。~~ -- [x] ~~确认当前运行时模型槽位可用于语义解析。~~ -- [x] ~~如果真实模型不可用,已准备规则解析回退路径。~~ - -## 1. 定义 8 个核心字段 - -- [x] ~~定义字段 `scenario`,表示业务场景。~~ -- [x] ~~定义字段 `intent`,表示用户意图。~~ -- [x] ~~定义字段 `entities`,表示业务对象。~~ -- [x] ~~定义字段 `time_range`,表示时间范围。~~ -- [x] ~~定义字段 `metrics`,表示指标或金额口径。~~ -- [x] ~~定义字段 `constraints`,表示过滤条件。~~ -- [x] ~~定义字段 `risk_flags`,表示风险信号。~~ -- [x] ~~定义字段 `permission`,表示动作权限。~~ -- [x] ~~为每个字段写清楚类型。~~ -- [x] ~~为每个字段写清楚是否必填。~~ -- [x] ~~为每个字段写清楚默认值。~~ -- [x] ~~为每个字段写清楚示例。~~ - -验收证据: - -- [x] ~~8 个字段在 Schema、服务层、日志中名字一致。~~ - -## 2. 设计字段枚举 - -- [x] ~~`scenario` 支持 `expense`。~~ -- [x] ~~`scenario` 支持 `accounts_receivable`。~~ -- [x] ~~`scenario` 支持 `accounts_payable`。~~ -- [x] ~~`scenario` 支持 `knowledge`。~~ -- [x] ~~`scenario` 支持 `unknown`。~~ -- [x] ~~`intent` 支持 `query`。~~ -- [x] ~~`intent` 支持 `explain`。~~ -- [x] ~~`intent` 支持 `compare`。~~ -- [x] ~~`intent` 支持 `risk_check`。~~ -- [x] ~~`intent` 支持 `draft`。~~ -- [x] ~~`intent` 支持 `operate`。~~ -- [x] ~~`permission.level` 支持 `read`。~~ -- [x] ~~`permission.level` 支持 `draft_write`。~~ -- [x] ~~`permission.level` 支持 `approval_required`。~~ -- [x] ~~`permission.level` 支持 `forbidden`。~~ - -验收证据: - -- [x] ~~未识别的问题不会抛异常,返回 `unknown`。~~ - -## 3. 建立 Schema - -- [x] ~~定义 `OntologyParseRequest`。~~ -- [x] ~~`OntologyParseRequest` 包含 `query`。~~ -- [x] ~~`OntologyParseRequest` 包含 `user_id`。~~ -- [x] ~~`OntologyParseRequest` 包含 `context_json`。~~ -- [x] ~~定义 `OntologyParseResult`。~~ -- [x] ~~`OntologyParseResult` 包含 8 个核心字段。~~ -- [x] ~~`OntologyParseResult` 包含 `confidence`。~~ -- [x] ~~`OntologyParseResult` 包含 `clarification_required`。~~ -- [x] ~~`OntologyParseResult` 包含 `clarification_question`。~~ -- [x] ~~`OntologyParseResult` 包含 `run_id`。~~ -- [x] ~~定义字段级错误结构。~~ - -验收证据: - -- [x] ~~OpenAPI 中可以看到语义解析请求和响应。~~ - -## 4. 实现解析服务 - -- [x] ~~新增 `SemanticOntologyService` 或同等服务。~~ -- [x] ~~实现 `parse(query, user_context)` 主函数。~~ -- [x] ~~增加上下文装配层,输入文本、页面上下文、附件摘要和预抽取字段。~~ -- [x] ~~实现模型优先的结构化语义解析。~~ -- [x] ~~约束模型只输出 JSON。~~ -- [x] ~~对模型输出做清洗、提取和 Schema 校验。~~ -- [x] ~~模型失败时自动回退到规则解析。~~ -- [x] ~~在结果中记录本次使用了 `llm_primary` 还是 `rule_fallback`。~~ -- [x] ~~报销关键词映射到 `expense`。~~ -- [x] ~~应收、回款、客户欠款映射到 `accounts_receivable`。~~ -- [x] ~~应付、供应商、付款映射到 `accounts_payable`。~~ -- [x] ~~风险、异常、重复、超标映射到 `risk_check`。~~ -- [x] ~~为什么、依据、规则映射到 `explain`。~~ -- [x] ~~统计、汇总、多少映射到 `query`。~~ -- [x] ~~生成、创建、发起映射到 `draft` 或 `operate`。~~ -- [x] ~~无法识别时返回低置信度和澄清问题。~~ -- [x] ~~叙述型报销输入优先识别为创建/草稿,而不是查询。~~ - -验收证据: - -- [x] ~~“查一下本周报销超标风险”能识别为 expense + risk_check。~~ -- [x] ~~“客户 A 这个月还有多少应收”能识别为 accounts_receivable + query。~~ -- [x] ~~“供应商 B 明天要付多少钱”能识别为 accounts_payable + query。~~ -- [x] ~~“我今天去客户现场,招待了客户,花销了1000元”不会错误识别为应收查询。~~ -- [x] ~~“昨天请客户吃饭花了 200 元”会优先识别为报销草稿语义,并把“昨天”换算为用户本地日期下的绝对日期。~~ - -## 5. 解析业务对象 - -- [x] ~~从问题中提取员工姓名。~~ -- [x] ~~从问题中提取部门。~~ -- [x] ~~从问题中提取客户。~~ -- [x] ~~从问题中提取供应商。~~ -- [x] ~~从问题中提取项目。~~ -- [x] ~~从问题中提取单据号。~~ -- [x] ~~从问题中提取金额。~~ -- [x] ~~从问题中提取费用类型。~~ -- [x] ~~无法提取时返回空数组,不返回 null。~~ - -验收证据: - -- [x] ~~“张三 4 月差旅报销”能提取员工、月份、费用类型。~~ - -## 6. 解析时间范围 - -- [x] ~~支持今天。~~ -- [x] ~~支持昨天。~~ -- [x] ~~支持本周。~~ -- [x] ~~支持上周。~~ -- [x] ~~支持本月。~~ -- [x] ~~支持上月。~~ -- [x] ~~支持本季度。~~ -- [x] ~~支持今年。~~ -- [x] ~~支持明确日期。~~ -- [x] ~~支持日期区间。~~ -- [x] ~~解析结果包含 `start_date` 和 `end_date`。~~ -- [x] ~~日期使用 ISO 格式。~~ - -验收证据: - -- [x] ~~“本周”能解析为当前周起止日期。~~ -- [x] ~~“2026 年 4 月”能解析为 `2026-04-01` 到 `2026-04-30`。~~ - -## 7. 解析指标与约束 - -- [x] ~~识别金额指标。~~ -- [x] ~~识别数量指标。~~ -- [x] ~~识别超标指标。~~ -- [x] ~~识别逾期指标。~~ -- [x] ~~识别重复报销指标。~~ -- [x] ~~识别部门过滤条件。~~ -- [x] ~~识别状态过滤条件。~~ -- [x] ~~识别金额阈值过滤条件。~~ -- [x] ~~识别排序要求。~~ -- [x] ~~识别 Top N 要求。~~ - -验收证据: - -- [x] ~~“列出金额最高的 10 笔报销”能识别排序和 Top 10。~~ - -## 8. 解析风险与权限 - -- [x] ~~重复报销映射到 `duplicate_expense`。~~ -- [x] ~~发票异常映射到 `invoice_anomaly`。~~ -- [x] ~~金额超标映射到 `amount_over_limit`。~~ -- [x] ~~逾期应收映射到 `ar_overdue`。~~ -- [x] ~~逾期应付映射到 `ap_overdue`。~~ -- [x] ~~查询类问题权限为 `read`。~~ -- [x] ~~生成草稿权限为 `draft_write`。~~ -- [x] ~~审批、上线、付款类动作权限为 `approval_required`。~~ -- [x] ~~越权动作权限为 `forbidden`。~~ - -验收证据: - -- [x] ~~“帮我直接付款”不能被标为可直接执行。~~ - -## 9. API 接口 - -- [x] ~~新增 `POST /api/v1/ontology/parse`。~~ -- [x] ~~请求参数包含用户问题。~~ -- [x] ~~请求参数包含用户上下文。~~ -- [x] ~~响应包含 8 个字段。~~ -- [x] ~~响应包含 `run_id`。~~ -- [x] ~~响应包含置信度。~~ -- [x] ~~响应包含澄清问题。~~ -- [x] ~~每次调用写入 `SemanticParseLog`。~~ -- [x] ~~每次调用写入 `AgentRun` 或关联已有 `AgentRun`。~~ - -验收证据: - -- [x] ~~连续调用多次都能在日志中查到。~~ - -## 10. 前端调试入口 - -- [x] ~~在合适页面增加语义解析调试入口。~~ -- [x] ~~输入框支持自然语言问题。~~ -- [x] ~~点击解析后调用 API。~~ -- [x] ~~展示 8 个字段。~~ -- [x] ~~展示 JSON 原始结果。~~ -- [x] ~~展示置信度。~~ -- [x] ~~展示澄清问题。~~ -- [x] ~~展示 `run_id`。~~ -- [x] ~~错误时展示错误信息。~~ - -验收证据: - -- [x] ~~产品和开发可以直接在页面验证解析结果。~~ - -## 11. 评测集 - -- [x] ~~创建至少 5 条报销问题。~~ -- [ ] 创建至少 5 条叙述型报销问题。 -- [ ] 创建至少 3 条附件 / OCR 摘要带入的报销问题。 -- [x] ~~创建至少 5 条应收问题。~~ -- [x] ~~创建至少 5 条应付问题。~~ -- [x] ~~创建至少 3 条知识库问题。~~ -- [x] ~~创建至少 3 条越权操作问题。~~ -- [ ] 创建至少 3 条模糊短句追问问题。 -- [x] ~~为每条问题写期望 `scenario`。~~ -- [x] ~~为每条问题写期望 `intent`。~~ -- [x] ~~为每条问题写期望权限级别。~~ -- [x] ~~编写评测脚本或测试。~~ - -验收证据: - -- [x] ~~当前评测样本集已通过,覆盖样本准确率达到当天设定阈值。~~ - -## 12. Day 3 验收 - -- [x] ~~语义解析 API 可用。~~ -- [x] ~~8 个核心字段完整返回。~~ -- [x] ~~解析日志可查询。~~ -- [x] ~~低置信度问题有澄清问题。~~ -- [x] ~~越权动作不会被标为可执行。~~ -- [x] ~~前端调试入口可用。~~ -- [x] ~~评测集可运行。~~ -- [x] ~~所有完成项已用 `[x] ~~...~~` 标记。~~ - -## 阻塞记录 - -- [x] ~~暂无。~~ - -## 日终交接 - -- [x] ~~已支持报销 / 应收 / 应付 / 知识 / 风险 / 草稿 / 越权动作等核心场景关键词、实体与权限解析。~~ -- [x] ~~语义层已可接收附件名称、附件数量和 OCR 摘要上下文,但这些样本仍需继续扩到评测集。~~ -- [x] ~~当前仍需继续扩充的弱样本主要是叙述型报销长句、附件/OCR 带入和模糊短句追问。~~ -- [x] ~~Day 4 可直接复用 `scenario / intent / entities / time_range / metrics / constraints / risk_flags / permission / confidence / missing_slots / ambiguity / parse_strategy / clarification_required / clarification_question / run_id`。~~ diff --git a/document/development/agent/agent week plan/day_4_orchestrator_runtime.md b/document/development/agent/agent week plan/day_4_orchestrator_runtime.md deleted file mode 100644 index 0cc3152..0000000 --- a/document/development/agent/agent week plan/day_4_orchestrator_runtime.md +++ /dev/null @@ -1,254 +0,0 @@ -# Day 4:Orchestrator 运行时 - -## 今天的大开发点 - -建立统一调度层。用户请求和系统任务都先进入 Orchestrator,由它完成语义解析、权限判断、能力选择、Agent 路由、工具调用记录和失败降级。 - -## 为什么第四天做这个 - -没有 Orchestrator,User Agent 和 Hermes 会各自直接调用能力,权限、审计、降级、Trace 都会分散。生产系统必须有统一入口。 - -## 今天主要交付 - -- Orchestrator 请求和响应结构。 -- 用户请求路由到 User Agent。 -- 定时任务路由到 Hermes。 -- 权限级别判断。 -- 语义补槽完成后的报销草稿创建、草稿更新、提交动作路由。 -- 高风险动作确认机制。 -- 能力注册查询。 -- 工具调用封装。 -- AgentRun Trace 查询。 -- 失败降级返回。 - -## 当前完成情况 - -- [x] ~~`/api/v1/orchestrator/run`、统一路由、权限阻断、ToolCall 记录、Trace 和降级结果已经可用。~~ -- [x] ~~用户消息已能路由到 User Agent,占位 Hermes 任务也能由定时入口触发。~~ -- [x] ~~附件名称、页面上下文和 OCR 摘要已能随 Orchestrator 请求透传到语义层和 User Agent。~~ -- [x] ~~Orchestrator 已开始向前端返回结构化 `review_payload`,用于右侧预审面板展示识别意图、槽位、票据和分单建议。~~ -- [x] ~~`conversation_id`、会话消息历史和 `draft_claim_id` 已接入 Orchestrator,会话内追问可继续落到同一张报销草稿。~~ -- [x] ~~已新增最近会话恢复与用户级会话清空接口,个人工作台可显式继续旧会话或删除旧会话后新建。~~ -- [x] ~~`clarification_required` 的报销请求已改为返回结构化核对结果,而不是只回一句追问文案。~~ -- [x] ~~`review_action`、`review_form_values` 已能透传到 User Agent / 报销草稿服务,用于结构化修改后重识别和保存草稿。~~ -- [ ] 真实 `expense_claims` 提交链路尚未接通;草稿建单 / 改单已接到真实落库,附件与 OCR 持久化仍未完成。 -- [ ] 报销附件持久化服务、OCR 结果落库服务和前端 ToolCall 细粒度 Trace 展示尚未接通。 - -相关架构文档: - -- [Orchestrator 与运行流程](<../agent plan/04_orchestrator_and_runtime_flow.md>) -- [能力注册](<../agent plan/07_capability_registry.md>) -- [权限与确认](<../agent plan/08_permission_confirmation.md>) -- [观测与 Trace](<../agent plan/09_observability_and_trace.md>) - -## 当天验收门槛 - -- Orchestrator API 可用。 -- 用户消息能路由到 User Agent 占位实现。 -- 定时任务能路由到 Hermes 占位实现。 -- forbidden 请求不会调用下游 Agent。 -- 每次运行都有 `run_id` 和 Trace。 -- 工具调用失败能记录并返回降级结果。 -- 叙述型报销输入在满足最小槽位后能进入建单或改单流程。 - -## 今天不做 - -- 不做复杂任务编排 DAG。 -- 不做多 Agent 协商。 -- 不做自动高风险动作。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 0. 开始前检查 - -- [x] ~~确认 Day 3 `POST /api/v1/ontology/parse` 可用。~~ -- [x] ~~确认 `AgentRun` 可创建。~~ -- [x] ~~确认 `AgentToolCall` 可创建。~~ -- [x] ~~确认资产列表能查询技能、MCP、任务。~~ -- [x] ~~确认权限级别枚举已稳定。~~ -- [x] ~~找到后端服务层适合放 Orchestrator 的位置。~~ - -## 1. Orchestrator 输入输出 - -- [x] ~~定义 `OrchestratorRequest`。~~ -- [x] ~~请求包含 `source`。~~ -- [x] ~~请求包含 `user_id`。~~ -- [x] ~~请求包含 `message`。~~ -- [x] ~~请求包含 `task_id`。~~ -- [x] ~~请求包含 `context_json`。~~ -- [x] ~~定义 `OrchestratorResponse`。~~ -- [x] ~~响应包含 `run_id`。~~ -- [x] ~~响应包含 `selected_agent`。~~ -- [x] ~~响应包含 `route_reason`。~~ -- [x] ~~响应包含 `permission_level`。~~ -- [x] ~~响应包含 `status`。~~ -- [x] ~~响应包含 `result`。~~ -- [x] ~~响应包含 `requires_confirmation`。~~ -- [x] ~~响应包含 `trace_summary`。~~ - -验收证据: - -- [x] ~~Orchestrator 响应能直接被前端展示。~~ - -## 2. 建立 Orchestrator 服务 - -- [x] ~~新增 `OrchestratorService`。~~ -- [x] ~~实现 `run(request)` 主入口。~~ -- [x] ~~主入口第一步创建 `AgentRun`。~~ -- [x] ~~主入口第二步调用语义解析。~~ -- [x] ~~主入口第三步执行权限判断。~~ -- [x] ~~主入口第四步选择 Agent。~~ -- [x] ~~主入口第五步调用目标 Agent 或返回阻断结果。~~ -- [x] ~~主入口第六步更新 `AgentRun` 状态。~~ -- [x] ~~所有异常都写入 `AgentRun.error_message`。~~ - -验收证据: - -- [x] ~~正常请求状态为 `succeeded`。~~ -- [x] ~~被权限拦截请求状态为 `blocked`。~~ -- [x] ~~异常请求状态为 `failed`。~~ - -## 3. 路由规则 - -- [x] ~~`source=user_message` 默认路由到 User Agent。~~ -- [x] ~~`source=schedule` 默认路由到 Hermes。~~ -- [x] ~~`intent=risk_check` 且来源为 schedule 时路由到 Hermes。~~ -- [x] ~~`intent=query` 且来源为 user_message 时路由到 User Agent。~~ -- [x] ~~`intent=explain` 路由到 User Agent。~~ -- [x] ~~`intent=draft` 路由到 User Agent,并可返回结构化核对结果、草稿结果或草稿更新结果。~~ -- [x] ~~`scenario=expense` 且最小建单槽位完整时,允许进入 `create_expense_claim_draft`。~~ -- [x] ~~`scenario=expense` 且已有 `claim_id` 或会话内 `draft_claim_id` 时,允许进入 `update_expense_claim_draft`。~~ -- [ ] `scenario=expense` 且用户明确确认提交时,允许进入 `submit_expense_claim`。 -- [x] ~~`permission.level=approval_required` 时设置 `requires_confirmation=true`。~~ -- [x] ~~`permission.level=forbidden` 时不调用下游 Agent。~~ -- [x] ~~无法识别或信息不足时返回澄清问题。~~ - -验收证据: - -- [x] ~~同一句风险检查,在用户入口和任务入口有不同路由结果。~~ - -## 4. 权限判断 - -- [x] ~~新增权限判断服务或函数。~~ -- [x] ~~查询类请求返回 `read`。~~ -- [x] ~~草稿类请求返回 `draft_write`。~~ -- [ ] 报销草稿字段补全、附件挂接返回 `draft_write`。 -- [ ] 报销单提交返回 `approval_required`,并要求显式用户确认。 -- [ ] 审批、上线、付款类请求返回 `approval_required`。 -- [x] ~~用户无权限时返回 `forbidden`。~~ -- [x] ~~高风险动作不允许自动执行。~~ -- [x] ~~需要确认的动作返回确认提示。~~ -- [x] ~~权限判断结果写入 `AgentRun.permission_level`。~~ - -验收证据: - -- [x] ~~“直接上线规则”不会被自动执行。~~ -- [x] ~~“直接付款”不会被自动执行。~~ - -## 5. 能力注册查询 - -- [x] ~~从 `AgentAsset` 查询 active 技能。~~ -- [x] ~~从 `AgentAsset` 查询 active MCP。~~ -- [x] ~~从 `AgentAsset` 查询 active 任务。~~ -- [ ] 查询可用的报销单写入服务和附件挂接服务。 -- [ ] 查询可用的 OCR 结果持久化服务和票据文件回溯服务。 -- [x] ~~过滤 disabled 能力。~~ -- [x] ~~过滤未审核 active 条件不满足的规则。~~ -- [x] ~~为每次能力选择记录 `route_json`。~~ -- [x] ~~找不到能力时返回降级说明。~~ - -验收证据: - -- [x] ~~禁用 MCP 不会被 Orchestrator 调用。~~ - -## 6. 工具调用封装 - -- [x] ~~定义统一工具调用接口。~~ -- [ ] 工具请求前写入 `AgentToolCall` running 或准备记录。 -- [x] ~~工具成功后写入响应和耗时。~~ -- [x] ~~工具失败后写入错误。~~ -- [ ] 报销草稿更新、提交也按工具调用或等价服务调用记录。 -- [x] ~~报销草稿创建按工具调用或等价服务调用记录。~~ -- [ ] 附件挂接、OCR 结果落库、票据回溯查询也按工具调用或等价服务调用记录。 -- [x] ~~外部 MCP 调用失败时返回降级结果。~~ -- [x] ~~数据库查询失败时返回明确错误。~~ -- [x] ~~LLM 调用失败时返回可读提示。~~ - -验收证据: - -- [x] ~~每次 Orchestrator 运行至少可以看到 0 到多条工具调用记录。~~ - -## 7. API 接口 - -- [x] ~~新增 `POST /api/v1/orchestrator/run`。~~ -- [x] ~~请求支持用户消息。~~ -- [x] ~~请求支持任务触发。~~ -- [x] ~~响应返回 `run_id`。~~ -- [x] ~~响应返回路由结果。~~ -- [x] ~~响应返回权限结果。~~ -- [x] ~~复用 `GET /api/v1/agent-runs/{run_id}` 查看 Trace。~~ -- [x] ~~Trace 接口返回语义解析、路由、工具调用、最终结果。~~ -- [x] ~~`POST /api/v1/orchestrator/run` 返回的 `result` 已可携带 `review_payload`。~~ - -验收证据: - -- [x] ~~前端或 curl 可以完整看到一次运行链路。~~ - -## 8. 前端最小 Trace 查看 - -- [ ] 在合适位置展示最近运行记录。 -- [x] ~~点击当前对话结果可查看 `run_id`。~~ -- [x] ~~展示 selected_agent。~~ -- [x] ~~展示 route_reason。~~ -- [x] ~~展示 permission_level。~~ -- [ ] 展示工具调用列表。 -- [x] ~~展示错误信息。~~ -- [ ] 展示耗时。 -- [ ] 展示报销写链路中的 claim_id / claim_no / status 变化。 - -验收证据: - -- [x] ~~开发调试时不需要直接查数据库才能理解主要路由结果。~~ - -## 9. 测试 - -- [x] ~~测试用户查询路由到 User Agent。~~ -- [x] ~~测试定时任务路由到 Hermes。~~ -- [x] ~~测试叙述型报销输入可路由到报销建单服务。~~ -- [x] ~~测试同一 `conversation_id` 下的追问会继续更新已有报销草稿。~~ -- [ ] 测试报销单提交前必须显式确认。 -- [x] ~~测试 forbidden 不调用下游 Agent。~~ -- [x] ~~测试 approval_required 返回确认。~~ -- [x] ~~测试工具失败写入 ToolCall。~~ -- [x] ~~测试 Orchestrator 异常写入 AgentRun。~~ - -验收证据: - -- [x] ~~Orchestrator 核心测试通过。~~ - -## 10. Day 4 验收 - -- [x] ~~Orchestrator API 可用。~~ -- [x] ~~用户请求能路由到 User Agent 占位实现。~~ -- [x] ~~定时任务能路由到 Hermes 占位实现。~~ -- [x] ~~语义补槽完成后的报销输入能路由到建单动作。~~ -- [x] ~~语义补槽完成后的报销输入能路由到改单动作。~~ -- [x] ~~权限阻断有效。~~ -- [x] ~~运行 Trace 可查询。~~ -- [x] ~~工具调用日志可查询。~~ -- [x] ~~降级结果可读。~~ -- [x] ~~所有完成项已用 `[x] ~~...~~` 标记。~~ - -## 阻塞记录 - -- [x] ~~暂无。~~ - -## 日终交接 - -- [x] ~~当前路由规则已稳定为:`user_message -> user_agent`、`schedule -> hermes`、`clarification_required -> blocked`。~~ -- [x] ~~当前权限判断已稳定为:`read / draft_write / approval_required / forbidden`,高风险动作默认阻断或要求确认。~~ -- [x] ~~Day 5 需承接的接口契约已明确:Orchestrator 向 User Agent 传入语义结果、能力码、工具结果,并期待返回 `answer / citations / suggested_actions / draft_payload / risk_flags`。~~ -- [x] ~~Day 5 当前已扩展接口契约:除 `answer / citations / suggested_actions / draft_payload / risk_flags` 外,还返回 `review_payload` 用于前端预审工作台。~~ -- [x] ~~下一步仍需补齐的运行时写链路是:附件持久化、OCR 结果落库和提交状态流转。~~ diff --git a/document/development/agent/agent week plan/day_5_user_agent_mvp.md b/document/development/agent/agent week plan/day_5_user_agent_mvp.md deleted file mode 100644 index 68e0766..0000000 --- a/document/development/agent/agent week plan/day_5_user_agent_mvp.md +++ /dev/null @@ -1,284 +0,0 @@ -# Day 5:User Agent MVP - -## 今天的大开发点 - -实现面向用户的自建 Agent。它负责用户提问、流程辅助、规则解释、查询结果解释和草稿生成。 - -User Agent 只能处理用户侧交互,不负责后台定时内循环,也不能自动执行高风险动作。 - -## 为什么第五天做这个 - -Day 1 到 Day 4 已经具备资产、语义、路由和日志基础,此时可以把用户自然语言入口接到真实流程上。 - -## 今天主要交付 - -- 用户自然语言入口。 -- 对话入口透传首句文本、附件名称和页面上下文。 -- 语义识别完整后创建报销单草稿。 -- 对话补充字段时更新报销主表、明细和附件关联。 -- 用户确认后触发报销单提交和状态变更。 -- 报销查询和解释。 -- 应收查询和解释。 -- 应付查询和解释。 -- 规则引用解释。 -- 风险原因说明。 -- 处理意见草稿。 -- 知识库读取骨架。 -- 低置信度场景的澄清追问。 -- 前端问答或操作入口。 - -## 当前完成情况 - -- [x] ~~个人工作台、报销对话框和通用聊天入口已经接通真实 Orchestrator / User Agent 问答链路。~~ -- [x] ~~回答、规则引用、风险说明、建议动作和结构化 `draft_payload` 已可返回。~~ -- [x] ~~报销对话框已接入 OCR 识别接口,附件名称、OCR 摘要和页面上下文已能透传到 Orchestrator / User Agent。~~ -- [x] ~~右侧工作台已开始展示结构化 `review_payload`,并已收敛为“识别结果专用区”:核心识别摘要、时间换算说明、逐票据识别结果、可能单据类型、建议归属费用和 OCR 置信度。~~ -- [x] ~~个人工作台和报销对话框已接入 `conversation_id` / `draft_claim_id`,同一会话内的连续追问不再按全新请求处理。~~ -- [x] ~~个人工作台已支持“继续会话 / 新建会话”,并可恢复最近一次用户会话或清空旧会话后重新开始。~~ -- [x] ~~报销核对流已切到产品化交互:正文区负责 AI 式核对提示、待补充信息、风险提醒和底部动作区,右侧只承载识别结果与票据识别明细,动作固定为“取消 / 修改识别信息 / 保存草稿或下一步”。~~ -- [ ] 真实 `document_assets` / `document_asset_versions` / `expense_item_documents` / `document_ocr_results` 落库,以及 `draft -> submitted` 状态流转尚未完成;`expense_claims` / `expense_claim_items` 草稿已接通真实落库。 - -相关架构文档: - -- [Agent 职责边界](<../agent plan/03_agent_responsibilities.md>) -- [Orchestrator 与运行流程](<../agent plan/04_orchestrator_and_runtime_flow.md>) -- [LLM Wiki 知识库架构](<../agent plan/12_llm_wiki_knowledge_architecture.md>) -- [规则形成生命周期](<../agent plan/13_rule_formation_lifecycle.md>) - -## 当天验收门槛 - -- 用户能输入自然语言问题。 -- 请求必须经过 Orchestrator。 -- 至少 3 类财务问题有可读回答。 -- 叙述型报销输入在最小槽位满足后能创建 `expense_claims` 草稿。 -- 用户确认提交后可把报销单从 `draft` 变更为 `submitted`。 -- 回答能引用规则或知识。 -- 语义低置信度时不会答非所问,而是追问。 -- 高风险动作只生成草稿或建议。 -- AgentRun Trace 能看到 User Agent 步骤。 - -## 今天不做 - -- 不做自动审批。 -- 不做自动付款。 -- 不做自动上线规则。 -- 不做完整知识库检索优化。 -- 不假装已读懂未解析的附件内容。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 0. 开始前检查 - -- [x] ~~确认 Orchestrator 能把用户请求路由到 User Agent。~~ -- [x] ~~确认语义本体 8 字段可用。~~ -- [x] ~~确认语义层已接入真实模型,而不是仅靠关键词规则。~~ -- [x] ~~确认规则资产可查询。~~ -- [x] ~~确认 AgentRun 和 ToolCall 可记录。~~ -- [x] ~~确认已有现成对话 UI 可复用。~~ -- [x] ~~确认财务业务数据已可通过最小真实数据查询。~~ -- [x] ~~当前无需额外补最小 Mock 数据服务。~~ - -## 1. User Agent 输入输出 - -- [x] ~~定义 `UserAgentRequest`。~~ -- [x] ~~请求包含 `run_id`。~~ -- [x] ~~请求包含 `user_id`。~~ -- [x] ~~请求包含 `message`。~~ -- [x] ~~请求包含 `ontology`。~~ -- [x] ~~请求包含 `context_json`。~~ -- [x] ~~定义 `UserAgentResponse`。~~ -- [x] ~~响应包含 `answer`。~~ -- [x] ~~响应包含 `citations`。~~ -- [x] ~~响应包含 `suggested_actions`。~~ -- [x] ~~响应包含 `draft_payload`。~~ -- [x] ~~响应包含 `risk_flags`。~~ -- [x] ~~响应包含 `requires_confirmation`。~~ - -验收证据: - -- [x] ~~User Agent 响应结构能被 Orchestrator 直接包装返回。~~ - -## 2. 查询处理 - -- [x] ~~实现报销查询处理器。~~ -- [x] ~~实现应收查询处理器。~~ -- [x] ~~实现应付查询处理器。~~ -- [ ] 查询前检查权限级别。 -- [x] ~~查询时记录 ToolCall。~~ -- [x] ~~查询失败时返回可读错误。~~ -- [x] ~~查询为空时返回空态解释。~~ -- [ ] 查询结果限制返回条数,避免一次返回过大。 - -验收证据: - -- [x] ~~“查本周报销金额”有可读回答。~~ -- [x] ~~“客户 A 本月应收多少”有可读回答。~~ -- [x] ~~“供应商 B 待付款多少”有可读回答。~~ - -## 3. 规则解释 - -- [x] ~~根据语义场景查询相关规则资产。~~ -- [x] ~~只引用 active 规则。~~ -- [x] ~~读取规则当前版本 Markdown。~~ -- [x] ~~从 Markdown 中提取规则摘要。~~ -- [x] ~~回答中说明使用了哪些规则。~~ -- [x] ~~回答中包含规则版本号。~~ -- [x] ~~回答中包含规则更新时间。~~ -- [x] ~~没有相关规则时说明缺失。~~ - -验收证据: - -- [x] ~~“为什么这笔报销有风险”能引用规则。~~ - -## 4. 风险解释 - -- [x] ~~识别重复报销风险。~~ -- [x] ~~识别金额超标风险。~~ -- [x] ~~识别发票异常风险。~~ -- [x] ~~识别逾期应收风险。~~ -- [x] ~~识别逾期应付风险。~~ -- [x] ~~风险回答包含风险类型。~~ -- [x] ~~风险回答包含触发原因。~~ -- [x] ~~风险回答包含建议处理动作。~~ -- [x] ~~高风险建议不能变成自动执行。~~ - -验收证据: - -- [x] ~~风险解释结果不是单纯“有风险”,而是有依据。~~ - -## 5. 草稿生成与单据落库 - -- [x] ~~支持根据语义结果创建 `expense_claims` 草稿。~~ -- [x] ~~报销草稿初始状态写为 `draft`。~~ -- [x] ~~支持根据语义结果创建或更新 `expense_claim_items`。~~ -- [ ] 支持把用户上传附件挂到 `document_assets`、`document_asset_versions`、`expense_item_documents`。 -- [ ] 支持把 OCR 识别快照写入 `document_ocr_results`,并保留 `ocr_engine`、`ocr_model`、`raw_json`、`confidence`。 -- [x] ~~对话中补充金额、发生时间、费用类型等已落地字段后,能回写已有草稿而不是只更新内存结果。~~ -- [x] ~~支持生成报销处理意见草稿。~~ -- [x] ~~支持生成应收催收建议草稿。~~ -- [x] ~~支持生成应付付款建议草稿。~~ -- [ ] 用户明确确认“提交报销”后,把 `expense_claims.status` 从 `draft` 更新为 `submitted`。 -- [ ] 报销提交时写入 `submitted_at`。 -- [ ] 报销状态变更写入审计日志。 -- [ ] 报销状态变更写入 AgentRun 结果。 -- [x] ~~草稿中标明“待人工确认”。~~ -- [x] ~~草稿不直接提交业务系统。~~ -- [x] ~~草稿生成写入审计日志。~~ -- [x] ~~草稿生成写入 AgentRun 结果。~~ -- [ ] 草稿创建或更新后向前端返回 `attachment_ids`。 -- [x] ~~草稿创建或更新后向前端返回 `claim_id`、`claim_no`、`status`。~~ - -验收证据: - -- [ ] “我今天去客户现场,招待了客户,花销了1000元”在补齐必要字段后可创建报销草稿。 -- [ ] “帮我提交这笔报销”在确认后只把状态改到 `submitted`,不会直接改成 `approved` 或 `paid`。 -- [x] ~~“帮我生成处理意见”只返回草稿,不执行审批。~~ - -## 6. 知识库读取骨架 - -- [ ] 建立知识条目查询接口或服务。 -- [ ] 支持按关键词查询知识条目。 -- [ ] 支持按业务场景查询知识条目。 -- [ ] User Agent 回答可以引用知识条目。 -- [ ] 引用中包含知识标题。 -- [ ] 引用中包含更新时间。 -- [ ] 知识库不可用时返回降级说明。 - -验收证据: - -- [ ] 知识库失败不会导致整个回答失败。 - -## 7. 对话或操作入口 - -- [x] ~~前端增加用户问题输入框。~~ -- [x] ~~输入框支持回车或按钮提交。~~ -- [x] ~~提交时调用 Orchestrator,而不是绕过 Orchestrator。~~ -- [x] ~~提交时透传首句文本。~~ -- [x] ~~提交时透传附件名称。~~ -- [x] ~~提交时透传 OCR 摘要。~~ -- [x] ~~提交时透传页面上下文。~~ -- [x] ~~提交时透传 `conversation_id` 与 `draft_claim_id`。~~ -- [ ] 提交时透传附件 ID。 -- [x] ~~展示 Agent 回答。~~ -- [x] ~~展示引用规则或知识。~~ -- [x] ~~展示建议动作。~~ -- [x] ~~展示识别意图摘要、待确认字段和确认动作卡片。~~ -- [x] ~~正文区改为简洁核对提示,不再堆叠调度结果或运行明细。~~ -- [x] ~~正文区待补充信息和风险提示已改为紧凑高亮样式,避免出现大段冗长说明。~~ -- [x] ~~展示逐票据 OCR 识别结果,并支持按 1、2、3… 顺序查看。~~ -- [x] ~~右侧逐票据结果已补充“可能单据类型 / 建议归属费用 / 识别置信度”等识别信息。~~ -- [x] ~~展示多场景票据的分单建议。~~ -- [ ] 展示报销草稿 ID 或 claim_no。 -- [ ] 展示当前报销状态。 -- [x] ~~展示需要人工确认的提示。~~ -- [x] ~~展示 `run_id`。~~ -- [x] ~~展示加载态。~~ -- [x] ~~展示错误态。~~ - -验收证据: - -- [x] ~~用户可在页面完成一次问答闭环。~~ - -## 8. 安全边界 - -- [x] ~~User Agent 不直接修改规则状态。~~ -- [x] ~~User Agent 不直接上线规则。~~ -- [x] ~~User Agent 不直接审批报销。~~ -- [x] ~~User Agent 不直接把报销单改为 `approved` 或 `paid`。~~ -- [x] ~~User Agent 不直接付款。~~ -- [x] ~~User Agent 不直接删除知识。~~ -- [x] ~~所有高风险动作只返回建议或草稿。~~ -- [ ] 报销从 `draft` 变更到 `submitted` 之前必须有用户确认。 -- [ ] 所有草稿动作标记 `requires_confirmation=true`。 -- [x] ~~语义低置信度时优先追问,不返回答非所问的查询结果。~~ -- [x] ~~没有 OCR/VLM 结果时,不假装读懂图片或票据内容。~~ - -验收证据: - -- [x] ~~提示词要求“直接付款”时仍被阻断。~~ - -## 9. 测试 - -- [x] ~~测试报销查询。~~ -- [x] ~~测试应收查询。~~ -- [ ] 测试应付查询。 -- [ ] 测试规则解释。 -- [x] ~~测试风险解释。~~ -- [ ] 测试 OCR 摘要透传后,User Agent 能在回答中正确引用附件语境而不编造内容。 -- [x] ~~测试报销草稿创建。~~ -- [x] ~~测试报销草稿补槽更新。~~ -- [ ] 测试报销状态从 `draft` 变更到 `submitted`。 -- [x] ~~测试草稿生成。~~ -- [ ] 测试越权动作阻断。 -- [ ] 测试知识库降级。 - -验收证据: - -- [x] ~~User Agent 核心测试通过。~~ - -## 10. Day 5 验收 - -- [x] ~~User Agent 服务可被 Orchestrator 调用。~~ -- [x] ~~用户入口可提交自然语言问题。~~ -- [x] ~~至少 3 个财务场景有回答。~~ -- [x] ~~语义识别完整后的报销输入能创建报销草稿。~~ -- [ ] 用户确认后能提交报销并更新状态。 -- [x] ~~回答能引用规则或知识。~~ -- [x] ~~高风险动作不会自动执行。~~ -- [x] ~~AgentRun Trace 能看到 User Agent 步骤。~~ -- [x] ~~前端构建通过。~~ -- [x] ~~所有完成项已用 `[x] ~~...~~` 标记。~~ - -## 阻塞记录 - -- [x] ~~暂无。~~ - -## 日终交接 - -- [x] ~~当前已支持报销 / 应收 / 应付查询、规则解释、风险解释、草稿建议与澄清追问。~~ -- [x] ~~当前已支持附件名称、OCR 摘要和页面上下文进入对话链路,但这还不是附件真实持久化。~~ -- [x] ~~当前已把用户一句话和多票据输入转成结构化预审面板,开始支持字段确认、票据核对和分单建议,而不再只是返回一段文本。~~ -- [x] ~~当前仍是占位的主要能力是报销单真实落库、附件持久化、OCR 结果入表和知识库读取,不再是简单静态问答 Mock。~~ -- [x] ~~Day 6 Hermes 可直接复用当前的规则检查、风险标签和 Orchestrator Trace / ToolCall 契约。~~ diff --git a/document/development/agent/agent week plan/day_6_hermes_mvp.md b/document/development/agent/agent week plan/day_6_hermes_mvp.md deleted file mode 100644 index 522cf5c..0000000 --- a/document/development/agent/agent week plan/day_6_hermes_mvp.md +++ /dev/null @@ -1,343 +0,0 @@ -# Day 6:Hermes MVP - -## 今天的大开发点 - -实现 Hermes 数字员工的最小闭环。Hermes 负责后台内循环:定时巡检、统计日报、风险预警、知识维护、规则草稿形成。 - -## 为什么第六天做这个 - -Hermes 依赖前几天已经建立的资产、规则、语义、Orchestrator、Trace 和权限体系。放在第六天做,可以避免它变成孤立脚本。 - -## 今天主要交付 - -- 任务资产调度入口。 -- 手动触发任务 API。 -- 系统 Hermes 后台执行入口。 -- 每日风险巡检。 -- 每日报销、报账、账款统计。 -- OCR Mock 接入点。 -- 知识候选条目生成。 -- 规则草稿生成。 -- LLM Wiki 解析目录与增量重建机制。 -- Hermes 运行结果展示。 - -相关架构文档: - -- [Agent 职责边界](<../agent plan/03_agent_responsibilities.md>) -- [OCR 票据识别架构](<../agent plan/11_ocr_invoice_architecture.md>) -- [LLM Wiki 知识库架构](<../agent plan/12_llm_wiki_knowledge_architecture.md>) -- [反馈学习闭环](<../agent plan/15_feedback_learning_loop.md>) - -## 当天验收门槛 - -- 至少一个 Hermes 任务可以手动触发。 -- 风险巡检有结构化结果。 -- 每日统计有结构化结果。 -- OCR Mock 调用能记录 ToolCall。 -- 知识候选只能是草稿。 -- 规则草稿只能是 draft,不能自动上线。 - -## 今天不做 - -- 不做完整生产调度集群。 -- 不做真实 OCR 深度集成。 -- 不做自动发布知识。 -- 不做自动上线规则。 -- 不做每天无差别全量重建 LLM Wiki。 - -## 本次新增约束 - -### 1. Hermes 必须是系统后台 Hermes - -这次 Hermes 不应继续只是代码里的占位逻辑。 - -最小可接受形态: - -- 后端任务入口能明确区分 `selected_agent=hermes`。 -- 后端可调用系统安装的 Hermes CLI 或受控 Hermes 进程。 -- 即使当前阶段仍允许 Python 内部 fallback,也必须保留真实 Hermes 进程接入点。 -- Hermes 的模型配置继续由系统设置同步,不允许在任务代码里再写一套模型配置。 -- Hermes 执行应记录 `run_id`、ToolCall、错误信息和最终摘要。 - -### 2. LLM Wiki 必须有独立解析目录 - -原始知识文件与解析产物必须分离。 - -推荐目录: - -```text -/app/server/storage/knowledge/报销制度 原始制度文件 -/app/server/storage/knowledge/.llm_wiki 解析产物根目录 -/app/server/storage/knowledge/.llm_wiki/documents// - document.json - text.md - chunks.json - clauses.json - knowledge_candidates.json - rule_candidates.json -/app/server/storage/knowledge/.llm_wiki/index.json -/app/server/storage/knowledge/.llm_wiki/sync_runs.json -``` - -### 3. LLM Wiki 只能增量形成 - -不允许每天无脑全量重建。 - -文档级重建触发条件至少包括: - -- 文件名 `original_name` 变更。 -- 文件对象 `stored_name` 变更。 -- 内容摘要 `sha256` 变更。 -- 上传版本 `version_number` 变更。 -- 更新时间 `updated_at` 变更,视为人工改动。 - -如果以上条件都未变化: - -- 本次文档应标记为 `unchanged_skipped`。 -- 不重新抽取文本。 -- 不重新生成知识候选。 -- 不重新生成规则草稿。 - -### 4. 规则草稿必须模板化 - -Hermes 不允许自由生成任意结构的规则。 - -必须满足: - -- 规则 Markdown 使用固定模板。 -- 可执行规则 JSON 使用固定模板族,不允许随意拼字段。 -- 规则中心要同时展示人类可读的 Markdown 和机器可执行的 JSON。 -- Hermes 生成的规则默认 `draft`。 -- 审核通过前不能 `active`。 -- Hermes 不能直接覆盖线上 active 规则。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 本轮追加范围(2026-05-15) - -本轮不扩散到新的业务能力,先把已经落地的 LLM Wiki 归纳链路收紧成可运维、可追踪、可持续运行的形态。 - -本轮目标: - -- 把知识管理中的 Hermes 归纳从同步请求改成后台异步任务。 -- 用户关闭或切走页面后,归纳任务仍继续执行,不因前端页面生命周期被误判失败。 -- 归纳过程中的状态、进度、摘要、异常统一写入 `AgentRun.route_json` 与 `result_summary`。 -- 知识管理页轮询真实任务状态,任务完成后立刻把文档状态从“正归纳”切到最终状态。 -- 右侧侧边栏新增“日志管理”入口。 -- 日志管理页拆成两类日志: - - Hermes 调用日志:查看归纳任务运行状态、当前阶段、文档进度、ToolCall、错误信息。 - - 系统运行日志:直接查看 `server/logs` 下的系统日志文本。 - -本轮边界: - -- 仍然使用系统 Hermes CLI 入口,不虚构不存在的 gateway 推理接口。 -- 不引入完整消息队列或 Celery 集群,先用后端受控后台任务管理器落地。 -- 不把日志页做成审计替代品,重点只覆盖 Hermes 运行日志和系统运行日志。 -- 不把普通用户开放为日志管理员,日志查看仍属于管理员能力。 - -## 0. 开始前检查 - -- [x] ~~确认任务资产 `asset_type=task` 可查询。~~ -- [x] ~~确认 Orchestrator 能处理 `source=schedule`。~~ -- [x] ~~确认系统 Hermes CLI 或等价后台 Hermes 进程可被调用。~~ -- [x] ~~确认 AgentRun 和 ToolCall 可记录。~~ -- [x] ~~确认是否已有后台任务框架。~~ -- [ ] 如果没有后台任务框架,先用手动触发 API 模拟定时执行。 - -## 1. Hermes 输入输出 - -- [ ] 定义 `HermesTaskRequest`。 -- [ ] 请求包含 `run_id`。 -- [ ] 请求包含 `task_asset_id`。 -- [ ] 请求包含 `task_type`。 -- [ ] 请求包含 `schedule_time`。 -- [ ] 请求包含 `context_json`。 -- [ ] 定义 `HermesTaskResult`。 -- [ ] 响应包含 `summary`。 -- [ ] 响应包含 `risk_items`。 -- [ ] 响应包含 `statistics`。 -- [ ] 响应包含 `knowledge_updates`。 -- [ ] 响应包含 `draft_rules`。 -- [ ] 响应包含 `next_actions`。 - -验收证据: - -- [ ] Hermes 响应能被任务详情或运行日志展示。 - -## 2. 任务调度入口 - -- [x] ~~新增手动触发任务 API。~~ -- [x] ~~API 参数支持任务资产 ID。~~ -- [x] ~~API 调用 Orchestrator,source 为 `schedule`。~~ -- [x] ~~Orchestrator 路由到 Hermes。~~ -- [x] ~~Hermes 执行结果写入 AgentRun。~~ -- [ ] 任务执行失败时写入错误。 -- [ ] 任务执行结束后更新任务最近执行时间。 -- [ ] 任务执行结束后更新任务最近执行状态。 -- [x] ~~保留真实 Hermes 进程执行入口,不把 Hermes 固定写死为本地占位函数。~~ - -验收证据: - -- [x] ~~可以手动触发一次 Hermes 任务并看到运行结果。~~ - -## 3. 每日风险巡检 - -- [ ] 实现重复报销巡检。 -- [ ] 实现金额超标巡检。 -- [ ] 实现发票异常巡检占位。 -- [ ] 实现应收逾期巡检。 -- [ ] 实现应付异常付款巡检。 -- [ ] 每个风险项包含风险类型。 -- [ ] 每个风险项包含业务对象。 -- [ ] 每个风险项包含触发规则。 -- [ ] 每个风险项包含建议动作。 -- [ ] 每个风险项包含风险等级。 - -验收证据: - -- [ ] 风险巡检结果可以被用户理解和追溯。 - -## 4. 每日统计 - -- [ ] 统计当日报销单数量。 -- [ ] 统计当日报销金额。 -- [ ] 统计当日报账数量。 -- [ ] 统计当日报账金额。 -- [ ] 统计应收新增金额。 -- [ ] 统计应收逾期金额。 -- [ ] 统计应付待付金额。 -- [ ] 统计应付逾期金额。 -- [ ] 输出日报摘要。 - -验收证据: - -- [ ] Hermes 能生成一份每日财务摘要。 - -## 5. OCR 接入点 - -- [ ] 原始票据先落 `document_assets` 和 `document_asset_versions`,不直接以内存临时文件参与流程。 -- [ ] 建立 OCR 识别服务接口。 -- [ ] 定义发票识别输入结构。 -- [ ] 定义发票识别输出结构。 -- [ ] 输出结构包含发票号。 -- [ ] 输出结构包含开票日期。 -- [ ] 输出结构包含金额。 -- [ ] 输出结构包含税额。 -- [ ] 输出结构包含销售方。 -- [ ] 输出结构包含购买方。 -- [ ] 输出结构包含置信度。 -- [ ] OCR 输入可通过 `storage_key` 或等价文件定位字段读取原件。 -- [ ] 当前阶段允许使用 Mock 结果。 -- [ ] OCR 调用写入 ToolCall。 - -验收证据: - -- [ ] Hermes 风险巡检中可以调用 OCR Mock。 - -## 6. 知识库维护 - -- [ ] 建立知识条目写入服务。 -- [x] ~~建立 `.llm_wiki` 独立解析目录。~~ -- [x] ~~原始文档与解析产物物理隔离。~~ -- [x] ~~文本抽取结果落 `text.md`。~~ -- [x] ~~分块结果落 `chunks.json`。~~ -- [x] ~~文档索引落 `index.json`。~~ -- [x] ~~同步记录落 `sync_runs.json`。~~ -- [x] ~~文档签名包含 `original_name`、`stored_name`、`sha256`、`version_number`、`updated_at`。~~ -- [x] ~~未变化文档跳过重建并记录 `unchanged_skipped`。~~ -- [x] ~~Hermes 可以生成知识候选条目。~~ -- [x] ~~候选条目包含标题。~~ -- [x] ~~候选条目包含正文。~~ -- [x] ~~候选条目包含来源。~~ -- [x] ~~候选条目包含适用场景。~~ -- [x] ~~候选条目默认状态为 `draft`。~~ -- [x] ~~知识条目不能自动发布。~~ -- [ ] 知识条目写入审计日志。 - -验收证据: - -- [x] ~~Hermes 可以生成待审核知识条目。~~ - -## 7. 规则草稿形成 - -- [ ] Hermes 可以根据风险巡检结果生成规则草稿。 -- [x] ~~规则草稿使用固定 Markdown 模板。~~ -- [x] ~~规则草稿生成可执行 JSON 草稿。~~ -- [x] ~~规则中心展示 Markdown + JSON 双视图。~~ -- [x] ~~JSON 草稿字段受模板约束,不允许自由扩展。~~ -- [x] ~~规则草稿保存为 `asset_type=rule`。~~ -- [x] ~~规则草稿状态为 `draft`。~~ -- [x] ~~规则草稿包含 Markdown 内容。~~ -- [x] ~~规则草稿包含 JSON 内容或等价 `runtime_rule` 配置。~~ -- [ ] 规则草稿包含生成原因。 -- [ ] 规则草稿包含关联风险样例。 -- [x] ~~规则草稿不能自动上线。~~ -- [x] ~~规则草稿需要审核人。~~ -- [x] ~~规则草稿写入审计日志。~~ -- [x] ~~Hermes 不直接覆盖线上 active 规则。~~ - -验收证据: - -- [x] ~~Hermes 生成的新规则出现在规则列表中,但不是 active。~~ - -## 8. Hermes 页面或日志展示 - -- [x] ~~任务详情能看到最近执行结果。~~ -- [ ] 任务详情能手动触发执行。 -- [ ] 任务详情能看到风险项数量。 -- [ ] 任务详情能看到日报摘要。 -- [ ] 任务详情能看到知识候选数量。 -- [ ] 任务详情能看到规则草稿数量。 -- [ ] 运行 Trace 能看到 Hermes 步骤。 -- [x] ~~错误时展示错误原因。~~ -- [ ] 日志管理页能查看 Hermes 归纳任务的实时状态。 -- [ ] 日志管理页能查看 Hermes ToolCall 请求与结果。 -- [ ] 日志管理页能查看系统运行日志文本。 -- [ ] 知识管理页能在后台任务完成后自动刷新归纳状态。 - -验收证据: - -- [x] ~~不查数据库也能判断 Hermes 是否执行成功。~~ - -## 9. 测试 - -- [x] ~~测试手动触发任务。~~ -- [x] ~~测试 Orchestrator 路由到 Hermes。~~ -- [ ] 测试风险巡检输出。 -- [ ] 测试日报统计输出。 -- [ ] 测试 OCR Mock 调用。 -- [x] ~~测试知识候选写入。~~ -- [x] ~~测试规则草稿生成。~~ -- [ ] 测试 Hermes 异常写入 AgentRun。 -- [ ] 测试知识归纳异步任务在接口返回后仍能继续执行。 -- [ ] 测试归纳进度能持续写入 AgentRun。 -- [ ] 测试系统日志读取接口。 - -验收证据: - -- [ ] Hermes 核心测试通过。 - -## 10. Day 6 验收 - -- [x] ~~Hermes 可被 Orchestrator 调用。~~ -- [x] ~~至少一个任务可以手动触发。~~ -- [ ] 风险巡检有结构化结果。 -- [ ] 每日统计有结构化结果。 -- [ ] OCR Mock 接入点可用。 -- [x] ~~知识候选可生成。~~ -- [x] ~~规则草稿可生成且不能自动上线。~~ -- [x] ~~任务详情或运行日志能展示结果。~~ -- [x] ~~所有完成项已用 `[x] ~~...~~` 标记。~~ - -## 阻塞记录 - -- [ ] 暂无。 - -## 日终交接 - -- [ ] 写明 Hermes 已支持任务类型。 -- [ ] 写明 OCR 当前是真实还是 Mock。 -- [ ] 写明生成的知识和规则草稿状态。 -- [ ] 写明 Day 7 需要重点回归的路径。 diff --git a/document/development/agent/agent week plan/day_7_hardening_demo_acceptance.md b/document/development/agent/agent week plan/day_7_hardening_demo_acceptance.md deleted file mode 100644 index 4146d5d..0000000 --- a/document/development/agent/agent week plan/day_7_hardening_demo_acceptance.md +++ /dev/null @@ -1,260 +0,0 @@ -# Day 7:加固、演示和验收 - -## 今天的大开发点 - -不再大规模扩功能,集中做回归、加固、测试、演示脚本、文档收尾和下一阶段交接。 - -## 为什么第七天做这个 - -一周开发不能只停留在“代码写了”。必须能演示、能追溯、能说清楚边界、能交给下一阶段继续开发。 - -## 今天主要交付 - -- 核心链路回归。 -- 权限和风险边界复查。 -- 审计日志补齐。 -- AgentRun Trace 补齐。 -- 前端体验修补。 -- 测试和构建记录。 -- 评测集执行记录。 -- 演示数据准备。 -- 演示脚本。 -- 下一阶段开发建议。 - -相关架构文档: - -- [Agent Plan 总览](<../agent plan/00_README.md>) -- [开发路线图](<../agent plan/05_development_roadmap.md>) -- [观测与 Trace](<../agent plan/09_observability_and_trace.md>) -- [评测与测试集](<../agent plan/10_evaluation_and_testset.md>) - -## 当天验收门槛 - -- 任务规则中心核心路径可演示。 -- 语义本体、Orchestrator、User Agent、Hermes 都能跑通最小链路。 -- 未审核规则、高风险动作、自动付款等边界都被拦截。 -- AgentRun、ToolCall、AuditLog 可追溯。 -- 有测试记录、演示脚本和交接说明。 - -## 今天不做 - -- 不做新大功能。 -- 不临时扩大范围。 -- 不绕过测试和验收。 - -## 详细执行清单 - -以下内容为合并后的详细执行清单。 - -## 0. 开始前检查 - -- [ ] 汇总 Day 1 未完成项。 -- [ ] 汇总 Day 2 未完成项。 -- [ ] 汇总 Day 3 未完成项。 -- [ ] 汇总 Day 4 未完成项。 -- [ ] 汇总 Day 5 未完成项。 -- [ ] 汇总 Day 6 未完成项。 -- [ ] 标记必须今天修复的问题。 -- [ ] 标记可以进入下一阶段的问题。 -- [ ] 冻结新增需求,只处理验收相关问题。 - -## 1. 核心链路回归 - -- [ ] 回归资产列表接口。 -- [ ] 回归规则详情接口。 -- [ ] 回归 Markdown 保存。 -- [ ] 回归版本列表。 -- [ ] 回归版本切换。 -- [ ] 回归审核接口。 -- [ ] 回归上线拦截。 -- [ ] 回归语义解析接口。 -- [ ] 回归 Orchestrator 路由。 -- [ ] 回归 User Agent 问答。 -- [ ] 回归 Hermes 任务执行。 -- [ ] 回归 AgentRun Trace。 -- [ ] 回归 ToolCall 日志。 -- [ ] 回归 AuditLog 日志。 - -验收证据: - -- [ ] 从前端能完成至少一条端到端演示路径。 - -## 2. 权限和风险边界 - -- [ ] 未审核规则不能上线。 -- [ ] rejected 规则不能上线。 -- [ ] disabled 能力不能被调用。 -- [ ] 用户请求付款必须拦截。 -- [ ] 用户请求审批必须需要确认。 -- [ ] Hermes 生成规则只能是 draft。 -- [ ] Hermes 生成知识只能是 draft。 -- [ ] User Agent 生成处理意见只能是草稿。 -- [ ] 所有高风险动作响应中包含 `requires_confirmation`。 - -验收证据: - -- [ ] 不存在 MVP 期间绕过人工审核的路径。 - -## 3. 审计和 Trace 补齐 - -- [ ] 规则保存写 AuditLog。 -- [ ] 规则审核写 AuditLog。 -- [ ] 规则上线写 AuditLog。 -- [ ] Hermes 生成规则草稿写 AuditLog。 -- [ ] Hermes 生成知识候选写 AuditLog。 -- [ ] User Agent 草稿生成写 AuditLog。 -- [ ] Orchestrator 每次运行有 AgentRun。 -- [ ] 每次工具调用有 ToolCall。 -- [ ] Trace 页面或接口能串起 run_id。 -- [ ] 错误 Trace 包含 error_message。 - -验收证据: - -- [ ] 任意一条演示链路都能追溯到 run_id。 - -## 4. 前端体验修补 - -- [ ] 任务规则中心列表无明显错位。 -- [ ] 详情页无双 title。 -- [ ] Hero title 高度紧凑。 -- [ ] 返回列表栏高度正常。 -- [ ] Markdown 编辑器和版本卡片底部对齐。 -- [ ] 版本卡片不贴右侧。 -- [ ] 当前版本标识不突兀。 -- [ ] 日期列对齐。 -- [ ] 弹窗文案清楚。 -- [ ] 加载态可见。 -- [ ] 错误态可见。 -- [ ] 空态可见。 -- [ ] 按钮禁用态可见。 -- [ ] 窄屏不出现内容重叠。 - -验收证据: - -- [ ] 任务规则中心可以给业务用户演示,不需要解释 UI 异常。 - -## 5. 测试补齐 - -- [ ] 运行后端现有测试。 -- [ ] 运行新增模型测试。 -- [ ] 运行新增 API 测试。 -- [ ] 运行语义解析测试。 -- [ ] 运行 Orchestrator 测试。 -- [ ] 运行 User Agent 测试。 -- [ ] 运行 Hermes 测试。 -- [ ] 运行前端构建。 -- [ ] 如果有前端测试,运行前端测试。 -- [ ] 记录未能运行的测试和原因。 - -验收证据: - -- [ ] 测试结果写入本文件“测试记录”。 - -## 6. 评测集 - -- [ ] 准备 5 条报销问题。 -- [ ] 准备 5 条应收问题。 -- [ ] 准备 5 条应付问题。 -- [ ] 准备 3 条规则解释问题。 -- [ ] 准备 3 条越权动作问题。 -- [ ] 执行语义解析评测。 -- [ ] 执行 User Agent 回答评测。 -- [ ] 执行权限拦截评测。 -- [ ] 记录失败样例。 -- [ ] 为失败样例写下一阶段优化建议。 - -验收证据: - -- [ ] 可以说明 MVP 当前能力边界和准确率风险。 - -## 7. 演示数据 - -- [ ] 准备 active 规则。 -- [ ] 准备 pending 规则。 -- [ ] 准备 rejected 规则。 -- [ ] 准备至少一条报销数据。 -- [ ] 准备至少一条应收数据。 -- [ ] 准备至少一条应付数据。 -- [ ] 准备至少一个 Hermes 任务。 -- [ ] 准备至少一个 MCP Mock。 -- [ ] 准备至少一个知识条目。 -- [ ] 准备至少一个风险样例。 - -验收证据: - -- [ ] 演示不会因为没有数据而中断。 - -## 8. 演示脚本 - -- [ ] 编写演示步骤 1:打开任务规则中心。 -- [ ] 编写演示步骤 2:查看规则详情。 -- [ ] 编写演示步骤 3:编辑 Markdown 并保存。 -- [ ] 编写演示步骤 4:切换版本。 -- [ ] 编写演示步骤 5:尝试上线未审核规则并被拦截。 -- [ ] 编写演示步骤 6:输入用户问题。 -- [ ] 编写演示步骤 7:查看语义本体结果。 -- [ ] 编写演示步骤 8:查看 User Agent 回答。 -- [ ] 编写演示步骤 9:手动触发 Hermes 任务。 -- [ ] 编写演示步骤 10:查看 AgentRun Trace。 -- [ ] 编写演示步骤 11:查看审计日志。 - -验收证据: - -- [ ] 新开发者按脚本可以复现演示。 - -## 9. 文档收尾 - -- [ ] 更新一周计划完成情况。 -- [ ] 更新剩余风险。 -- [ ] 更新下一阶段开发建议。 -- [ ] 更新接口清单。 -- [ ] 更新数据模型清单。 -- [ ] 更新前端页面清单。 -- [ ] 更新评测结果。 -- [ ] 更新演示脚本。 -- [ ] 更新部署或启动说明。 - -验收证据: - -- [ ] 文档能指导下一周继续开发。 - -## 10. 最终验收清单 - -- [ ] 任务规则中心可查看规则、技能、MCP、任务。 -- [ ] 规则详情可编辑 Markdown。 -- [ ] 规则详情可查看最近 5 个版本。 -- [ ] 版本切换有确认弹窗。 -- [ ] 审核者信息可见。 -- [ ] 未审核规则不能上线。 -- [ ] 语义本体 8 字段可返回。 -- [ ] Orchestrator 能路由用户请求。 -- [ ] Orchestrator 能路由定时任务。 -- [ ] User Agent 能回答至少 3 类财务问题。 -- [ ] Hermes 能执行至少 1 个任务。 -- [ ] OCR Mock 接入点可用。 -- [ ] 知识候选可生成。 -- [ ] 规则草稿可生成。 -- [ ] AgentRun Trace 可查。 -- [ ] AuditLog 可查。 -- [ ] 前端构建通过。 -- [ ] 后端核心测试通过。 -- [ ] 演示脚本可执行。 -- [ ] 所有完成项已用 `[x] ~~...~~` 标记。 - -## 测试记录 - -- [ ] 后端测试:未运行。 -- [ ] 前端构建:未运行。 -- [ ] 语义评测:未运行。 -- [ ] 手动验收:未运行。 - -## 阻塞记录 - -- [ ] 暂无。 - -## 日终交接 - -- [ ] 写明本周最终完成内容。 -- [ ] 写明未完成内容。 -- [ ] 写明生产化前必须补齐内容。 -- [ ] 写明下一周建议优先级。 diff --git a/document/development/agent/agent_week_plan_html/day-1.html b/document/development/agent/agent_week_plan_html/day-1.html deleted file mode 100644 index c215609..0000000 --- a/document/development/agent/agent_week_plan_html/day-1.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - Day 1 - 基础模型与工程骨架 - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
Foundation Completed
-

Day 1 基础模型与工程骨架

-

这一天的任务不是做炫目的业务能力,而是把后面 6 天要反复依赖的模型、版本、审核、run trace、审计日志和最小业务数据源一次定稳。Day 1 做虚了,Day 4 到 Day 6 会全部返工。

-
-
当前状态
已完成(2026-05-11),可直接进入 Day 2 联调。
-
上游依赖
无,Day 1 是全周底座。
-
下游交接
Day 2 资产 API,Day 3 解析日志,Day 4 run trace,Day 5/6 业务数据查询。
-
当天关键
先确定统一模型,再接 API 骨架和种子数据。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划里定义这一天要完成“工程地基”,强调只做稳定模型、API 骨架、种子数据、基础审计和可运行验证。

- -
-
-

执行细则

-

执行层把 Day 1 拆成命名边界、最小财务业务数据模型、Agent 资产模型、版本、审核、Run、ToolCall、SemanticParseLog、AuditLog、Schema、API、服务层。

- -
-
-

架构依据

-

主要受总体架构、语义本体、数据契约、能力注册、权限确认、可观测性和财务标准模型约束。

- -
-
- -
Build Order
-

推荐开发顺序

-
-
Step 1先确认后端目录、ORM、迁移方式、测试目录和不该碰的文件。
-
Step 2统一命名:资产类型、状态、审核状态、Agent、权限级别。
-
Step 3补最小财务业务数据模型:expense_claimsaccounts_receivableaccounts_payable
-
Step 4完成 AgentAsset、Version、Review、Run、ToolCall、ParseLog、AuditLog。
-
Step 5把 Schema、API 骨架、服务层、种子数据接起来。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

平台底座表

-
    -
  • AgentAssetAgentAssetVersionAgentAssetReview
  • -
  • AgentRunAgentToolCallSemanticParseLog
  • -
  • AuditLog
  • -
-
-
-

最小业务数据来源

-
    -
  • 报销至少有时间、地点、理由、金额、员工、部门、状态。
  • -
  • 应收至少有客户、金额、未收金额、到期日、账龄、状态。
  • -
  • 应付至少有供应商、金额、未付金额、到期日、账龄、状态。
  • -
-
-
-

API 骨架

-
    -
  • 资产列表 / 详情 / 版本 / 审核 / 上线。
  • -
  • 运行日志与审计日志查询。
  • -
  • 返回真实数据库结果,不用前端硬编码收尾。
  • -
-
-
-

统一服务边界

-
    -
  • 上线拦截逻辑在服务层,不堆到路由。
  • -
  • 所有写操作要留审计接口。
  • -
  • 任何 Agent 执行记录都必须生成 run_id
  • -
-
-
- -
Acceptance Snapshot
-

验收快照

-
-
资产模型
已落地 3 条规则、2 条技能、2 条 MCP、3 条任务,并可通过资产接口返回。
-
版本与审核
三条规则都具备版本历史;同一资产版本号不可重复,未审核规则不能上线。
-
运行与错误
`GET /api/v1/agent-runs` 可返回 3 条运行日志,任意新建 Run 自动生成 run_id
-
最小业务表
报销、应收、应付种子数据已就位,后续查询和风险巡检都有明确数据来源。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 只建 Agent 表,不建最小财务业务表,导致 User Agent 和 Hermes 后面无数据可查。
  • -
  • 把审核拦截塞在 API 路由里,后面很难复用到 Orchestrator 和别的入口。
  • -
  • 没有统一 run_id 和审计接口,Day 4 到 Day 7 的 Trace 会断链。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/day-2.html b/document/development/agent/agent_week_plan_html/day-2.html deleted file mode 100644 index 0cea4d5..0000000 --- a/document/development/agent/agent_week_plan_html/day-2.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - Day 2 - 任务规则中心联调 - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
Integration
-

Day 2 任务规则中心联调

-

Day 2 的核心不是“把页面做漂亮”,而是让规则、技能、MCP、任务这四类资产第一次脱离本地假数据,真正连到 Day 1 的数据库和 API。最关键的能力是 Markdown、版本、审核和上线约束闭环。

-
-
上游依赖
Day 1 的资产模型、版本模型、审核模型、资产 API。
-
下游交接
Day 3 要复用资产数据,Day 4 要查询 active 技能 / MCP / 任务。
-
当天关键
前端联调不是硬编码演示,而是可对接真实后端。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划要求把任务规则中心从静态 UI 升级到真实数据对接,覆盖规则、技能、MCP、任务四类资产。

- -
-
-

执行细则

-

执行层拆成 API Client、四类列表、规则详情、Markdown 编辑、版本卡片、审核与上线、技能详情、MCP 详情、任务详情、前端质量和当天验收。

- -
-
-

架构依据

-

这一天主要受能力注册、规则形成生命周期和数据治理约束,重点在四类资产的统一展示方式和规则上线前审核拦截。

- -
-
- -
Build Order
-

推荐开发顺序

-
-
Step 1先补 API Client:列表、详情、版本、保存、审核、上线、运行日志。
-
Step 2把四个页签的真实数据接起来,覆盖筛选、搜索、状态、空态和加载态。
-
Step 3把规则详情的 Hero 区、Markdown 编辑器、版本卡片和审核信息拉通。
-
Step 4补技能 / MCP / 任务的差异化详情,不复用规则编辑器。
-
Step 5最后收 UI 细节、错误态、禁用态、确认弹窗和构建验证。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

规则中心四页签

-
    -
  • 规则、技能、MCP、任务都能切换。
  • -
  • 每个页签都来自真实接口,不再只读本地常量。
  • -
  • 搜索和状态筛选同时生效。
  • -
-
-
-

规则详情闭环

-
    -
  • 能读取当前 Markdown。
  • -
  • 能保存并刷新版本列表。
  • -
  • 能展示审核者、审核状态、上线条件。
  • -
-
-
-

版本与上线约束

-
    -
  • 最近 5 个版本可见。
  • -
  • 切换旧版本必须弹确认框。
  • -
  • 未审核规则不能上线,拒绝原因要可见。
  • -
-
-
-

详情差异化

-
    -
  • 技能详情展示输入输出与依赖。
  • -
  • MCP 详情展示服务地址、鉴权、降级策略。
  • -
  • 任务详情展示 cron、执行 Agent、最近执行结果。
  • -
-
-
- -
Acceptance Snapshot
-

验收快照

-
-
真实数据
四个页签都能用真实后端数据渲染,后端不可用时有明确错误提示。
-
规则编辑
Markdown 保存后刷新页面仍在,保存失败不丢输入。
-
版本卡片
最近 5 个版本可切换,当前版本标识清楚但不造成布局位移。
-
审核上线
pending / rejected 规则都无法上线,approved 才能放行。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 只把规则页签接成真实数据,技能、MCP、任务仍然靠假数据撑场面。
  • -
  • 只做版本列表展示,不做确认弹窗和拒绝风险提示。
  • -
  • 把任务写成“定时任务”暴露给用户,违背文档里 UI 名称统一成“任务”的约束。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/day-3.html b/document/development/agent/agent_week_plan_html/day-3.html deleted file mode 100644 index 7946f9b..0000000 --- a/document/development/agent/agent_week_plan_html/day-3.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - Day 3 - 语义本体 MVP - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
Ontology
-

Day 3 语义本体 MVP

-

这一天把自然语言问题统一切成 8 个核心字段。Day 3 不是追求大模型多聪明,而是先让结构稳定、可落日志、可被 Orchestrator、User Agent 和 Hermes 共用。

-
-
上游依赖
Day 1 的 SemanticParseLog / AgentRun,Day 2 的资产 API。
-
下游交接
Day 4 路由、Day 5 查询解释、Day 6 风险巡检都直接消费这 8 字段。
-
当天关键
名字统一、类型统一、日志统一、低置信度有澄清问题。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划要求建立用户问题的统一语义解析层,覆盖场景、意图、对象、时间、指标、约束、风险、权限 8 字段。

- -
-
-

执行细则

-

执行层拆成 8 字段定义、字段枚举、Schema、解析服务、对象提取、时间范围、指标约束、风险权限、API、前端调试入口和评测集。

- -
-
-

架构依据

-

主要受语义本体、财务标准模型和数据治理约束。应收、应付、报销的对象语义必须能回到最小业务表和标准对象。

- -
-
- -
Build Order
-

推荐开发顺序

-
-
Step 1先固定 8 个字段名字、类型、默认值和示例。
-
Step 2scenariointentpermission.level 的枚举定死。
-
Step 3做请求/响应 Schema,再写解析服务。
-
Step 4补对象提取、时间范围、指标约束、风险和权限映射。
-
Step 5接 API、日志、调试入口和最小评测集。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

8 字段统一结构

-
    -
  • scenariointententitiestime_range
  • -
  • metricsconstraintsrisk_flagspermission
  • -
  • 附带 confidenceclarification_requiredrun_id
  • -
-
-
-

规则解析优先版

-
    -
  • 先用关键词和规则解析打底。
  • -
  • 报销 / 应收 / 应付 / 知识 / unknown 场景都能落到结构。
  • -
  • 越权动作能识别为 approval_requiredforbidden
  • -
-
-
-

日志和调试入口

-
    -
  • 每次解析都要落 SemanticParseLog
  • -
  • 前端可直接输入一句话看 8 字段结果。
  • -
  • 低置信度问题必须给澄清问题。
  • -
-
-
-

最小评测集

-
    -
  • 至少覆盖报销、应收、应付、知识、越权动作。
  • -
  • 每条样例要写期望 scenariointent 和权限级别。
  • -
  • 当天目标是可评测,而不是追求完美准确率。
  • -
-
-
- -
Acceptance Snapshot
-

验收快照

-
-
语义结构
8 字段在 Schema、服务层、日志里名字完全一致。
-
关键识别
“本周报销超标风险”“客户 A 本月应收”“供应商 B 明天要付多少钱”都能落到正确场景和意图。
-
权限结果
“帮我直接付款”不能被识别成可直接执行动作。
-
日志与前端
连续调用多次都能在日志中查到,并能通过调试入口观察结果。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 字段结构和日志结构各写一套名字,后面 Trace 很难串。
  • -
  • 只做 scenariointent,不做 permission,Day 4 会直接失去拦截依据。
  • -
  • 只在服务里返回结果,不把解析过程落库或落日志,后续无法复盘误判样例。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/day-4.html b/document/development/agent/agent_week_plan_html/day-4.html deleted file mode 100644 index 2307dc0..0000000 --- a/document/development/agent/agent_week_plan_html/day-4.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - Day 4 - Orchestrator 运行时 - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
Runtime
-

Day 4 Orchestrator 运行时

-

Day 4 把整个系统第一次串成“能跑的链”。用户消息和定时任务都先走 Orchestrator,由它创建 run、调用语义解析、做权限判断、选择 Agent、记录 ToolCall 和 Trace,然后再给下游执行。

-
-
上游依赖
Day 3 的语义解析结果,Day 1 的 Run / ToolCall,Day 2 的 active 资产。
-
下游交接
Day 5 User Agent 和 Day 6 Hermes 都通过它被调度。
-
当天关键
权限拦截和 Trace 必须在 Orchestrator 层,而不是散落在各 Agent。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划要求建立统一调度层,让用户请求和系统任务都先进入 Orchestrator,再根据语义、权限、能力注册路由到 User Agent、Hermes、MCP 或规则引擎。

- -
-
-

执行细则

-

执行层拆成输入输出、Orchestrator 服务、路由规则、权限判断、能力查询、工具调用封装、API、最小 Trace 查看和测试。

- -
-
-

架构依据

-

主要受运行时流程、能力注册、权限确认和可观测性约束。Day 4 的输出要能直接给前端展示,并支持 Day 5/6 的占位实现接入。

- -
-
- -
Build Order
-

推荐开发顺序

-
-
Step 1先定 OrchestratorRequestOrchestratorResponse
-
Step 2run(request) 主流程:创建 Run、解析语义、判权限、选 Agent、更新状态。
-
Step 3把用户入口 / 任务入口的路由规则固化下来。
-
Step 4封装工具调用记录和降级策略。
-
Step 5暴露 API 和最小 Trace 页面或接口。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

统一入口

-
    -
  • source=user_messagesource=schedule 都能进同一入口。
  • -
  • 请求返回 run_idselected_agentroute_reasonpermission_level
  • -
  • 返回结果要能被前端直接展示。
  • -
-
-
-

权限与路由

-
    -
  • 查询类走 User Agent,定时风险类走 Hermes。
  • -
  • approval_required 只返回确认,不直接执行。
  • -
  • forbidden 直接阻断,不调下游 Agent。
  • -
-
-
-

能力与工具调用

-
    -
  • 只查询 active 技能 / MCP / 任务。
  • -
  • 禁用能力不允许被调用。
  • -
  • 每次工具调用都能落 AgentToolCall
  • -
-
-
-

Trace 与降级

-
    -
  • Trace 能串起语义解析、路由、工具调用和最终结果。
  • -
  • 外部 MCP 失败要返回降级说明,不让前端拿到不可读错误。
  • -
  • 异常都要写进 AgentRun.error_message
  • -
-
-
- -
Acceptance Snapshot
-

验收快照

-
-
路由结果
同一句风险检查,在用户入口和任务入口会有不同路由结果。
-
权限边界
“直接上线规则”和“直接付款”都不会被自动执行。
-
日志完整度
每次运行至少有一条 AgentRun,工具调用有 0 到多条 AgentToolCall
-
可观察性
前端或 curl 可以完整看到一次运行链路,不需要直接查数据库猜过程。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 把权限判断放到 User Agent / Hermes 内部,导致系统没有统一边界。
  • -
  • 只记录成功 ToolCall,不记录失败 ToolCall,后面降级和排错会缺证据。
  • -
  • 路由能跑,但没有统一 Trace 输出,Day 7 演示时会非常难讲清链路。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/day-5.html b/document/development/agent/agent_week_plan_html/day-5.html deleted file mode 100644 index 2ccde41..0000000 --- a/document/development/agent/agent_week_plan_html/day-5.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - Day 5 - User Agent MVP - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
User Agent
-

Day 5 User Agent MVP

-

这一天开始让“用户真的能问问题”。但 User Agent 只负责查询、解释、规则引用和草稿生成,绝不绕过权限做审批、付款、上线这类高风险动作。

-
-
上游依赖
Day 4 Orchestrator、Day 3 语义结构、Day 1 业务数据与日志模型、Day 2 规则资产。
-
下游交接
Day 7 要拿它做问答演示、规则解释演示和草稿生成演示。
-
当天关键
回答可读、引用可追溯、草稿可确认、高风险不自动执行。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划要求做用户自然语言入口、报销 / 应收 / 应付查询解释、规则引用解释、建议草稿和前端入口。

- -
-
-

执行细则

-

执行层拆成输入输出、查询处理、规则解释、风险解释、草稿生成、知识库读取骨架、对话入口、安全边界和测试。

- -
-
-

架构依据

-

主要受 Agent 职责划分、运行时流程、知识架构和规则形成生命周期约束。所有高风险动作只能停留在建议或草稿层。

- -
-
- -
Build Order
-

推荐开发顺序

-
-
Step 1先定 UserAgentRequest / UserAgentResponse 协议。
-
Step 2优先实现报销、应收、应付查询处理器。
-
Step 3补规则解释和风险解释,让回答有依据而不是只给一句话。
-
Step 4补草稿生成与知识读取骨架。
-
Step 5最后接前端问答入口、加载态、错误态和确认提示。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

三类财务查询

-
    -
  • 报销查询可读,能查金额、状态或进度。
  • -
  • 应收查询可读,能查客户未收金额或账龄。
  • -
  • 应付查询可读,能查供应商待付款或付款状态。
  • -
-
-
-

解释能力

-
    -
  • 规则解释能引用 active 规则、版本号和更新时间。
  • -
  • 风险解释能说明风险类型、原因和建议动作。
  • -
  • 知识库不可用时要优雅降级。
  • -
-
-
-

草稿而非执行

-
    -
  • 可生成报销处理意见草稿、应收催收建议草稿、应付付款建议草稿。
  • -
  • 草稿必须写明“待人工确认”。
  • -
  • 草稿行为写入审计日志和 AgentRun 结果。
  • -
-
-
-

用户入口

-
    -
  • 前端输入框走 Orchestrator,不绕行。
  • -
  • 显示回答、引用、建议动作、确认提示和 run_id
  • -
  • 有加载态和错误态。
  • -
-
-
- -
Acceptance Snapshot
-

验收快照

-
-
问答闭环
用户在页面上能完成一次自然语言提问、拿到回答、看到引用和 run_id。
-
三类场景
至少报销、应收、应付三类财务问题都有结构化回答。
-
引用能力
“为什么这笔报销有风险”这类问题能引用规则,而不是只给模糊判断。
-
安全边界
“直接付款”“直接审批”类提示不会自动执行,只能变成建议或草稿。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 只返回原始查询数据,不把结果翻译成用户可读回答。
  • -
  • 只做草稿内容,不做 requires_confirmation 和审计日志。
  • -
  • 绕过 Orchestrator 直接从前端打 User Agent,导致 Day 4 的统一链路失效。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/day-6.html b/document/development/agent/agent_week_plan_html/day-6.html deleted file mode 100644 index 178fe9f..0000000 --- a/document/development/agent/agent_week_plan_html/day-6.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - Day 6 - Hermes MVP - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
Hermes
-

Day 6 Hermes MVP

-

Hermes 是后台数字员工,不做即时对话,而是负责定时巡检、风险预警、日报统计、知识候选和规则草稿。它的关键不是“会不会说”,而是“任务能不能跑、结果能不能追”。

-
-
上游依赖
Day 4 的 Orchestrator 路由,Day 1 的任务与日志表,Day 3 的语义结构,Day 5 可复用的风险/规则/知识接口。
-
下游交接
Day 7 要用它做手动触发任务、查看结果、展示规则草稿和知识候选。
-
当天关键
任务入口、风险项结构、OCR Mock、知识候选和规则草稿都必须可追溯。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划要求实现 Hermes 调度入口、每日风险巡检、统计任务、知识库维护、OCR Mock 和运行结果面板或 API。

- -
-
-

执行细则

-

执行层拆成输入输出、任务调度入口、风险巡检、每日统计、OCR 接入点、知识库维护、规则草稿形成、结果展示和测试。

- -
-
-

架构依据

-

主要受 Agent 职责、OCR 架构、知识库架构和反馈学习闭环约束。Hermes 能生成候选和草稿,但不能自动发布正式结果。

- -
-
- -
Build Order
-

推荐开发顺序

-
-
Step 1先定 HermesTaskRequest / HermesTaskResult
-
Step 2建立手动触发任务 API,经 Orchestrator 路由到 Hermes。
-
Step 3补风险巡检和每日统计的结构化输出。
-
Step 4接入 OCR Mock、知识候选生成、规则草稿生成。
-
Step 5补任务详情展示、错误信息和测试。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

任务调度入口

-
    -
  • 可手动触发至少一个任务资产。
  • -
  • 任务经 Orchestrator 进入 Hermes。
  • -
  • 结束后能更新最近执行时间和状态。
  • -
-
-
-

风险与统计

-
    -
  • 重复报销、金额超标、应收逾期、应付异常付款等风险有结构化输出。
  • -
  • 日报包含报销、报账、应收、应付的关键统计口径。
  • -
  • 每个风险项都要能被业务人员理解和追溯。
  • -
-
-
-

知识候选与规则草稿

-
    -
  • 知识候选默认是 draft,不能自动发布。
  • -
  • 规则草稿保存为 asset_type=rule,状态为 draft
  • -
  • 两类生成都要写审计日志。
  • -
-
-
-

OCR Mock 与结果展示

-
    -
  • OCR 服务接口和输入输出结构定下来。
  • -
  • 当前阶段允许完全使用 Mock 结果。
  • -
  • 任务详情或运行日志中能直接看到 Hermes 的执行结果。
  • -
-
-
- -
Acceptance Snapshot
-

验收快照

-
-
任务可触发
至少一个任务可以手动触发,并能查到结构化结果。
-
风险巡检
输出里能看到风险类型、业务对象、触发规则、建议动作和风险等级。
-
候选与草稿
知识候选和规则草稿都能生成,但都不是 active / published 正式状态。
-
可观察性
不用查数据库,也能从任务详情或运行日志判断 Hermes 是否执行成功。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 只做 Hermes 服务逻辑,不做任务入口和结果展示,最后无法演示。
  • -
  • 能生成知识或规则,但没把状态锁在 draft,会直接越过人工审核边界。
  • -
  • OCR Mock 只返回一段自由文本,不定义结构字段,后面无法和规则或风险逻辑对接。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/day-7.html b/document/development/agent/agent_week_plan_html/day-7.html deleted file mode 100644 index 60331f7..0000000 --- a/document/development/agent/agent_week_plan_html/day-7.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - Day 7 - 加固、演示和验收 - - - -
- - -
- Day 1 - Day 2 - Day 3 - Day 4 - Day 5 - Day 6 - Day 7 -
- -
-
Hardening
-

Day 7 加固、演示和验收

-

Day 7 不再追求新增大功能,而是把 Day 1 到 Day 6 的链路整理成“可演示、可验收、可继续接手”的状态。没有这一层收口,前面做出来的东西很容易停在“只有作者自己懂”的阶段。

-
-
上游依赖
Day 1 到 Day 6 的全部核心路径。
-
当天输出
回归记录、权限边界、审计和 Trace 补齐、测试记录、演示脚本、交接说明。
-
当天关键
冻结新增需求,只收验收相关缺口。
-
-
- -
Three-Layer Mapping
-

三层文档映射

-
-
-

路线图

-

周计划要求完成回归、权限补齐、审计补齐、错误态和空态、评测、演示数据、构建和交付说明。

- -
-
-

执行细则

-

执行层拆成核心链路回归、权限和风险边界、审计和 Trace、前端体验修补、测试补齐、评测集、演示数据、演示脚本和文档收尾。

- -
-
-

架构依据

-

主要受整体 README、开发路线图、可观测性和评测集约束。Day 7 的本质是把所有边界和证据讲清楚。

- -
-
- -
Build Order
-

推荐收口顺序

-
-
Step 1先汇总 Day 1 到 Day 6 未完成项,冻结新增需求。
-
Step 2回归核心链路:资产、规则、语义解析、Orchestrator、User Agent、Hermes、Trace、AuditLog。
-
Step 3补权限边界与高风险动作拦截。
-
Step 4补测试、评测、演示数据和前端体验问题。
-
Step 5写演示脚本和交接说明,形成最终交付。
-
- -
Must Deliver
-

今天必须产出的东西

-
-
-

回归与边界

-
    -
  • 未审核规则不能上线。
  • -
  • 付款、审批、上线等高风险动作都不能绕过确认。
  • -
  • disabled 能力不能被调用。
  • -
-
-
-

审计与 Trace

-
    -
  • 规则保存、审核、上线都能看到 AuditLog。
  • -
  • Hermes 生成知识候选 / 规则草稿有审计。
  • -
  • 任意演示路径都能追到 run_id
  • -
-
-
-

测试、评测、演示数据

-
    -
  • 后端测试、前端构建、语义评测至少有执行记录。
  • -
  • 报销 / 应收 / 应付 / 风险 / 知识都准备好演示数据。
  • -
  • 失败样例和已知边界要明确写出。
  • -
-
-
-

演示脚本与交接

-
    -
  • 从任务规则中心、规则详情、版本切换、上线拦截,到 User Agent 问答、Hermes 任务、Trace 和审计,都有明确步骤。
  • -
  • 新开发者按脚本能走通一遍。
  • -
-
-
- -
Acceptance Snapshot
-

最终验收快照

-
-
端到端链路
从规则中心到 User Agent,再到 Hermes 和 Trace,至少有一条完整演示路径可复现。
-
证据完整
AgentRun、ToolCall、AuditLog、测试记录、评测结果和演示脚本都存在。
-
风险边界
MVP 期间不存在绕过人工审核、自动付款、自动上线的暗门路径。
-
可交接性
下一位开发或 Codex 打开文档就能知道已完成、未完成和生产化前必补项。
-
- -
Common Misses
-

这一天最容易漏掉的点

-
    -
  • 只验证 Happy Path,不回归错误态、空态、禁用态和被权限拦截路径。
  • -
  • 能讲演示,但没有测试记录和已知风险说明,交接质量会很差。
  • -
  • 前 6 天的 TODO 没回写完成状态,导致页面和 Markdown 脱节。
  • -
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/index.html b/document/development/agent/agent_week_plan_html/index.html deleted file mode 100644 index 1877979..0000000 --- a/document/development/agent/agent_week_plan_html/index.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - Agent Week Plan HTML - - - -
- - -
-
Static Map
-

把 7 天周计划变成可直接浏览的开发视图

-

这一套 HTML 页面不是替代 Markdown,而是把 agent week planagent plan 的对应关系收成一个稳定入口。每天的路线图和执行清单现在已经并到同一份 daily 文档里。

-
-
-
阅读顺序
-
先总览,再选 Day,再跳转到具体 Markdown 落地执行。
-
-
-
核心视图
-
路线图、执行细则、架构依据三层同时可见。
-
-
-
适用对象
-
Codex 开发、后端开发、前端开发、项目 owner、验收人员。
-
-
-
- -
How To Use
-

怎么用这套页面

-
-
-

Codex 开发视角

-
    -
  1. 先看今天在哪一天,确认上游依赖和下游交接。
  2. -
  3. 用“两层映射”定位:daily 文档看目标和步骤,架构文档看约束。
  4. -
  5. 按“推荐开发顺序”推进,不跳天,不跨层乱做。
  6. -
  7. 完成后回到原始 Markdown,把 TODO、阻塞、交接更新回文档。
  8. -
-
-
-

人工开发与验收视角

-
    -
  1. 先看每一天的“今日定位”,知道这一天到底产出什么。
  2. -
  3. 再看“今天必须产出的东西”和“验收快照”,确认完成标准。
  4. -
  5. 最后跳转到对应 Markdown,逐条执行或验收。
  6. -
  7. 如果发现跨天阻塞,优先回前一天补地基,而不是在当前天临时兜底。
  8. -
-
-
- -
Three Layers
-

文档结构一眼看清

-
-
-

1. 周计划路线图

-

定义每天的大方向、交付物和验收门槛。用于排期、对齐和验收。核心入口是 MASTER_TODO.md 和 Day 1 到 Day 7 daily 文档。

- -
-
-

2. 每日执行清单

-

每天的开发目标已经拆到对应 daily 文档中的详细执行清单,直接覆盖模型、字段、接口、服务、前端、测试和验收证据。

- -
-
-

3. 架构依据

-

提供为什么要这么做、协议怎么定、权限和审计边界是什么。它不直接当 TODO,但所有实现都要受它约束。

- -
-
- -
Seven Days
-

7 天总览

-
-
-

Day 1 基础模型与工程骨架

-

当前状态:已完成(2026-05-11)。先把 Agent 资产、版本、审核、运行日志、审计日志,以及报销 / 应收 / 应付的最小业务数据来源定下来。后面所有能力都站在这一天的模型上。

- -
-
-

Day 2 任务规则中心联调

-

把规则、技能、MCP、任务从静态 UI 拉到真实后端数据。重点是规则 Markdown、版本切换、审核和上线拦截。

- -
-
-

Day 3 语义本体 MVP

-

建立 8 字段语义解析协议,让报销、应收、应付、知识查询进入同一结构,给 Orchestrator、User Agent、Hermes 统一消费。

- -
-
-

Day 4 Orchestrator 运行时

-

把用户消息和定时任务统一接到 Orchestrator,完成 run_id、权限拦截、Agent 路由、ToolCall 和 Trace。

- -
-
-

Day 5 User Agent MVP

-

面向用户的问答和流程辅助层。做查询、解释、规则引用、草稿生成,但严格不碰自动审批、自动付款和自动上线。

- -
-
-

Day 6 Hermes MVP

-

后台数字员工层。做任务触发、风险巡检、日报统计、OCR Mock、知识候选、规则草稿,结果都必须可追溯。

- -
-
-

Day 7 加固、演示和验收

-

不再大扩功能,只做回归、权限边界、审计、Trace、测试、演示脚本和交接收口,让整周产出可跑、可演示、可继续接手。

- -
-
- -
Dependency Chain
-

跨天依赖链

-
-
Day 1模型、审计、运行日志、最小业务数据源
-
Day 2把 Day 1 的资产 API 接进规则中心 UI
-
Day 3在 Day 1/2 基础上产出统一语义结构
-
Day 4用 Day 3 的语义结果完成路由与权限
-
Day 5接入 User Agent 问答、解释和草稿
-
Day 6接入 Hermes 任务、巡检和知识/规则候选
-
Day 7统一回归、补日志、做演示和交接
-
- - -
- - diff --git a/document/development/agent/agent_week_plan_html/styles.css b/document/development/agent/agent_week_plan_html/styles.css deleted file mode 100644 index 0db90b9..0000000 --- a/document/development/agent/agent_week_plan_html/styles.css +++ /dev/null @@ -1,426 +0,0 @@ -:root { - --bg: #f3ead9; - --bg-deep: #e7d8bc; - --panel: rgba(255, 250, 241, 0.9); - --panel-strong: #fff8ee; - --ink: #1f2a24; - --muted: #64655d; - --line: #dbc8a9; - --accent: #bb5b2c; - --accent-strong: #8d3d1b; - --accent-soft: #f4d9bf; - --teal: #20656d; - --teal-soft: #d8ecee; - --olive: #5f6b3a; - --olive-soft: #e6ecd7; - --shadow: 0 24px 60px rgba(84, 59, 30, 0.12); - --radius-xl: 28px; - --radius-lg: 20px; - --radius-md: 14px; - --max: 1240px; -} - -* { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - font-family: "Trebuchet MS", "Gill Sans", "Lucida Grande", sans-serif; - color: var(--ink); - background: - radial-gradient(circle at top left, rgba(32, 101, 109, 0.14), transparent 26%), - radial-gradient(circle at top right, rgba(187, 91, 44, 0.15), transparent 30%), - linear-gradient(180deg, #f8f0e2 0%, var(--bg) 40%, #efe2cb 100%); -} - -a { - color: inherit; -} - -.shell { - width: min(100% - 40px, var(--max)); - margin: 0 auto; - padding: 28px 0 56px; -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; - margin-bottom: 18px; -} - -.brand { - display: inline-flex; - align-items: center; - gap: 12px; - text-decoration: none; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--accent-strong); -} - -.brand-mark { - display: inline-flex; - align-items: center; - justify-content: center; - width: 42px; - height: 42px; - border-radius: 50%; - background: linear-gradient(135deg, var(--accent), #df9a44); - color: #fff7ef; - box-shadow: 0 14px 30px rgba(187, 91, 44, 0.28); -} - -.quick-links, -.day-nav { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 38px; - padding: 10px 14px; - border-radius: 999px; - border: 1px solid rgba(143, 114, 74, 0.22); - background: rgba(255, 248, 238, 0.75); - text-decoration: none; - color: var(--muted); - font-size: 14px; - transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; -} - -.pill:hover, -.pill:focus-visible { - transform: translateY(-1px); - border-color: rgba(187, 91, 44, 0.4); - background: rgba(255, 251, 245, 0.96); - outline: none; -} - -.pill.active { - color: #fff6ef; - border-color: transparent; - background: linear-gradient(135deg, var(--accent-strong), var(--accent)); - box-shadow: 0 14px 24px rgba(141, 61, 27, 0.24); -} - -.hero { - position: relative; - overflow: hidden; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgba(128, 109, 82, 0.18); - border-radius: var(--radius-xl); - background: - linear-gradient(135deg, rgba(255, 248, 238, 0.95), rgba(247, 236, 216, 0.88)), - var(--panel); - box-shadow: var(--shadow); -} - -.hero::after { - content: ""; - position: absolute; - right: -50px; - top: -50px; - width: 220px; - height: 220px; - border-radius: 50%; - background: radial-gradient(circle, rgba(32, 101, 109, 0.16), transparent 68%); -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - padding: 7px 12px; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent-strong); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.hero h1 { - margin: 0; - font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; - font-size: clamp(34px, 5vw, 62px); - line-height: 1.03; -} - -.hero p { - max-width: 880px; - margin: 14px 0 0; - color: var(--muted); - font-size: 18px; - line-height: 1.65; -} - -.hero-meta { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 14px; - margin-top: 20px; -} - -.meta-card { - padding: 14px 16px; - border-radius: var(--radius-md); - background: rgba(255, 255, 255, 0.55); - border: 1px solid rgba(132, 109, 83, 0.16); -} - -.meta-label { - margin-bottom: 6px; - color: var(--muted); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.meta-value { - font-size: 16px; - line-height: 1.45; -} - -.grid { - display: grid; - gap: 18px; -} - -.grid.two { - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); -} - -.grid.three { - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); -} - -.card { - padding: 22px; - border: 1px solid rgba(132, 109, 83, 0.15); - border-radius: var(--radius-lg); - background: var(--panel); - box-shadow: 0 16px 36px rgba(78, 58, 32, 0.08); - animation: rise 420ms ease both; -} - -.card:nth-child(2) { animation-delay: 60ms; } -.card:nth-child(3) { animation-delay: 120ms; } -.card:nth-child(4) { animation-delay: 180ms; } -.card:nth-child(5) { animation-delay: 240ms; } - -.card h2, -.card h3 { - margin: 0 0 10px; - font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; -} - -.card h2 { - font-size: 28px; -} - -.card h3 { - font-size: 22px; -} - -.card p { - margin: 0; - color: var(--muted); - line-height: 1.7; -} - -.section-title { - margin: 28px 0 14px; - font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; - font-size: 28px; -} - -.section-kicker { - margin: 30px 0 8px; - color: var(--accent-strong); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.list, -.compact-list { - margin: 12px 0 0; - padding-left: 18px; - color: var(--ink); - line-height: 1.72; -} - -.compact-list { - font-size: 15px; -} - -.list li + li, -.compact-list li + li { - margin-top: 8px; -} - -.card-links { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 16px; -} - -.link-chip { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 10px 13px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.76); - border: 1px solid rgba(132, 109, 83, 0.18); - text-decoration: none; - font-size: 14px; -} - -.tone-warm { - background: linear-gradient(180deg, rgba(244, 217, 191, 0.55), rgba(255, 250, 241, 0.9)); -} - -.tone-teal { - background: linear-gradient(180deg, rgba(216, 236, 238, 0.76), rgba(255, 250, 241, 0.92)); -} - -.tone-olive { - background: linear-gradient(180deg, rgba(230, 236, 215, 0.82), rgba(255, 250, 241, 0.92)); -} - -.tone-accent { - background: linear-gradient(160deg, rgba(141, 61, 27, 0.94), rgba(187, 91, 44, 0.92)); - color: #fff8f1; -} - -.tone-accent p, -.tone-accent .meta-label, -.tone-accent .meta-value, -.tone-accent li { - color: rgba(255, 248, 241, 0.92); -} - -.tone-accent .link-chip, -.tone-accent .pill { - background: rgba(255, 255, 255, 0.14); - border-color: rgba(255, 255, 255, 0.18); - color: #fff8f1; -} - -.timeline { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; -} - -.timeline-step { - position: relative; - padding: 16px; - border-radius: var(--radius-md); - border: 1px solid rgba(132, 109, 83, 0.16); - background: rgba(255, 252, 247, 0.84); -} - -.timeline-step strong { - display: block; - margin-bottom: 8px; - font-size: 15px; -} - -.footer { - margin-top: 26px; - padding: 20px 4px 0; - color: var(--muted); - font-size: 14px; -} - -.muted { - color: var(--muted); -} - -.table-like { - display: grid; - gap: 12px; -} - -.row { - display: grid; - grid-template-columns: minmax(120px, 0.9fr) minmax(0, 2.3fr); - gap: 14px; - padding: 14px 16px; - border-radius: var(--radius-md); - border: 1px solid rgba(132, 109, 83, 0.15); - background: rgba(255, 255, 255, 0.56); -} - -.row-label { - font-size: 13px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--accent-strong); -} - -.row-value { - line-height: 1.68; -} - -code { - padding: 1px 6px; - border-radius: 8px; - background: rgba(32, 101, 109, 0.08); - color: var(--teal); - font-family: "Lucida Console", "Courier New", monospace; - font-size: 0.92em; -} - -@keyframes rise { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 760px) { - .shell { - width: min(100% - 24px, var(--max)); - padding-top: 18px; - } - - .hero { - padding: 22px; - } - - .hero p { - font-size: 16px; - } - - .row { - grid-template-columns: 1fr; - } -} diff --git a/document/development/backend_api/README.md b/document/development/backend_api/README.md deleted file mode 100644 index 8ad8cb0..0000000 --- a/document/development/backend_api/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Backend API Swagger 文档 - -本目录用于沉淀后端接口的 Swagger / OpenAPI 产物,给开发、联调和后续 Agent 接口调用统一对照。 - -## 目录说明 - -- `openapi.json` - - 由 FastAPI `app.openapi()` 导出的完整 OpenAPI 规范。 -- `interface_inventory.md` - - 基于 OpenAPI 自动整理的接口清单,按 tag 分组查看方法、路径和摘要。 - -## 在线入口 - -- Swagger UI:`/docs` -- ReDoc:`/redoc` -- OpenAPI JSON:`/openapi.json` - -如果本地默认端口不变,完整地址通常是: - -- `http://127.0.0.1:8000/docs` -- `http://127.0.0.1:8000/redoc` -- `http://127.0.0.1:8000/openapi.json` - -## 重新生成 - -在 `/app/server` 下执行: - -```bash -PYTHONPATH=/app/server/src /app/server/.venv/bin/python /app/server/scripts/export_openapi.py -``` - -## 当前约定 - -- 全部业务接口前缀:`/api/v1` -- 知识库接口使用请求头模拟登录用户: - - `X-Auth-Username` - - `X-Auth-Name` - - `X-Auth-Role-Codes` - - `X-Auth-Is-Admin` -- Agent 资产写接口支持审计头: - - `X-Actor` - - `X-Request-Id` -- Hermes 运行时模型接口使用: - - `Authorization: Bearer ` diff --git a/document/development/backend_api/interface_inventory.md b/document/development/backend_api/interface_inventory.md deleted file mode 100644 index e103109..0000000 --- a/document/development/backend_api/interface_inventory.md +++ /dev/null @@ -1,100 +0,0 @@ -# Backend API Interface Inventory - -- Generated at: `2026-05-11 04:14:05 UTC` -- API title: `X-Financial` -- API version: `0.1.0` -- Total paths: `28` - -## Tag Overview - -### agent-assets - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/agent-assets` | 查询 Agent 资产列表 | -| `POST` | `/api/v1/agent-assets` | 创建 Agent 资产 | -| `GET` | `/api/v1/agent-assets/{asset_id}` | 读取 Agent 资产详情 | -| `PATCH` | `/api/v1/agent-assets/{asset_id}` | 更新 Agent 资产 | -| `POST` | `/api/v1/agent-assets/{asset_id}/activate` | 激活资产当前版本 | -| `POST` | `/api/v1/agent-assets/{asset_id}/reviews` | 创建资产审核记录 | -| `GET` | `/api/v1/agent-assets/{asset_id}/versions` | 查询资产版本列表 | -| `POST` | `/api/v1/agent-assets/{asset_id}/versions` | 创建资产版本 | - -### agent-runs - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/agent-runs` | 查询 Agent 运行日志 | -| `GET` | `/api/v1/agent-runs/{run_id}` | 读取单次 Agent 运行详情 | - -### audit-logs - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/audit-logs` | 查询审计日志 | - -### auth - -| Method | Path | Summary | -| --- | --- | --- | -| `POST` | `/api/v1/auth/login` | 用户登录 | - -### bootstrap - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/bootstrap` | 读取初始化状态 | -| `POST` | `/api/v1/bootstrap` | 写入初始化配置 | - -### employees - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/employees` | 查询员工列表 | -| `POST` | `/api/v1/employees` | 创建员工 | -| `GET` | `/api/v1/employees/meta` | 读取员工目录元数据 | -| `GET` | `/api/v1/employees/{employee_id}` | 读取员工详情 | -| `PATCH` | `/api/v1/employees/{employee_id}` | 更新员工 | -| `POST` | `/api/v1/employees/{employee_id}/disable` | 停用员工 | - -### health - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/health` | 服务健康检查 | - -### knowledge - -| Method | Path | Summary | -| --- | --- | --- | -| `POST` | `/api/v1/knowledge/documents` | 上传知识库文档 | -| `DELETE` | `/api/v1/knowledge/documents/{document_id}` | 删除知识库文档 | -| `GET` | `/api/v1/knowledge/documents/{document_id}` | 读取知识库文档详情 | -| `GET` | `/api/v1/knowledge/documents/{document_id}/content` | 下载或预览知识库原文 | -| `GET` | `/api/v1/knowledge/documents/{document_id}/onlyoffice-config` | 读取 ONLYOFFICE 预览配置 | -| `POST` | `/api/v1/knowledge/documents/{document_id}/onlyoffice/callback` | 接收 ONLYOFFICE 回调 | -| `GET` | `/api/v1/knowledge/documents/{document_id}/onlyoffice/content` | 读取 ONLYOFFICE 文档源文件 | -| `GET` | `/api/v1/knowledge/library` | 查询知识库目录 | - -### reimbursements - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/reimbursements` | 查询报销申请列表 | -| `POST` | `/api/v1/reimbursements` | 创建报销申请 | -| `GET` | `/api/v1/reimbursements/{request_id}` | 读取报销申请详情 | - -### root - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/` | 服务根检查 | - -### settings - -| Method | Path | Summary | -| --- | --- | --- | -| `GET` | `/api/v1/settings` | 读取系统设置 | -| `PUT` | `/api/v1/settings` | 保存系统设置 | -| `POST` | `/api/v1/settings/model-connectivity` | 测试模型连通性 | -| `GET` | `/api/v1/settings/runtime-models/{slot}` | 读取 Hermes 运行时模型配置 | diff --git a/document/development/backend_api/openapi.json b/document/development/backend_api/openapi.json deleted file mode 100644 index ca9afc2..0000000 --- a/document/development/backend_api/openapi.json +++ /dev/null @@ -1,6242 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "X-Financial", - "description": "X-Financial 后端 OpenAPI 文档。\n\n## 基本信息\n\n- 所有业务接口都挂在 `/api/v1` 前缀下。\n- 交互式 Swagger 页面默认位于 `/docs`,ReDoc 页面位于 `/redoc`。\n- 仓库内导出的静态规范文件位于 `document/development/backend_api/openapi.json`。\n\n## 鉴权约定\n\n- 知识库接口依赖以下请求头模拟当前用户:\n - `X-Auth-Username`\n - `X-Auth-Name`\n - `X-Auth-Role-Codes`\n - `X-Auth-Is-Admin`\n- Agent 资产写接口支持以下审计头:\n - `X-Actor`\n - `X-Request-Id`\n- Hermes 运行时模型配置接口需要:\n - `Authorization: Bearer `\n\n## 当前模块范围\n\n- 系统初始化与健康检查\n- 登录认证\n- 员工目录\n- 报销单\n- 知识库与 ONLYOFFICE\n- 系统设置与模型连通性\n- Agent 资产、运行日志、审计日志", - "version": "0.1.0" - }, - "paths": { - "/api/v1/health": { - "get": { - "tags": [ - "health" - ], - "summary": "服务健康检查", - "description": "检查服务基础状态,并在系统初始化完成后验证数据库连通性。", - "operationId": "health_check_api_v1_health_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthCheckRead" - } - } - } - } - } - } - }, - "/api/v1/bootstrap": { - "get": { - "tags": [ - "bootstrap" - ], - "summary": "读取初始化状态", - "description": "返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。", - "operationId": "get_bootstrap_state_api_v1_bootstrap_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootstrapStateRead" - } - } - } - } - } - }, - "post": { - "tags": [ - "bootstrap" - ], - "summary": "写入初始化配置", - "description": "保存系统初始化配置,并刷新运行时数据库连接。", - "operationId": "initialize_bootstrap_api_v1_bootstrap_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootstrapSetupPayload" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootstrapStateRead" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/login": { - "post": { - "tags": [ - "auth" - ], - "summary": "用户登录", - "description": "支持管理员账号和员工账号登录,成功后返回前端会话所需的用户信息。", - "operationId": "login_api_v1_auth_login_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponse" - } - } - } - }, - "401": { - "description": "账号或密码错误。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-assets": { - "get": { - "tags": [ - "agent-assets" - ], - "summary": "查询 Agent 资产列表", - "description": "按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。", - "operationId": "list_agent_assets_api_v1_agent_assets_get", - "parameters": [ - { - "name": "asset_type", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "资产类型:`rule`、`skill`、`mcp`、`task`。", - "title": "Asset Type" - }, - "description": "资产类型:`rule`、`skill`、`mcp`、`task`。" - }, - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "资产状态筛选。", - "title": "Status" - }, - "description": "资产状态筛选。" - }, - { - "name": "domain", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "业务领域筛选,例如 `expense`、`ar`、`ap`。", - "title": "Domain" - }, - "description": "业务领域筛选,例如 `expense`、`ar`、`ap`。" - }, - { - "name": "keyword", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "资产编码、名称关键字模糊查询。", - "title": "Keyword" - }, - "description": "资产编码、名称关键字模糊查询。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentAssetListItem" - }, - "title": "Response List Agent Assets Api V1 Agent Assets Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "agent-assets" - ], - "summary": "创建 Agent 资产", - "description": "创建新的规则、技能、MCP 或任务资产,并自动记录审计日志。", - "operationId": "create_agent_asset_api_v1_agent_assets_post", - "parameters": [ - { - "name": "x-actor", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。", - "title": "X-Actor" - }, - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。" - }, - { - "name": "x-request-id", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "外部请求 ID,用于串联审计日志和上游调用链。", - "title": "X-Request-Id" - }, - "description": "外部请求 ID,用于串联审计日志和上游调用链。" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetCreate" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetRead" - } - } - } - }, - "400": { - "description": "资产编码冲突或请求字段不合法。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-assets/{asset_id}": { - "get": { - "tags": [ - "agent-assets" - ], - "summary": "读取 Agent 资产详情", - "description": "返回资产当前版本正文、最近版本列表和最近一次审核信息。", - "operationId": "get_agent_asset_api_v1_agent_assets__asset_id__get", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Asset Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetRead" - } - } - } - }, - "404": { - "description": "资产不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "agent-assets" - ], - "summary": "更新 Agent 资产", - "description": "更新资产基础信息、当前版本、状态和配置,并写入审计日志。", - "operationId": "update_agent_asset_api_v1_agent_assets__asset_id__patch", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Asset Id" - } - }, - { - "name": "x-actor", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。", - "title": "X-Actor" - }, - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。" - }, - { - "name": "x-request-id", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "外部请求 ID,用于串联审计日志和上游调用链。", - "title": "X-Request-Id" - }, - "description": "外部请求 ID,用于串联审计日志和上游调用链。" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetUpdate" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetRead" - } - } - } - }, - "400": { - "description": "状态更新非法或请求字段不合法。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "资产或指定版本不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-assets/{asset_id}/versions": { - "get": { - "tags": [ - "agent-assets" - ], - "summary": "查询资产版本列表", - "description": "返回指定资产的版本历史,默认按最近版本优先排序。", - "operationId": "list_agent_asset_versions_api_v1_agent_assets__asset_id__versions_get", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Asset Id" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "description": "返回版本数量上限。", - "default": 20, - "title": "Limit" - }, - "description": "返回版本数量上限。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentAssetVersionRead" - }, - "title": "Response List Agent Asset Versions Api V1 Agent Assets Asset Id Versions Get" - } - } - } - }, - "404": { - "description": "资产不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "agent-assets" - ], - "summary": "创建资产版本", - "description": "为指定资产创建新版本;规则使用 Markdown,其他资产使用 JSON 快照。", - "operationId": "create_agent_asset_version_api_v1_agent_assets__asset_id__versions_post", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Asset Id" - } - }, - { - "name": "x-actor", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。", - "title": "X-Actor" - }, - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。" - }, - { - "name": "x-request-id", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "外部请求 ID,用于串联审计日志和上游调用链。", - "title": "X-Request-Id" - }, - "description": "外部请求 ID,用于串联审计日志和上游调用链。" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetVersionCreate" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetVersionRead" - } - } - } - }, - "400": { - "description": "版本号重复或内容类型不匹配。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "资产不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-assets/{asset_id}/reviews": { - "post": { - "tags": [ - "agent-assets" - ], - "summary": "创建资产审核记录", - "description": "为指定资产版本写入审核结果,并联动更新资产状态。", - "operationId": "create_agent_asset_review_api_v1_agent_assets__asset_id__reviews_post", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Asset Id" - } - }, - { - "name": "x-actor", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。", - "title": "X-Actor" - }, - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。" - }, - { - "name": "x-request-id", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "外部请求 ID,用于串联审计日志和上游调用链。", - "title": "X-Request-Id" - }, - "description": "外部请求 ID,用于串联审计日志和上游调用链。" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetReviewCreate" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetReviewRead" - } - } - } - }, - "400": { - "description": "审核参数不合法。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "资产或版本不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-assets/{asset_id}/activate": { - "post": { - "tags": [ - "agent-assets" - ], - "summary": "激活资产当前版本", - "description": "将资产当前版本切换为上线状态;规则资产必须已有 `approved` 审核记录。", - "operationId": "activate_agent_asset_api_v1_agent_assets__asset_id__activate_post", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Asset Id" - } - }, - { - "name": "x-actor", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。", - "title": "X-Actor" - }, - "description": "审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。" - }, - { - "name": "x-request-id", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "外部请求 ID,用于串联审计日志和上游调用链。", - "title": "X-Request-Id" - }, - "description": "外部请求 ID,用于串联审计日志和上游调用链。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentAssetRead" - } - } - } - }, - "400": { - "description": "审核未通过或当前版本未设置。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "资产不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-runs": { - "get": { - "tags": [ - "agent-runs" - ], - "summary": "查询 Agent 运行日志", - "description": "按 Agent、运行状态、来源和数量限制筛选运行日志。", - "operationId": "list_agent_runs_api_v1_agent_runs_get", - "parameters": [ - { - "name": "agent", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Agent 名称筛选。", - "title": "Agent" - }, - "description": "Agent 名称筛选。" - }, - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "运行状态筛选。", - "title": "Status" - }, - "description": "运行状态筛选。" - }, - { - "name": "source", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "运行来源筛选。", - "title": "Source" - }, - "description": "运行来源筛选。" - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "description": "返回记录上限。", - "default": 20, - "title": "Limit" - }, - "description": "返回记录上限。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentRunRead" - }, - "title": "Response List Agent Runs Api V1 Agent Runs Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/agent-runs/{run_id}": { - "get": { - "tags": [ - "agent-runs" - ], - "summary": "读取单次 Agent 运行详情", - "description": "按 `run_id` 返回单次执行的路由结果、工具调用和语义解析信息。", - "operationId": "get_agent_run_api_v1_agent_runs__run_id__get", - "parameters": [ - { - "name": "run_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Run Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentRunRead" - } - } - } - }, - "404": { - "description": "运行记录不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/audit-logs": { - "get": { - "tags": [ - "audit-logs" - ], - "summary": "查询审计日志", - "description": "按资源类型、资源 ID、动作类型和数量限制筛选审计日志。", - "operationId": "list_audit_logs_api_v1_audit_logs_get", - "parameters": [ - { - "name": "resource_type", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "资源类型筛选,例如 `rule`、`task`。", - "title": "Resource Type" - }, - "description": "资源类型筛选,例如 `rule`、`task`。" - }, - { - "name": "resource_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "资源主键或业务编码筛选。", - "title": "Resource Id" - }, - "description": "资源主键或业务编码筛选。" - }, - { - "name": "action", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "动作名称筛选。", - "title": "Action" - }, - "description": "动作名称筛选。" - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 200, - "minimum": 1, - "description": "返回日志上限。", - "default": 50, - "title": "Limit" - }, - "description": "返回日志上限。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AuditLogRead" - }, - "title": "Response List Audit Logs Api V1 Audit Logs Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/library": { - "get": { - "tags": [ - "knowledge" - ], - "summary": "查询知识库目录", - "description": "返回固定知识库目录与当前已上传文档列表。", - "operationId": "get_knowledge_library_api_v1_knowledge_library_get", - "parameters": [ - { - "name": "x-auth-username", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。", - "title": "X-Auth-Username" - }, - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。" - }, - { - "name": "x-auth-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录人展示姓名。未传时默认回退到用户名。", - "title": "X-Auth-Name" - }, - "description": "当前登录人展示姓名。未传时默认回退到用户名。" - }, - { - "name": "x-auth-role-codes", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。", - "title": "X-Auth-Role-Codes" - }, - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。" - }, - { - "name": "x-auth-is-admin", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "是否管理员,支持 `true/false/1/0`。", - "title": "X-Auth-Is-Admin" - }, - "description": "是否管理员,支持 `true/false/1/0`。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeLibraryRead" - } - } - } - }, - "401": { - "description": "未提供知识库访问用户头。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/documents/{document_id}": { - "get": { - "tags": [ - "knowledge" - ], - "summary": "读取知识库文档详情", - "description": "返回单个知识库文档的元信息、预览类型和预览内容。", - "operationId": "get_knowledge_document_api_v1_knowledge_documents__document_id__get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "x-auth-username", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。", - "title": "X-Auth-Username" - }, - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。" - }, - { - "name": "x-auth-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录人展示姓名。未传时默认回退到用户名。", - "title": "X-Auth-Name" - }, - "description": "当前登录人展示姓名。未传时默认回退到用户名。" - }, - { - "name": "x-auth-role-codes", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。", - "title": "X-Auth-Role-Codes" - }, - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。" - }, - { - "name": "x-auth-is-admin", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "是否管理员,支持 `true/false/1/0`。", - "title": "X-Auth-Is-Admin" - }, - "description": "是否管理员,支持 `true/false/1/0`。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeDocumentDetailRead" - } - } - } - }, - "401": { - "description": "未提供知识库访问用户头。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "知识库文件不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "knowledge" - ], - "summary": "删除知识库文档", - "description": "删除知识库文档及其索引记录。", - "operationId": "delete_knowledge_document_api_v1_knowledge_documents__document_id__delete", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "x-auth-username", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。", - "title": "X-Auth-Username" - }, - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。" - }, - { - "name": "x-auth-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录人展示姓名。未传时默认回退到用户名。", - "title": "X-Auth-Name" - }, - "description": "当前登录人展示姓名。未传时默认回退到用户名。" - }, - { - "name": "x-auth-role-codes", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。", - "title": "X-Auth-Role-Codes" - }, - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。" - }, - { - "name": "x-auth-is-admin", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "是否管理员,支持 `true/false/1/0`。", - "title": "X-Auth-Is-Admin" - }, - "description": "是否管理员,支持 `true/false/1/0`。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeActionResponse" - } - } - } - }, - "401": { - "description": "未提供知识库访问用户头。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "只有管理员可以删除知识库文件。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "知识库文件不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/documents/{document_id}/onlyoffice-config": { - "get": { - "tags": [ - "knowledge" - ], - "summary": "读取 ONLYOFFICE 预览配置", - "description": "为支持的 Office 文档生成 ONLYOFFICE 前端配置和临时访问令牌。", - "operationId": "get_knowledge_document_onlyoffice_config_api_v1_knowledge_documents__document_id__onlyoffice_config_get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "x-auth-username", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。", - "title": "X-Auth-Username" - }, - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。" - }, - { - "name": "x-auth-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录人展示姓名。未传时默认回退到用户名。", - "title": "X-Auth-Name" - }, - "description": "当前登录人展示姓名。未传时默认回退到用户名。" - }, - { - "name": "x-auth-role-codes", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。", - "title": "X-Auth-Role-Codes" - }, - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。" - }, - { - "name": "x-auth-is-admin", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "是否管理员,支持 `true/false/1/0`。", - "title": "X-Auth-Is-Admin" - }, - "description": "是否管理员,支持 `true/false/1/0`。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeOnlyOfficeConfigRead" - } - } - } - }, - "400": { - "description": "ONLYOFFICE 未启用、配置不完整或文件格式不支持。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "未提供知识库访问用户头。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "知识库文件不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/documents": { - "post": { - "tags": [ - "knowledge" - ], - "summary": "上传知识库文档", - "description": "上传原始文件二进制内容到指定知识库目录。已有同名文件会覆盖并提升版本号。", - "operationId": "upload_knowledge_document_api_v1_knowledge_documents_post", - "parameters": [ - { - "name": "folder", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1, - "description": "目标知识库目录名称。", - "title": "Folder" - }, - "description": "目标知识库目录名称。" - }, - { - "name": "filename", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1, - "description": "原始文件名。", - "title": "Filename" - }, - "description": "原始文件名。" - }, - { - "name": "x-auth-username", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。", - "title": "X-Auth-Username" - }, - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。" - }, - { - "name": "x-auth-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录人展示姓名。未传时默认回退到用户名。", - "title": "X-Auth-Name" - }, - "description": "当前登录人展示姓名。未传时默认回退到用户名。" - }, - { - "name": "x-auth-role-codes", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。", - "title": "X-Auth-Role-Codes" - }, - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。" - }, - { - "name": "x-auth-is-admin", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "是否管理员,支持 `true/false/1/0`。", - "title": "X-Auth-Is-Admin" - }, - "description": "是否管理员,支持 `true/false/1/0`。" - } - ], - "requestBody": { - "required": true, - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "contentMediaType": "application/octet-stream", - "description": "待上传的文件二进制内容。", - "title": "Content" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeDocumentDetailRead" - } - } - } - }, - "400": { - "description": "目录、文件名或文件内容不合法。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "未提供知识库访问用户头。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "只有管理员可以上传知识库文件。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/documents/{document_id}/content": { - "get": { - "tags": [ - "knowledge" - ], - "summary": "下载或预览知识库原文", - "description": "根据文档 ID 返回原始文件内容,可用于浏览器内联预览或下载。", - "operationId": "get_knowledge_document_content_api_v1_knowledge_documents__document_id__content_get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "disposition", - "in": "query", - "required": false, - "schema": { - "type": "string", - "pattern": "^(inline|attachment)$", - "description": "内容展示方式,支持 `inline` 或 `attachment`。", - "default": "inline", - "title": "Disposition" - }, - "description": "内容展示方式,支持 `inline` 或 `attachment`。" - }, - { - "name": "x-auth-username", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。", - "title": "X-Auth-Username" - }, - "description": "当前登录用户名。知识库接口至少需要提供用户名或姓名。" - }, - { - "name": "x-auth-name", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "当前登录人展示姓名。未传时默认回退到用户名。", - "title": "X-Auth-Name" - }, - "description": "当前登录人展示姓名。未传时默认回退到用户名。" - }, - { - "name": "x-auth-role-codes", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。", - "title": "X-Auth-Role-Codes" - }, - "description": "角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。" - }, - { - "name": "x-auth-is-admin", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "是否管理员,支持 `true/false/1/0`。", - "title": "X-Auth-Is-Admin" - }, - "description": "是否管理员,支持 `true/false/1/0`。" - } - ], - "responses": { - "200": { - "description": "文件内容。", - "content": { - "application/octet-stream": {} - } - }, - "401": { - "description": "未提供知识库访问用户头。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "知识库文件不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/documents/{document_id}/onlyoffice/content": { - "get": { - "tags": [ - "knowledge" - ], - "summary": "读取 ONLYOFFICE 文档源文件", - "description": "供 ONLYOFFICE 服务通过短时访问令牌拉取原始文件内容。", - "operationId": "get_knowledge_document_onlyoffice_content_api_v1_knowledge_documents__document_id__onlyoffice_content_get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "access_token", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1, - "description": "ONLYOFFICE 临时访问令牌。", - "title": "Access Token" - }, - "description": "ONLYOFFICE 临时访问令牌。" - } - ], - "responses": { - "200": { - "description": "文件内容。", - "content": { - "application/octet-stream": {} - } - }, - "401": { - "description": "ONLYOFFICE 访问令牌无效。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "知识库文件不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/knowledge/documents/{document_id}/onlyoffice/callback": { - "post": { - "tags": [ - "knowledge" - ], - "summary": "接收 ONLYOFFICE 回调", - "description": "接收 ONLYOFFICE 文档回写回调,在状态满足要求时更新知识库文件内容。", - "operationId": "handle_knowledge_document_onlyoffice_callback_api_v1_knowledge_documents__document_id__onlyoffice_callback_post", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeOnlyOfficeCallbackWrite" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/KnowledgeOnlyOfficeCallbackRead" - } - } - } - }, - "400": { - "description": "回调载荷不合法。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "知识库文件不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/employees/meta": { - "get": { - "tags": [ - "employees" - ], - "summary": "读取员工目录元数据", - "description": "返回员工总数、状态汇总和可选角色列表,供员工管理页面初始化使用。", - "operationId": "get_employee_meta_api_v1_employees_meta_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeMetaRead" - } - } - } - } - } - } - }, - "/api/v1/employees": { - "get": { - "tags": [ - "employees" - ], - "summary": "查询员工列表", - "description": "按状态和关键字筛选员工目录。", - "operationId": "list_employees_api_v1_employees_get", - "parameters": [ - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "员工状态筛选值。", - "title": "Status" - }, - "description": "员工状态筛选值。" - }, - { - "name": "keyword", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "姓名、工号、邮箱等关键字模糊查询。", - "title": "Keyword" - }, - "description": "姓名、工号、邮箱等关键字模糊查询。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EmployeeRead" - }, - "title": "Response List Employees Api V1 Employees Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "employees" - ], - "summary": "创建员工", - "description": "创建新的员工目录记录,并初始化基础角色与组织归属。", - "operationId": "create_employee_api_v1_employees_post", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeCreate" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeRead" - } - } - } - }, - "400": { - "description": "员工数据校验失败。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/employees/{employee_id}": { - "get": { - "tags": [ - "employees" - ], - "summary": "读取员工详情", - "description": "根据员工主键读取员工完整档案信息。", - "operationId": "get_employee_api_v1_employees__employee_id__get", - "parameters": [ - { - "name": "employee_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Employee Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeRead" - } - } - } - }, - "404": { - "description": "员工不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "employees" - ], - "summary": "更新员工", - "description": "更新员工基础信息、角色、密码等可维护字段。", - "operationId": "update_employee_api_v1_employees__employee_id__patch", - "parameters": [ - { - "name": "employee_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Employee Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeUpdate" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeRead" - } - } - } - }, - "400": { - "description": "请求字段不合法。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "员工不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/employees/{employee_id}/disable": { - "post": { - "tags": [ - "employees" - ], - "summary": "停用员工", - "description": "将员工状态切换为停用,阻止其继续登录系统。", - "operationId": "disable_employee_api_v1_employees__employee_id__disable_post", - "parameters": [ - { - "name": "employee_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Employee Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeRead" - } - } - } - }, - "404": { - "description": "员工不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/reimbursements": { - "get": { - "tags": [ - "reimbursements" - ], - "summary": "查询报销申请列表", - "description": "返回当前系统中的报销申请列表。", - "operationId": "list_reimbursements_api_v1_reimbursements_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ReimbursementRead" - }, - "type": "array", - "title": "Response List Reimbursements Api V1 Reimbursements Get" - } - } - } - } - } - }, - "post": { - "tags": [ - "reimbursements" - ], - "summary": "创建报销申请", - "description": "创建一条新的报销申请记录,初始状态为 `draft`。", - "operationId": "create_reimbursement_api_v1_reimbursements_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReimbursementCreate" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReimbursementRead" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/reimbursements/{request_id}": { - "get": { - "tags": [ - "reimbursements" - ], - "summary": "读取报销申请详情", - "description": "根据报销申请主键读取单据详情。", - "operationId": "get_reimbursement_api_v1_reimbursements__request_id__get", - "parameters": [ - { - "name": "request_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Request Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReimbursementRead" - } - } - } - }, - "404": { - "description": "报销申请不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/settings": { - "get": { - "tags": [ - "settings" - ], - "summary": "读取系统设置", - "description": "返回公司、管理员、模型、日志、邮件和 ONLYOFFICE 的设置快照。", - "operationId": "get_settings_api_v1_settings_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SettingsRead" - } - } - } - } - } - }, - "put": { - "tags": [ - "settings" - ], - "summary": "保存系统设置", - "description": "保存系统设置,并同步运行时模型配置与 Hermes 使用的模型路由。", - "operationId": "update_settings_api_v1_settings_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SettingsWrite" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SettingsRead" - } - } - } - }, - "400": { - "description": "设置字段校验失败。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/settings/model-connectivity": { - "post": { - "tags": [ - "settings" - ], - "summary": "测试模型连通性", - "description": "验证指定模型服务端点是否可用;当未传 API Key 且提供 slot 时会尝试复用已保存密钥。", - "operationId": "test_model_connectivity_api_v1_settings_model_connectivity_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelConnectivityTestRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelConnectivityTestRead" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/settings/runtime-models/{slot}": { - "get": { - "tags": [ - "settings" - ], - "summary": "读取 Hermes 运行时模型配置", - "description": "供 Hermes 进程读取主模型、备用模型、VLM 或 Embedding 模型的运行时配置。", - "operationId": "get_runtime_model_config_api_v1_settings_runtime_models__slot__get", - "parameters": [ - { - "name": "slot", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Slot" - } - }, - { - "name": "authorization", - "in": "header", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Hermes 读取运行时模型配置时使用的 Bearer Token。", - "title": "Authorization" - }, - "description": "Hermes 读取运行时模型配置时使用的 Bearer Token。" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RuntimeModelConfigRead" - } - } - } - }, - "401": { - "description": "Hermes 令牌校验失败。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "指定模型槽位不存在。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "503": { - "description": "Hermes 集成令牌尚未配置。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/": { - "get": { - "tags": [ - "root" - ], - "summary": "服务根检查", - "description": "用于快速确认后端服务进程已经启动。", - "operationId": "root__get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RootStatusRead" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AgentAssetContentType": { - "type": "string", - "enum": [ - "markdown", - "json" - ], - "title": "AgentAssetContentType" - }, - "AgentAssetCreate": { - "properties": { - "asset_type": { - "$ref": "#/components/schemas/AgentAssetType" - }, - "code": { - "type": "string", - "maxLength": 100, - "minLength": 1, - "title": "Code" - }, - "name": { - "type": "string", - "maxLength": 200, - "minLength": 1, - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description", - "default": "" - }, - "domain": { - "$ref": "#/components/schemas/AgentAssetDomain" - }, - "scenario_json": { - "items": {}, - "type": "array", - "title": "Scenario Json" - }, - "owner": { - "type": "string", - "maxLength": 100, - "minLength": 1, - "title": "Owner" - }, - "reviewer": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ], - "title": "Reviewer" - }, - "status": { - "$ref": "#/components/schemas/AgentAssetStatus", - "default": "draft" - }, - "config_json": { - "additionalProperties": true, - "type": "object", - "title": "Config Json" - } - }, - "type": "object", - "required": [ - "asset_type", - "code", - "name", - "domain", - "owner" - ], - "title": "AgentAssetCreate" - }, - "AgentAssetDomain": { - "type": "string", - "enum": [ - "expense", - "ar", - "ap", - "knowledge", - "system" - ], - "title": "AgentAssetDomain" - }, - "AgentAssetListItem": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "asset_type": { - "type": "string", - "title": "Asset Type" - }, - "code": { - "type": "string", - "title": "Code" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "domain": { - "type": "string", - "title": "Domain" - }, - "scenario_json": { - "items": {}, - "type": "array", - "title": "Scenario Json" - }, - "owner": { - "type": "string", - "title": "Owner" - }, - "reviewer": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reviewer" - }, - "status": { - "type": "string", - "title": "Status" - }, - "current_version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Current Version" - }, - "config_json": { - "additionalProperties": true, - "type": "object", - "title": "Config Json" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" - } - }, - "type": "object", - "required": [ - "id", - "asset_type", - "code", - "name", - "description", - "domain", - "scenario_json", - "owner", - "reviewer", - "status", - "current_version", - "config_json", - "created_at", - "updated_at" - ], - "title": "AgentAssetListItem" - }, - "AgentAssetRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "asset_type": { - "type": "string", - "title": "Asset Type" - }, - "code": { - "type": "string", - "title": "Code" - }, - "name": { - "type": "string", - "title": "Name" - }, - "description": { - "type": "string", - "title": "Description" - }, - "domain": { - "type": "string", - "title": "Domain" - }, - "scenario_json": { - "items": {}, - "type": "array", - "title": "Scenario Json" - }, - "owner": { - "type": "string", - "title": "Owner" - }, - "reviewer": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reviewer" - }, - "status": { - "type": "string", - "title": "Status" - }, - "current_version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Current Version" - }, - "config_json": { - "additionalProperties": true, - "type": "object", - "title": "Config Json" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" - }, - "current_version_content": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "title": "Current Version Content" - }, - "current_version_content_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Current Version Content Type" - }, - "current_version_change_note": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Current Version Change Note" - }, - "recent_versions": { - "items": { - "$ref": "#/components/schemas/AgentAssetVersionRead" - }, - "type": "array", - "title": "Recent Versions" - }, - "latest_review": { - "anyOf": [ - { - "$ref": "#/components/schemas/AgentAssetReviewRead" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "id", - "asset_type", - "code", - "name", - "description", - "domain", - "scenario_json", - "owner", - "reviewer", - "status", - "current_version", - "config_json", - "created_at", - "updated_at" - ], - "title": "AgentAssetRead" - }, - "AgentAssetReviewCreate": { - "properties": { - "version": { - "type": "string", - "maxLength": 30, - "minLength": 1, - "title": "Version" - }, - "reviewer": { - "type": "string", - "maxLength": 100, - "minLength": 1, - "title": "Reviewer" - }, - "review_status": { - "$ref": "#/components/schemas/AgentReviewStatus" - }, - "review_note": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Review Note" - } - }, - "type": "object", - "required": [ - "version", - "reviewer", - "review_status" - ], - "title": "AgentAssetReviewCreate" - }, - "AgentAssetReviewRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "asset_id": { - "type": "string", - "title": "Asset Id" - }, - "version": { - "type": "string", - "title": "Version" - }, - "reviewer": { - "type": "string", - "title": "Reviewer" - }, - "review_status": { - "type": "string", - "title": "Review Status" - }, - "review_note": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Review Note" - }, - "reviewed_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Reviewed At" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "asset_id", - "version", - "reviewer", - "review_status", - "review_note", - "reviewed_at", - "created_at" - ], - "title": "AgentAssetReviewRead" - }, - "AgentAssetStatus": { - "type": "string", - "enum": [ - "draft", - "review", - "active", - "disabled" - ], - "title": "AgentAssetStatus" - }, - "AgentAssetType": { - "type": "string", - "enum": [ - "rule", - "skill", - "mcp", - "task" - ], - "title": "AgentAssetType" - }, - "AgentAssetUpdate": { - "properties": { - "name": { - "anyOf": [ - { - "type": "string", - "maxLength": 200, - "minLength": 1 - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "domain": { - "anyOf": [ - { - "$ref": "#/components/schemas/AgentAssetDomain" - }, - { - "type": "null" - } - ] - }, - "scenario_json": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Scenario Json" - }, - "owner": { - "anyOf": [ - { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - { - "type": "null" - } - ], - "title": "Owner" - }, - "reviewer": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ], - "title": "Reviewer" - }, - "status": { - "anyOf": [ - { - "$ref": "#/components/schemas/AgentAssetStatus" - }, - { - "type": "null" - } - ] - }, - "current_version": { - "anyOf": [ - { - "type": "string", - "maxLength": 30 - }, - { - "type": "null" - } - ], - "title": "Current Version" - }, - "config_json": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Config Json" - } - }, - "type": "object", - "title": "AgentAssetUpdate" - }, - "AgentAssetVersionCreate": { - "properties": { - "version": { - "type": "string", - "maxLength": 30, - "minLength": 1, - "title": "Version" - }, - "content": { - "title": "Content" - }, - "content_type": { - "$ref": "#/components/schemas/AgentAssetContentType" - }, - "change_note": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Change Note" - }, - "created_by": { - "type": "string", - "maxLength": 100, - "minLength": 1, - "title": "Created By" - } - }, - "type": "object", - "required": [ - "version", - "content", - "content_type", - "created_by" - ], - "title": "AgentAssetVersionCreate" - }, - "AgentAssetVersionRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "asset_id": { - "type": "string", - "title": "Asset Id" - }, - "version": { - "type": "string", - "title": "Version" - }, - "content": { - "title": "Content" - }, - "content_type": { - "type": "string", - "title": "Content Type" - }, - "change_note": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Change Note" - }, - "created_by": { - "type": "string", - "title": "Created By" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "is_current": { - "type": "boolean", - "title": "Is Current", - "default": false - } - }, - "type": "object", - "required": [ - "id", - "asset_id", - "version", - "content", - "content_type", - "change_note", - "created_by", - "created_at" - ], - "title": "AgentAssetVersionRead" - }, - "AgentReviewStatus": { - "type": "string", - "enum": [ - "pending", - "approved", - "rejected" - ], - "title": "AgentReviewStatus" - }, - "AgentRunRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "run_id": { - "type": "string", - "title": "Run Id" - }, - "agent": { - "type": "string", - "title": "Agent" - }, - "source": { - "type": "string", - "title": "Source" - }, - "user_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, - "task_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Task Id" - }, - "ontology_json": { - "additionalProperties": true, - "type": "object", - "title": "Ontology Json" - }, - "route_json": { - "additionalProperties": true, - "type": "object", - "title": "Route Json" - }, - "permission_level": { - "type": "string", - "title": "Permission Level" - }, - "status": { - "type": "string", - "title": "Status" - }, - "result_summary": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Result Summary" - }, - "error_message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Message" - }, - "started_at": { - "type": "string", - "format": "date-time", - "title": "Started At" - }, - "finished_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Finished At" - }, - "tool_calls": { - "items": { - "$ref": "#/components/schemas/AgentToolCallRead" - }, - "type": "array", - "title": "Tool Calls" - }, - "semantic_parse": { - "anyOf": [ - { - "$ref": "#/components/schemas/SemanticParseRead" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "id", - "run_id", - "agent", - "source", - "user_id", - "task_id", - "ontology_json", - "route_json", - "permission_level", - "status", - "result_summary", - "error_message", - "started_at", - "finished_at" - ], - "title": "AgentRunRead" - }, - "AgentToolCallRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "run_id": { - "type": "string", - "title": "Run Id" - }, - "tool_type": { - "type": "string", - "title": "Tool Type" - }, - "tool_name": { - "type": "string", - "title": "Tool Name" - }, - "request_json": { - "additionalProperties": true, - "type": "object", - "title": "Request Json" - }, - "response_json": { - "additionalProperties": true, - "type": "object", - "title": "Response Json" - }, - "status": { - "type": "string", - "title": "Status" - }, - "duration_ms": { - "type": "integer", - "title": "Duration Ms" - }, - "error_message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Message" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "run_id", - "tool_type", - "tool_name", - "request_json", - "response_json", - "status", - "duration_ms", - "error_message", - "created_at" - ], - "title": "AgentToolCallRead" - }, - "AuditLogRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "actor": { - "type": "string", - "title": "Actor" - }, - "action": { - "type": "string", - "title": "Action" - }, - "resource_type": { - "type": "string", - "title": "Resource Type" - }, - "resource_id": { - "type": "string", - "title": "Resource Id" - }, - "before_json": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Before Json" - }, - "after_json": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "After Json" - }, - "request_id": { - "type": "string", - "title": "Request Id" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "actor", - "action", - "resource_type", - "resource_id", - "before_json", - "after_json", - "request_id", - "created_at" - ], - "title": "AuditLogRead" - }, - "AuthUserRead": { - "properties": { - "username": { - "type": "string", - "title": "Username" - }, - "name": { - "type": "string", - "title": "Name" - }, - "role": { - "type": "string", - "title": "Role" - }, - "roleCodes": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Rolecodes" - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "string" - } - ], - "title": "Email" - }, - "avatar": { - "type": "string", - "title": "Avatar" - }, - "isAdmin": { - "type": "boolean", - "title": "Isadmin", - "default": false - } - }, - "type": "object", - "required": [ - "username", - "name", - "role", - "email", - "avatar" - ], - "title": "AuthUserRead" - }, - "BootstrapCacheRead": { - "properties": { - "enabled": { - "type": "boolean", - "title": "Enabled" - }, - "url": { - "type": "string", - "title": "Url" - } - }, - "type": "object", - "required": [ - "enabled", - "url" - ], - "title": "BootstrapCacheRead" - }, - "BootstrapCompanyRead": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "code": { - "type": "string", - "title": "Code" - }, - "admin_email": { - "type": "string", - "title": "Admin Email" - } - }, - "type": "object", - "required": [ - "name", - "code", - "admin_email" - ], - "title": "BootstrapCompanyRead" - }, - "BootstrapConnectionRead": { - "properties": { - "host": { - "type": "string", - "title": "Host" - }, - "port": { - "type": "integer", - "title": "Port" - } - }, - "type": "object", - "required": [ - "host", - "port" - ], - "title": "BootstrapConnectionRead" - }, - "BootstrapDatabaseRead": { - "properties": { - "driver": { - "type": "string", - "title": "Driver" - }, - "host": { - "type": "string", - "title": "Host" - }, - "port": { - "type": "integer", - "title": "Port" - }, - "name": { - "type": "string", - "title": "Name" - }, - "username": { - "type": "string", - "title": "Username" - }, - "password_configured": { - "type": "boolean", - "title": "Password Configured" - } - }, - "type": "object", - "required": [ - "driver", - "host", - "port", - "name", - "username", - "password_configured" - ], - "title": "BootstrapDatabaseRead" - }, - "BootstrapSetupPayload": { - "properties": { - "company_name": { - "type": "string", - "maxLength": 80, - "minLength": 2, - "title": "Company Name" - }, - "company_code": { - "type": "string", - "maxLength": 32, - "title": "Company Code", - "default": "" - }, - "admin_email": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "null" - } - ], - "title": "Admin Email" - }, - "postgres_host": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Postgres Host" - }, - "postgres_port": { - "type": "integer", - "maximum": 65535.0, - "minimum": 1.0, - "title": "Postgres Port", - "default": 5432 - }, - "postgres_db": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "title": "Postgres Db" - }, - "postgres_user": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "title": "Postgres User" - }, - "postgres_password": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Postgres Password" - }, - "redis_url": { - "anyOf": [ - { - "type": "string", - "maxLength": 255 - }, - { - "type": "null" - } - ], - "title": "Redis Url" - } - }, - "type": "object", - "required": [ - "company_name", - "postgres_host", - "postgres_db", - "postgres_user", - "postgres_password" - ], - "title": "BootstrapSetupPayload" - }, - "BootstrapStateRead": { - "properties": { - "initialized": { - "type": "boolean", - "title": "Initialized" - }, - "company": { - "$ref": "#/components/schemas/BootstrapCompanyRead" - }, - "web": { - "$ref": "#/components/schemas/BootstrapConnectionRead" - }, - "server": { - "$ref": "#/components/schemas/BootstrapConnectionRead" - }, - "database": { - "$ref": "#/components/schemas/BootstrapDatabaseRead" - }, - "redis": { - "$ref": "#/components/schemas/BootstrapCacheRead" - } - }, - "type": "object", - "required": [ - "initialized", - "company", - "web", - "server", - "database", - "redis" - ], - "title": "BootstrapStateRead" - }, - "EmployeeCreate": { - "properties": { - "employee_no": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Employee No" - }, - "name": { - "type": "string", - "maxLength": 100, - "minLength": 1, - "title": "Name" - }, - "email": { - "type": "string", - "format": "email", - "title": "Email" - }, - "gender": { - "anyOf": [ - { - "type": "string", - "maxLength": 20 - }, - { - "type": "null" - } - ], - "title": "Gender" - }, - "birth_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Birth Date" - }, - "phone": { - "anyOf": [ - { - "type": "string", - "maxLength": 30 - }, - { - "type": "null" - } - ], - "title": "Phone" - }, - "join_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Join Date" - }, - "location": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ], - "title": "Location" - }, - "position": { - "type": "string", - "maxLength": 100, - "title": "Position", - "default": "员工" - }, - "grade": { - "type": "string", - "maxLength": 20, - "title": "Grade", - "default": "P3" - }, - "cost_center": { - "anyOf": [ - { - "type": "string", - "maxLength": 50 - }, - { - "type": "null" - } - ], - "title": "Cost Center" - }, - "finance_owner_name": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ], - "title": "Finance Owner Name" - }, - "employment_status": { - "type": "string", - "maxLength": 30, - "title": "Employment Status", - "default": "在职" - }, - "sync_state": { - "type": "string", - "maxLength": 30, - "title": "Sync State", - "default": "已同步" - }, - "spotlight": { - "type": "boolean", - "title": "Spotlight", - "default": false - }, - "organization_unit_code": { - "anyOf": [ - { - "type": "string", - "maxLength": 50 - }, - { - "type": "null" - } - ], - "title": "Organization Unit Code" - }, - "manager_employee_no": { - "anyOf": [ - { - "type": "string", - "maxLength": 50 - }, - { - "type": "null" - } - ], - "title": "Manager Employee No" - }, - "role_codes": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Role Codes" - } - }, - "type": "object", - "required": [ - "employee_no", - "name", - "email" - ], - "title": "EmployeeCreate" - }, - "EmployeeHistoryRead": { - "properties": { - "action": { - "type": "string", - "title": "Action" - }, - "owner": { - "type": "string", - "title": "Owner" - }, - "time": { - "type": "string", - "title": "Time" - }, - "occurredAt": { - "type": "string", - "title": "Occurredat" - } - }, - "type": "object", - "required": [ - "action", - "owner", - "time", - "occurredAt" - ], - "title": "EmployeeHistoryRead" - }, - "EmployeeMetaRead": { - "properties": { - "totalEmployees": { - "type": "integer", - "title": "Totalemployees" - }, - "statusSummary": { - "items": { - "$ref": "#/components/schemas/EmployeeStatusSummaryRead" - }, - "type": "array", - "title": "Statussummary" - }, - "roleOptions": { - "items": { - "$ref": "#/components/schemas/EmployeeRoleOptionRead" - }, - "type": "array", - "title": "Roleoptions" - } - }, - "type": "object", - "required": [ - "totalEmployees", - "statusSummary", - "roleOptions" - ], - "title": "EmployeeMetaRead" - }, - "EmployeeOrganizationRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "code": { - "type": "string", - "title": "Code" - }, - "name": { - "type": "string", - "title": "Name" - }, - "unitType": { - "type": "string", - "title": "Unittype" - }, - "costCenter": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Costcenter" - }, - "location": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Location" - }, - "managerName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Managername" - } - }, - "type": "object", - "required": [ - "id", - "code", - "name", - "unitType" - ], - "title": "EmployeeOrganizationRead" - }, - "EmployeeRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "avatar": { - "type": "string", - "title": "Avatar" - }, - "name": { - "type": "string", - "title": "Name" - }, - "employeeNo": { - "type": "string", - "title": "Employeeno" - }, - "department": { - "type": "string", - "title": "Department" - }, - "position": { - "type": "string", - "title": "Position" - }, - "grade": { - "type": "string", - "title": "Grade" - }, - "manager": { - "type": "string", - "title": "Manager" - }, - "financeOwner": { - "type": "string", - "title": "Financeowner" - }, - "roles": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Roles" - }, - "roleCodes": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Rolecodes" - }, - "status": { - "type": "string", - "title": "Status" - }, - "statusTone": { - "type": "string", - "title": "Statustone" - }, - "gender": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Gender" - }, - "age": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Age" - }, - "birthDate": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Birthdate" - }, - "email": { - "type": "string", - "format": "email", - "title": "Email" - }, - "phone": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Phone" - }, - "joinDate": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Joindate" - }, - "location": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Location" - }, - "costCenter": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Costcenter" - }, - "updatedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Updatedat" - }, - "lastSync": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Lastsync" - }, - "syncState": { - "type": "string", - "title": "Syncstate" - }, - "spotlight": { - "type": "boolean", - "title": "Spotlight", - "default": false - }, - "permissions": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Permissions" - }, - "history": { - "items": { - "$ref": "#/components/schemas/EmployeeHistoryRead" - }, - "type": "array", - "title": "History" - }, - "organization": { - "anyOf": [ - { - "$ref": "#/components/schemas/EmployeeOrganizationRead" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "id", - "avatar", - "name", - "employeeNo", - "department", - "position", - "grade", - "manager", - "financeOwner", - "status", - "statusTone", - "email", - "syncState" - ], - "title": "EmployeeRead" - }, - "EmployeeRoleOptionRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "code": { - "type": "string", - "title": "Code" - }, - "label": { - "type": "string", - "title": "Label" - }, - "desc": { - "type": "string", - "title": "Desc" - }, - "permissions": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Permissions" - } - }, - "type": "object", - "required": [ - "id", - "code", - "label", - "desc" - ], - "title": "EmployeeRoleOptionRead" - }, - "EmployeeStatusSummaryRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "label": { - "type": "string", - "title": "Label" - }, - "count": { - "type": "integer", - "title": "Count" - } - }, - "type": "object", - "required": [ - "id", - "label", - "count" - ], - "title": "EmployeeStatusSummaryRead" - }, - "EmployeeUpdate": { - "properties": { - "name": { - "anyOf": [ - { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "gender": { - "anyOf": [ - { - "type": "string", - "maxLength": 20 - }, - { - "type": "null" - } - ], - "title": "Gender" - }, - "birth_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Birth Date" - }, - "phone": { - "anyOf": [ - { - "type": "string", - "maxLength": 30 - }, - { - "type": "null" - } - ], - "title": "Phone" - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "null" - } - ], - "title": "Email" - }, - "join_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Join Date" - }, - "location": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ], - "title": "Location" - }, - "position": { - "anyOf": [ - { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - { - "type": "null" - } - ], - "title": "Position" - }, - "grade": { - "anyOf": [ - { - "type": "string", - "maxLength": 20, - "minLength": 1 - }, - { - "type": "null" - } - ], - "title": "Grade" - }, - "cost_center": { - "anyOf": [ - { - "type": "string", - "maxLength": 50 - }, - { - "type": "null" - } - ], - "title": "Cost Center" - }, - "finance_owner_name": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ], - "title": "Finance Owner Name" - }, - "role_codes": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Role Codes" - }, - "password": { - "anyOf": [ - { - "type": "string", - "maxLength": 128, - "minLength": 5 - }, - { - "type": "null" - } - ], - "title": "Password" - } - }, - "type": "object", - "title": "EmployeeUpdate" - }, - "ErrorResponse": { - "properties": { - "detail": { - "type": "string", - "title": "Detail" - } - }, - "type": "object", - "required": [ - "detail" - ], - "title": "ErrorResponse" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "HealthCheckRead": { - "properties": { - "status": { - "type": "string", - "title": "Status" - }, - "database": { - "$ref": "#/components/schemas/HealthDatabaseStatusRead" - }, - "redis": { - "$ref": "#/components/schemas/HealthRedisStatusRead" - } - }, - "type": "object", - "required": [ - "status", - "database", - "redis" - ], - "title": "HealthCheckRead" - }, - "HealthDatabaseStatusRead": { - "properties": { - "configured": { - "type": "boolean", - "title": "Configured" - }, - "ok": { - "type": "boolean", - "title": "Ok" - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error" - } - }, - "type": "object", - "required": [ - "configured", - "ok" - ], - "title": "HealthDatabaseStatusRead" - }, - "HealthRedisStatusRead": { - "properties": { - "configured": { - "type": "boolean", - "title": "Configured" - }, - "enabled": { - "type": "boolean", - "title": "Enabled" - } - }, - "type": "object", - "required": [ - "configured", - "enabled" - ], - "title": "HealthRedisStatusRead" - }, - "KnowledgeActionResponse": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok", - "default": true - }, - "detail": { - "type": "string", - "title": "Detail" - } - }, - "type": "object", - "required": [ - "detail" - ], - "title": "KnowledgeActionResponse" - }, - "KnowledgeDocumentDetailRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "folder": { - "type": "string", - "title": "Folder" - }, - "tag": { - "type": "string", - "title": "Tag" - }, - "time": { - "type": "string", - "title": "Time" - }, - "version": { - "type": "string", - "title": "Version" - }, - "state": { - "type": "string", - "title": "State" - }, - "stateTone": { - "type": "string", - "title": "Statetone" - }, - "owner": { - "type": "string", - "title": "Owner" - }, - "icon": { - "type": "string", - "title": "Icon" - }, - "fileType": { - "type": "string", - "title": "Filetype" - }, - "fileTypeLabel": { - "type": "string", - "title": "Filetypelabel" - }, - "summary": { - "type": "string", - "title": "Summary" - }, - "mimeType": { - "type": "string", - "title": "Mimetype" - }, - "extension": { - "type": "string", - "title": "Extension" - }, - "sizeBytes": { - "type": "integer", - "title": "Sizebytes" - }, - "canPreview": { - "type": "boolean", - "title": "Canpreview", - "default": false - }, - "previewKind": { - "type": "string", - "title": "Previewkind" - }, - "previewPages": { - "items": { - "$ref": "#/components/schemas/KnowledgePreviewPageRead" - }, - "type": "array", - "title": "Previewpages" - } - }, - "type": "object", - "required": [ - "id", - "name", - "folder", - "tag", - "time", - "version", - "state", - "stateTone", - "owner", - "icon", - "fileType", - "fileTypeLabel", - "summary", - "mimeType", - "extension", - "sizeBytes", - "previewKind" - ], - "title": "KnowledgeDocumentDetailRead" - }, - "KnowledgeDocumentRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "folder": { - "type": "string", - "title": "Folder" - }, - "tag": { - "type": "string", - "title": "Tag" - }, - "time": { - "type": "string", - "title": "Time" - }, - "version": { - "type": "string", - "title": "Version" - }, - "state": { - "type": "string", - "title": "State" - }, - "stateTone": { - "type": "string", - "title": "Statetone" - }, - "owner": { - "type": "string", - "title": "Owner" - }, - "icon": { - "type": "string", - "title": "Icon" - }, - "fileType": { - "type": "string", - "title": "Filetype" - }, - "fileTypeLabel": { - "type": "string", - "title": "Filetypelabel" - }, - "summary": { - "type": "string", - "title": "Summary" - }, - "mimeType": { - "type": "string", - "title": "Mimetype" - }, - "extension": { - "type": "string", - "title": "Extension" - }, - "sizeBytes": { - "type": "integer", - "title": "Sizebytes" - }, - "canPreview": { - "type": "boolean", - "title": "Canpreview", - "default": false - } - }, - "type": "object", - "required": [ - "id", - "name", - "folder", - "tag", - "time", - "version", - "state", - "stateTone", - "owner", - "icon", - "fileType", - "fileTypeLabel", - "summary", - "mimeType", - "extension", - "sizeBytes" - ], - "title": "KnowledgeDocumentRead" - }, - "KnowledgeFolderRead": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "count": { - "type": "integer", - "title": "Count" - }, - "icon": { - "type": "string", - "title": "Icon", - "default": "mdi mdi-folder" - } - }, - "type": "object", - "required": [ - "name", - "count" - ], - "title": "KnowledgeFolderRead" - }, - "KnowledgeLibraryRead": { - "properties": { - "folders": { - "items": { - "$ref": "#/components/schemas/KnowledgeFolderRead" - }, - "type": "array", - "title": "Folders" - }, - "documents": { - "items": { - "$ref": "#/components/schemas/KnowledgeDocumentRead" - }, - "type": "array", - "title": "Documents" - } - }, - "type": "object", - "title": "KnowledgeLibraryRead" - }, - "KnowledgeOnlyOfficeCallbackRead": { - "properties": { - "error": { - "type": "integer", - "title": "Error", - "default": 0 - } - }, - "type": "object", - "title": "KnowledgeOnlyOfficeCallbackRead" - }, - "KnowledgeOnlyOfficeCallbackWrite": { - "properties": { - "status": { - "type": "integer", - "title": "Status", - "description": "ONLYOFFICE 回调状态码。" - }, - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Url", - "description": "文档下载地址,状态为 2 或 6 时使用。" - }, - "users": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Users", - "description": "当前编辑用户列表。" - } - }, - "additionalProperties": true, - "type": "object", - "required": [ - "status" - ], - "title": "KnowledgeOnlyOfficeCallbackWrite" - }, - "KnowledgeOnlyOfficeConfigRead": { - "properties": { - "documentServerUrl": { - "type": "string", - "title": "Documentserverurl" - }, - "config": { - "additionalProperties": true, - "type": "object", - "title": "Config" - } - }, - "type": "object", - "required": [ - "documentServerUrl" - ], - "title": "KnowledgeOnlyOfficeConfigRead" - }, - "KnowledgePreviewBlockRead": { - "properties": { - "heading": { - "type": "string", - "title": "Heading" - }, - "lines": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Lines" - } - }, - "type": "object", - "required": [ - "heading" - ], - "title": "KnowledgePreviewBlockRead" - }, - "KnowledgePreviewPageRead": { - "properties": { - "title": { - "type": "string", - "title": "Title" - }, - "subtitle": { - "type": "string", - "title": "Subtitle" - }, - "stats": { - "items": { - "$ref": "#/components/schemas/KnowledgePreviewStatRead" - }, - "type": "array", - "title": "Stats" - }, - "blocks": { - "items": { - "$ref": "#/components/schemas/KnowledgePreviewBlockRead" - }, - "type": "array", - "title": "Blocks" - } - }, - "type": "object", - "required": [ - "title", - "subtitle" - ], - "title": "KnowledgePreviewPageRead" - }, - "KnowledgePreviewStatRead": { - "properties": { - "label": { - "type": "string", - "title": "Label" - }, - "value": { - "type": "string", - "title": "Value" - } - }, - "type": "object", - "required": [ - "label", - "value" - ], - "title": "KnowledgePreviewStatRead" - }, - "LoginRequest": { - "properties": { - "username": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Username" - }, - "password": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "title": "Password" - } - }, - "type": "object", - "required": [ - "username", - "password" - ], - "title": "LoginRequest" - }, - "LoginResponse": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok", - "default": true - }, - "detail": { - "type": "string", - "title": "Detail", - "default": "登录成功。" - }, - "user": { - "$ref": "#/components/schemas/AuthUserRead" - } - }, - "type": "object", - "required": [ - "user" - ], - "title": "LoginResponse" - }, - "ModelConnectivityTestRead": { - "properties": { - "ok": { - "type": "boolean", - "title": "Ok" - }, - "provider": { - "type": "string", - "title": "Provider" - }, - "model": { - "type": "string", - "title": "Model" - }, - "endpoint": { - "type": "string", - "title": "Endpoint" - }, - "capability": { - "type": "string", - "title": "Capability" - }, - "detail": { - "type": "string", - "title": "Detail" - }, - "status_code": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Status Code" - }, - "checked_at": { - "type": "string", - "format": "date-time", - "title": "Checked At" - } - }, - "type": "object", - "required": [ - "ok", - "provider", - "model", - "endpoint", - "capability", - "detail", - "checked_at" - ], - "title": "ModelConnectivityTestRead" - }, - "ModelConnectivityTestRequest": { - "properties": { - "provider": { - "type": "string", - "maxLength": 64, - "minLength": 1, - "title": "Provider" - }, - "endpoint": { - "type": "string", - "maxLength": 512, - "minLength": 1, - "title": "Endpoint" - }, - "model": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Model" - }, - "api_key": { - "anyOf": [ - { - "type": "string", - "maxLength": 1024 - }, - { - "type": "null" - } - ], - "title": "Api Key" - }, - "capability": { - "type": "string", - "enum": [ - "chat", - "embedding" - ], - "title": "Capability", - "default": "chat" - }, - "slot": { - "anyOf": [ - { - "type": "string", - "enum": [ - "main", - "backup", - "vlm", - "embedding" - ] - }, - { - "type": "null" - } - ], - "title": "Slot" - } - }, - "type": "object", - "required": [ - "provider", - "endpoint", - "model" - ], - "title": "ModelConnectivityTestRequest" - }, - "ReimbursementCreate": { - "properties": { - "request_no": { - "type": "string", - "title": "Request No" - }, - "employee_id": { - "type": "string", - "title": "Employee Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "category": { - "type": "string", - "title": "Category" - }, - "amount": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" - } - ], - "title": "Amount" - }, - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reason" - } - }, - "type": "object", - "required": [ - "request_no", - "employee_id", - "title", - "category", - "amount" - ], - "title": "ReimbursementCreate" - }, - "ReimbursementRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "request_no": { - "type": "string", - "title": "Request No" - }, - "employee_id": { - "type": "string", - "title": "Employee Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "category": { - "type": "string", - "title": "Category" - }, - "status": { - "type": "string", - "title": "Status" - }, - "amount": { - "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", - "title": "Amount" - }, - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reason" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" - } - }, - "type": "object", - "required": [ - "id", - "request_no", - "employee_id", - "title", - "category", - "status", - "amount", - "reason", - "created_at", - "updated_at" - ], - "title": "ReimbursementRead" - }, - "RootStatusRead": { - "properties": { - "message": { - "type": "string", - "title": "Message" - } - }, - "type": "object", - "required": [ - "message" - ], - "title": "RootStatusRead" - }, - "RuntimeModelConfigRead": { - "properties": { - "slot": { - "type": "string", - "enum": [ - "main", - "backup", - "vlm", - "embedding" - ], - "title": "Slot" - }, - "provider": { - "type": "string", - "title": "Provider" - }, - "model": { - "type": "string", - "title": "Model" - }, - "endpoint": { - "type": "string", - "title": "Endpoint" - }, - "apiKey": { - "type": "string", - "title": "Apikey" - }, - "capability": { - "type": "string", - "enum": [ - "chat", - "embedding" - ], - "title": "Capability" - } - }, - "type": "object", - "required": [ - "slot", - "provider", - "model", - "endpoint", - "apiKey", - "capability" - ], - "title": "RuntimeModelConfigRead" - }, - "SemanticParseRead": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "run_id": { - "type": "string", - "title": "Run Id" - }, - "user_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, - "raw_query": { - "type": "string", - "title": "Raw Query" - }, - "scenario": { - "type": "string", - "title": "Scenario" - }, - "intent": { - "type": "string", - "title": "Intent" - }, - "entities_json": { - "items": {}, - "type": "array", - "title": "Entities Json" - }, - "time_range_json": { - "additionalProperties": true, - "type": "object", - "title": "Time Range Json" - }, - "metrics_json": { - "items": {}, - "type": "array", - "title": "Metrics Json" - }, - "constraints_json": { - "items": {}, - "type": "array", - "title": "Constraints Json" - }, - "risk_flags_json": { - "items": {}, - "type": "array", - "title": "Risk Flags Json" - }, - "permission_json": { - "additionalProperties": true, - "type": "object", - "title": "Permission Json" - }, - "confidence": { - "type": "number", - "title": "Confidence" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "run_id", - "user_id", - "raw_query", - "scenario", - "intent", - "entities_json", - "time_range_json", - "metrics_json", - "constraints_json", - "risk_flags_json", - "permission_json", - "confidence", - "created_at" - ], - "title": "SemanticParseRead" - }, - "SettingsAdminForm": { - "properties": { - "adminAccount": { - "type": "string", - "maxLength": 120, - "minLength": 1, - "title": "Adminaccount" - }, - "adminEmail": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Adminemail" - }, - "newPassword": { - "type": "string", - "maxLength": 128, - "title": "Newpassword", - "default": "" - }, - "confirmPassword": { - "type": "string", - "maxLength": 128, - "title": "Confirmpassword", - "default": "" - }, - "sessionTimeout": { - "type": "integer", - "maximum": 240.0, - "minimum": 5.0, - "title": "Sessiontimeout", - "default": 30 - }, - "noticeEmail": { - "type": "string", - "maxLength": 255, - "title": "Noticeemail", - "default": "" - }, - "mfaEnabled": { - "type": "boolean", - "title": "Mfaenabled", - "default": true - }, - "strongPassword": { - "type": "boolean", - "title": "Strongpassword", - "default": true - }, - "loginAlertEnabled": { - "type": "boolean", - "title": "Loginalertenabled", - "default": true - }, - "adminPasswordConfigured": { - "type": "boolean", - "title": "Adminpasswordconfigured", - "default": false - } - }, - "type": "object", - "required": [ - "adminAccount", - "adminEmail" - ], - "title": "SettingsAdminForm" - }, - "SettingsCompanyForm": { - "properties": { - "companyName": { - "type": "string", - "maxLength": 120, - "minLength": 1, - "title": "Companyname" - }, - "displayName": { - "type": "string", - "maxLength": 120, - "minLength": 1, - "title": "Displayname" - }, - "companyCode": { - "type": "string", - "maxLength": 64, - "title": "Companycode", - "default": "" - }, - "recordNumber": { - "type": "string", - "maxLength": 120, - "title": "Recordnumber", - "default": "" - }, - "copyright": { - "type": "string", - "maxLength": 255, - "title": "Copyright", - "default": "" - } - }, - "type": "object", - "required": [ - "companyName", - "displayName" - ], - "title": "SettingsCompanyForm" - }, - "SettingsLlmForm": { - "properties": { - "mainProvider": { - "type": "string", - "maxLength": 64, - "minLength": 1, - "title": "Mainprovider" - }, - "mainModel": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Mainmodel" - }, - "mainEndpoint": { - "type": "string", - "maxLength": 512, - "minLength": 1, - "title": "Mainendpoint" - }, - "mainApiKey": { - "type": "string", - "maxLength": 1024, - "title": "Mainapikey", - "default": "" - }, - "mainApiKeyConfigured": { - "type": "boolean", - "title": "Mainapikeyconfigured", - "default": false - }, - "backupProvider": { - "type": "string", - "maxLength": 64, - "minLength": 1, - "title": "Backupprovider" - }, - "backupModel": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Backupmodel" - }, - "backupEndpoint": { - "type": "string", - "maxLength": 512, - "minLength": 1, - "title": "Backupendpoint" - }, - "backupApiKey": { - "type": "string", - "maxLength": 1024, - "title": "Backupapikey", - "default": "" - }, - "backupApiKeyConfigured": { - "type": "boolean", - "title": "Backupapikeyconfigured", - "default": false - }, - "vlmProvider": { - "type": "string", - "maxLength": 64, - "minLength": 1, - "title": "Vlmprovider" - }, - "vlmModel": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Vlmmodel" - }, - "vlmEndpoint": { - "type": "string", - "maxLength": 512, - "minLength": 1, - "title": "Vlmendpoint" - }, - "vlmApiKey": { - "type": "string", - "maxLength": 1024, - "title": "Vlmapikey", - "default": "" - }, - "vlmApiKeyConfigured": { - "type": "boolean", - "title": "Vlmapikeyconfigured", - "default": false - }, - "embeddingProvider": { - "type": "string", - "maxLength": 64, - "minLength": 1, - "title": "Embeddingprovider" - }, - "embeddingModel": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Embeddingmodel" - }, - "embeddingEndpoint": { - "type": "string", - "maxLength": 512, - "minLength": 1, - "title": "Embeddingendpoint" - }, - "embeddingApiKey": { - "type": "string", - "maxLength": 1024, - "title": "Embeddingapikey", - "default": "" - }, - "embeddingApiKeyConfigured": { - "type": "boolean", - "title": "Embeddingapikeyconfigured", - "default": false - } - }, - "type": "object", - "required": [ - "mainProvider", - "mainModel", - "mainEndpoint", - "backupProvider", - "backupModel", - "backupEndpoint", - "vlmProvider", - "vlmModel", - "vlmEndpoint", - "embeddingProvider", - "embeddingModel", - "embeddingEndpoint" - ], - "title": "SettingsLlmForm" - }, - "SettingsLogForm": { - "properties": { - "level": { - "type": "string", - "maxLength": 16, - "minLength": 1, - "title": "Level" - }, - "retentionDays": { - "type": "integer", - "maximum": 3650.0, - "minimum": 1.0, - "title": "Retentiondays", - "default": 180 - }, - "archiveCycle": { - "type": "string", - "maxLength": 32, - "title": "Archivecycle", - "default": "weekly" - }, - "logPath": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Logpath" - }, - "alertEmail": { - "type": "string", - "maxLength": 255, - "title": "Alertemail", - "default": "" - }, - "operationAudit": { - "type": "boolean", - "title": "Operationaudit", - "default": true - }, - "loginAudit": { - "type": "boolean", - "title": "Loginaudit", - "default": true - }, - "maskSensitive": { - "type": "boolean", - "title": "Masksensitive", - "default": true - } - }, - "type": "object", - "required": [ - "level", - "logPath" - ], - "title": "SettingsLogForm" - }, - "SettingsMailForm": { - "properties": { - "smtpHost": { - "type": "string", - "maxLength": 255, - "minLength": 1, - "title": "Smtphost" - }, - "port": { - "type": "integer", - "maximum": 65535.0, - "minimum": 1.0, - "title": "Port", - "default": 465 - }, - "encryption": { - "type": "string", - "maxLength": 32, - "title": "Encryption", - "default": "SSL/TLS" - }, - "senderName": { - "type": "string", - "maxLength": 120, - "title": "Sendername", - "default": "" - }, - "senderAddress": { - "type": "string", - "maxLength": 255, - "title": "Senderaddress", - "default": "" - }, - "username": { - "type": "string", - "maxLength": 255, - "title": "Username", - "default": "" - }, - "password": { - "type": "string", - "maxLength": 1024, - "title": "Password", - "default": "" - }, - "passwordConfigured": { - "type": "boolean", - "title": "Passwordconfigured", - "default": false - }, - "alertEnabled": { - "type": "boolean", - "title": "Alertenabled", - "default": true - }, - "digestEnabled": { - "type": "boolean", - "title": "Digestenabled", - "default": false - }, - "digestTime": { - "type": "string", - "maxLength": 16, - "title": "Digesttime", - "default": "09:00" - }, - "defaultReceiver": { - "type": "string", - "maxLength": 255, - "title": "Defaultreceiver", - "default": "" - } - }, - "type": "object", - "required": [ - "smtpHost" - ], - "title": "SettingsMailForm" - }, - "SettingsRead": { - "properties": { - "companyForm": { - "$ref": "#/components/schemas/SettingsCompanyForm" - }, - "adminForm": { - "$ref": "#/components/schemas/SettingsAdminForm" - }, - "llmForm": { - "$ref": "#/components/schemas/SettingsLlmForm" - }, - "renderForm": { - "$ref": "#/components/schemas/SettingsRenderForm" - }, - "logForm": { - "$ref": "#/components/schemas/SettingsLogForm" - }, - "mailForm": { - "$ref": "#/components/schemas/SettingsMailForm" - } - }, - "type": "object", - "required": [ - "companyForm", - "adminForm", - "llmForm", - "renderForm", - "logForm", - "mailForm" - ], - "title": "SettingsRead" - }, - "SettingsRenderForm": { - "properties": { - "enabled": { - "type": "boolean", - "title": "Enabled", - "default": false - }, - "publicUrl": { - "type": "string", - "maxLength": 512, - "title": "Publicurl", - "default": "" - }, - "jwtSecret": { - "type": "string", - "maxLength": 1024, - "title": "Jwtsecret", - "default": "" - }, - "jwtSecretConfigured": { - "type": "boolean", - "title": "Jwtsecretconfigured", - "default": false - } - }, - "type": "object", - "title": "SettingsRenderForm" - }, - "SettingsWrite": { - "properties": { - "companyForm": { - "$ref": "#/components/schemas/SettingsCompanyForm" - }, - "adminForm": { - "$ref": "#/components/schemas/SettingsAdminForm" - }, - "llmForm": { - "$ref": "#/components/schemas/SettingsLlmForm" - }, - "renderForm": { - "$ref": "#/components/schemas/SettingsRenderForm" - }, - "logForm": { - "$ref": "#/components/schemas/SettingsLogForm" - }, - "mailForm": { - "$ref": "#/components/schemas/SettingsMailForm" - } - }, - "type": "object", - "required": [ - "companyForm", - "adminForm", - "llmForm", - "renderForm", - "logForm", - "mailForm" - ], - "title": "SettingsWrite" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - }, - "input": { - "title": "Input" - }, - "ctx": { - "type": "object", - "title": "Context" - } - }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" - } - } - }, - "tags": [ - { - "name": "health", - "description": "服务健康检查与数据库 / Redis 连通性状态。" - }, - { - "name": "bootstrap", - "description": "系统初始化配置,包括公司信息、数据库和缓存配置。" - }, - { - "name": "auth", - "description": "后台登录认证接口,支持管理员和员工账号登录。" - }, - { - "name": "employees", - "description": "员工目录管理,包括员工列表、详情、创建、更新和停用。" - }, - { - "name": "reimbursements", - "description": "报销申请基础接口,包含列表、创建和详情查询。" - }, - { - "name": "knowledge", - "description": "知识库文件管理、内容访问与 ONLYOFFICE 集成接口。" - }, - { - "name": "settings", - "description": "系统设置、模型配置、模型连通性探测和 Hermes 运行时模型配置。" - }, - { - "name": "agent-assets", - "description": "Agent 资产中心,覆盖规则、技能、MCP、任务及其版本、审核和上线流程。" - }, - { - "name": "agent-runs", - "description": "Agent 运行日志查询,包括工具调用和语义解析结果。" - }, - { - "name": "audit-logs", - "description": "系统审计日志查询接口,用于追踪资产和任务写操作。" - }, - { - "name": "root", - "description": "服务根入口,用于确认应用已启动。" - } - ] -} \ No newline at end of file diff --git a/document/development/budget-center/MASTER_TODO.md b/document/development/budget-center/MASTER_TODO.md deleted file mode 100644 index 639f416..0000000 --- a/document/development/budget-center/MASTER_TODO.md +++ /dev/null @@ -1,60 +0,0 @@ -# 预算中心 MASTER TODO - -## 总目标 - -把预算从首页静态展示升级为真实费控底座,让费用申请和报销都必须经过预算口径校验。 - -## 状态图 - -```text -预算额度 - -> 申请提交: 预占 - -> 申请退回/撤回/驳回: 释放 - -> 申请通过: 保持预占 - -> 报销提交: 校验申请与预算 - -> 报销审批通过: 核销 - -> 报销退回/撤回: 释放或回滚 -``` - -## 总 TODO - -- [ ] 新增预算中心开发文档并纳入每日核对。 -- [ ] 定义预算维度:部门、成本中心、项目、费用科目、期间。 -- [ ] 定义预算模型:预算额度、预算交易、预算占用。 -- [ ] 定义预算状态:正常、预警、超预算、冻结。 -- [ ] 定义预算交易类型:初始化、调整、预占、释放、核销、回滚。 -- [ ] 新增预算列表接口。 -- [ ] 新增预算详情接口。 -- [ ] 新增预算台账接口。 -- [ ] 新增预算占用接口或内部服务。 -- [ ] 新增预算释放接口或内部服务。 -- [ ] 新增预算核销接口或内部服务。 -- [ ] 费用申请提交时写入预算预占。 -- [ ] 费用申请驳回、撤回、取消时释放预算。 -- [ ] 费用申请转报销时保留预算来源。 -- [ ] 报销提交时校验预算归属和可用余额。 -- [ ] 报销审批通过时核销预算。 -- [ ] 报销退回时回滚预算状态。 -- [ ] 报销详情展示预算占用和核销信息。 -- [ ] 申请详情展示预算占用和剩余额度。 -- [ ] 预算中心页面展示执行率、已占用、已核销、可用余额。 -- [ ] 预算台账展示每笔来源单据和交易类型。 -- [ ] 首页预算执行率改为后端真实数据。 -- [ ] 本体识别支持预算维度字段。 -- [ ] AI对话能解释预算不足、预算归属缺失、超预算原因。 -- [ ] 添加后端单元测试。 -- [ ] 添加前端预算视图测试。 -- [ ] 添加申请到报销的端到端预算验收场景。 - -## 验收场景 - -- [ ] 有预算时,费用申请提交成功并预占预算。 -- [ ] 预算不足时,申请提交被阻断或进入超预算复核。 -- [ ] 申请驳回后,预算预占被释放。 -- [ ] 申请审批通过后,预算仍保持预占。 -- [ ] 申请转报销后,报销单继承预算来源。 -- [ ] 报销审批通过后,预算从预占转为核销。 -- [ ] 报销退回后,预算核销回滚。 -- [ ] 预算中心能看到完整交易台账。 -- [ ] 首页预算执行率与预算中心汇总一致。 - diff --git a/document/development/budget-center/README.md b/document/development/budget-center/README.md deleted file mode 100644 index 117d829..0000000 --- a/document/development/budget-center/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# 预算中心开发总览 - -## 目标 - -预算中心先作为费控平台的前置底座建设,优先打通: - -```text -预算编制 -> 预算可用额度 -> 费用申请预占 -> 报销核销 -> 释放/调整 -> 预算看板 -``` - -第一版不追求完整预算编制系统,而是先让申请、报销、审批、付款、归档都有真实预算口径。 - -## 当前项目基础 - -- 员工和组织已有 `cost_center` 成本中心字段,可作为预算归属维度。 -- 报销单已有部门、项目、费用类型、金额、状态等字段,可接入预算核销。 -- 首页已有静态预算执行率展示,但还不是后端真实预算数据。 -- 费用申请已有前端意图识别和申请草稿痕迹,但预算占用还没有真实台账。 - -## 第一版预算中心范围 - -必须做: - -- 预算主体:部门、成本中心、项目、费用科目。 -- 预算期间:月度、季度、年度。 -- 预算额度:总额、已占用、已核销、已释放、可用余额。 -- 预算台账:每一次占用、核销、释放、调整都落账。 -- 申请联动:费用申请提交时预占预算,驳回/撤回时释放。 -- 报销联动:报销提交或审批通过时核销预算。 -- 风险拦截:预算不足、超预算、缺预算归属时阻断或进入复核。 -- 预算中心页面:列表、详情、台账、执行率、异常预算。 - -暂缓: - -- 完整预算编制审批流。 -- 多版本预算测算。 -- 外部 ERP 预算接口。 -- 真正多币种预算。 -- 复杂滚动预算和预测模型。 - -## 关键原则 - -- 预算中心是独立业务域,不塞进报销 Service。 -- 所有预算变化必须通过预算交易台账记录。 -- 不直接改写已用金额,必须由交易汇总得到。 -- 申请、报销、付款只是预算事件来源。 -- 预算不足的判断必须来自后端,不依赖前端显示。 - -## 7天开发路径 - -- Day 1:预算模型、状态机、接口契约。 -- Day 2:预算中心页面、列表、详情、台账视图。 -- Day 3:预算占用/释放/核销服务。 -- Day 4:费用申请与报销联动预算。 -- Day 5:审批、付款、归档中的预算状态传递。 -- Day 6:预算看板、本体识别、AI提示。 -- Day 7:端到端验收、演示数据、测试补齐。 - diff --git a/document/development/budget-center/budget-center-ui-prototype.html b/document/development/budget-center/budget-center-ui-prototype.html deleted file mode 100644 index 4d8c4e5..0000000 --- a/document/development/budget-center/budget-center-ui-prototype.html +++ /dev/null @@ -1,1179 +0,0 @@ - - - - - - X-Financial 预算中心 UI 原型 - - - -
- - -
-
-
-

预算中心

-

按部门、成本中心、项目和费用科目管理预算,申请预占与报销核销统一落入台账。

-
-
- - - -
-
- -
-
-
- 年度预算总额 - -
- ¥ 8,260,000 -
- 覆盖 18 个成本中心 - +12% -
-
-
-
- 已预占 - -
- ¥ 1,384,200 -
- 来自 42 个申请 - 16.8% -
-
-
-
- 已核销 - -
- ¥ 3,962,580 -
- 审批通过报销 - 48.0% -
-
-
-
- 可用余额 - -
- ¥ 2,913,220 -
- 按实时台账计算 - 35.2% -
-
-
-
- 预警预算 - -
- 7 -
- 执行率超过 80% - 需关注 -
-
-
-
- 超预算事项 - -
- 3 -
- 等待复核 - 阻断 -
-
-
- -
-
-
-
- - - -
-
- - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
预算编号归属费用科目预算总额已预占已核销可用余额执行率状态
-
- BUD-2026-CC2108-TRAVEL - 2026 年度 -
-
解决方案部 / CC-2108差旅费¥ 860,000¥ 168,000¥ 451,600¥ 240,400 -
-
72%
-
预警
-
- BUD-2026-CC4102-MEETING - 2026 年度 -
-
市场部 / CC-4102会议培训费¥ 520,000¥ 96,000¥ 184,500¥ 239,500 -
-
54%
-
正常
-
- BUD-2026-CC6112-PURCHASE - 2026 年度 -
-
交付中心 / CC-6112办公采购¥ 280,000¥ 72,600¥ 226,800- ¥ 19,400 -
-
107%
-
超预算
-
- BUD-2026-PRJ-A09-TRAVEL - 2026 Q2 -
-
华东电力项目 / PRJ-A09项目差旅¥ 420,000¥ 63,200¥ 128,900¥ 227,900 -
-
46%
-
正常
-
- BUD-2026-CC2120-ENT - 2026-05 月度 -
-
财务部 / CC-2120商务招待¥ 90,000¥ 12,800¥ 77,200¥ 0 -
-
100%
-
冻结
-
-
- - -
- -

移动端会保留指标、筛选、预算列表和详情入口,台账与风险信息折叠到预算详情中。

-
-
- - diff --git a/document/development/budget-center/day_1_budget_foundation.md b/document/development/budget-center/day_1_budget_foundation.md deleted file mode 100644 index 59cf164..0000000 --- a/document/development/budget-center/day_1_budget_foundation.md +++ /dev/null @@ -1,105 +0,0 @@ -# Day 1 - 预算模型与接口契约 - -## 目标 - -先把预算中心的数据边界和接口边界定稳,避免后续把预算逻辑散落在申请、报销、审批和付款模块里。 - -## 开发任务 - -- [ ] 新增预算模型设计。 -- [ ] 新增预算交易台账设计。 -- [ ] 新增预算服务边界设计。 -- [ ] 新增预算接口契约。 -- [ ] 新增预算状态与交易类型常量。 -- [ ] 明确申请、报销、付款对预算服务的调用点。 - -## 建议模型 - -预算额度: - -```text -BudgetAllocation -- id -- budget_no -- fiscal_year -- period_type -- period_key -- department_id -- department_name -- cost_center -- project_code -- subject_code -- subject_name -- original_amount -- adjusted_amount -- status -- warning_threshold -- created_at -- updated_at -``` - -预算交易: - -```text -BudgetTransaction -- id -- transaction_no -- allocation_id -- source_type -- source_id -- source_no -- transaction_type -- amount -- before_available_amount -- after_available_amount -- operator -- reason -- created_at -``` - -交易类型: - -```text -init 初始化 -adjust 调整 -reserve 预占 -release 释放 -consume 核销 -rollback 回滚 -freeze 冻结 -unfreeze 解冻 -``` - -预算汇总字段由交易汇总得到: - -```text -total_amount = original_amount + adjusted_amount -reserved_amount = reserve - release -consumed_amount = consume - rollback -available_amount = total_amount - reserved_amount - consumed_amount -``` - -## 接口契约 - -```text -GET /api/v1/budgets/allocations -POST /api/v1/budgets/allocations -GET /api/v1/budgets/allocations/{id} -GET /api/v1/budgets/allocations/{id}/transactions -POST /api/v1/budgets/allocations/{id}/adjust -POST /api/v1/budgets/check -POST /api/v1/budgets/reserve -POST /api/v1/budgets/release -POST /api/v1/budgets/consume -POST /api/v1/budgets/rollback -GET /api/v1/budgets/summary -``` - -## 验收 - -- [ ] 能创建一条部门月度预算。 -- [ ] 能查询预算列表和详情。 -- [ ] 能查询预算台账。 -- [ ] 能根据部门、成本中心、项目、费用科目定位预算。 -- [ ] 预算不足时 `check` 接口能返回明确原因。 - diff --git a/document/development/budget-center/day_2_budget_center_ui.md b/document/development/budget-center/day_2_budget_center_ui.md deleted file mode 100644 index 7917c88..0000000 --- a/document/development/budget-center/day_2_budget_center_ui.md +++ /dev/null @@ -1,82 +0,0 @@ -# Day 2 - 预算中心页面 - -## 目标 - -新增预算中心作为独立主菜单,让预算不再只是首页指标,而是可操作、可追踪、可解释的费控入口。 - -## 页面入口 - -主菜单建议: - -```text -费用申请 -报销中心 -审批中心 -预算中心 -付款中心 -归档中心 -经营分析 -``` - -## 页面结构 - -顶部指标: - -- 预算总额 -- 已预占 -- 已核销 -- 可用余额 -- 超预算事项 -- 预警预算数 - -列表字段: - -- 预算编号 -- 预算期间 -- 部门 -- 成本中心 -- 项目 -- 费用科目 -- 预算总额 -- 已预占 -- 已核销 -- 可用余额 -- 执行率 -- 状态 - -筛选条件: - -- 年度 -- 月份/季度 -- 部门 -- 成本中心 -- 项目 -- 费用科目 -- 状态 - -详情页: - -- 基本信息 -- 执行概览 -- 来源单据 -- 交易台账 -- 风险提示 -- 调整记录 - -## 操作 - -- 新增预算 -- 调整预算 -- 冻结预算 -- 查看台账 -- 查看关联申请 -- 查看关联报销 - -## 验收 - -- [ ] 预算中心能从主菜单进入。 -- [ ] 列表能展示后端预算数据。 -- [ ] 点击预算能进入详情。 -- [ ] 详情能展示交易台账。 -- [ ] 首页预算执行率能跳转到预算中心。 - diff --git a/document/development/budget-center/day_3_budget_transaction_service.md b/document/development/budget-center/day_3_budget_transaction_service.md deleted file mode 100644 index 99c79de..0000000 --- a/document/development/budget-center/day_3_budget_transaction_service.md +++ /dev/null @@ -1,55 +0,0 @@ -# Day 3 - 预算占用、释放、核销服务 - -## 目标 - -把预算变化统一收敛到预算服务,申请、报销、付款都只能通过预算服务改变预算状态。 - -## 服务能力 - -预算检查: - -- 校验预算归属是否存在。 -- 校验预算是否被冻结。 -- 校验可用余额是否充足。 -- 返回超预算金额和处理建议。 - -预算预占: - -- 用于费用申请提交。 -- 写入 `reserve` 交易。 -- 记录来源单据。 - -预算释放: - -- 用于申请撤回、退回、驳回、取消。 -- 写入 `release` 交易。 -- 必须找到原始预占来源。 - -预算核销: - -- 用于报销审批通过。 -- 写入 `consume` 交易。 -- 如果来源申请已有预占,应先释放预占或转换为核销,不能重复占用。 - -预算回滚: - -- 用于报销退回或撤销审批。 -- 写入 `rollback` 交易。 - -## 关键防错 - -- 同一来源单据不能重复预占。 -- 同一报销单不能重复核销。 -- 释放金额不能超过原预占金额。 -- 核销金额不能超过可用余额加当前来源预占余额。 -- 所有预算交易必须有来源单据和操作人。 - -## 验收 - -- [ ] 预算预占后可用余额减少。 -- [ ] 预算释放后可用余额恢复。 -- [ ] 预算核销后已核销金额增加。 -- [ ] 重复预占会被阻断。 -- [ ] 重复核销会被阻断。 -- [ ] 台账能解释每一次余额变化。 - diff --git a/document/development/budget-center/day_4_application_claim_integration.md b/document/development/budget-center/day_4_application_claim_integration.md deleted file mode 100644 index d0ebc75..0000000 --- a/document/development/budget-center/day_4_application_claim_integration.md +++ /dev/null @@ -1,58 +0,0 @@ -# Day 4 - 费用申请与报销联动预算 - -## 目标 - -让预算成为申请和报销之间的硬约束,先申请、再占用、再报销、再核销。 - -## 费用申请联动 - -提交申请时: - -- 根据申请人部门、成本中心、项目、费用科目定位预算。 -- 预算充足则预占。 -- 预算不足则阻断或进入超预算复核。 -- 申请详情展示预算占用结果。 - -申请退回/驳回/撤回时: - -- 释放对应预算预占。 -- 记录释放原因。 - -申请审批通过时: - -- 保持预算预占。 -- 允许转报销。 - -申请转报销时: - -- 报销单继承申请预算来源。 -- 报销金额默认不超过申请金额。 -- 超过申请金额时进入风险提示或复核。 - -## 报销联动 - -报销提交时: - -- 校验是否需要事前申请。 -- 校验是否有关联已通过申请。 -- 校验预算来源是否存在。 - -报销审批通过时: - -- 将预算预占转为核销。 -- 记录核销台账。 - -报销退回时: - -- 回滚核销。 -- 视状态保留或释放预占。 - -## 验收 - -- [ ] 有预算的申请提交后形成预占。 -- [ ] 预算不足的申请不能直接提交。 -- [ ] 驳回申请释放预算。 -- [ ] 已通过申请能转报销。 -- [ ] 报销审批通过后预算转核销。 -- [ ] 未关联预算的报销不能绕过预算校验。 - diff --git a/document/development/budget-center/day_5_approval_payment_archive_budget.md b/document/development/budget-center/day_5_approval_payment_archive_budget.md deleted file mode 100644 index d73666f..0000000 --- a/document/development/budget-center/day_5_approval_payment_archive_budget.md +++ /dev/null @@ -1,52 +0,0 @@ -# Day 5 - 审批、付款、归档中的预算口径 - -## 目标 - -预算信息不能只停留在申请和报销页面,还要贯穿审批、付款和归档。 - -## 审批中心 - -审批列表增加预算提示: - -- 是否预算内 -- 是否超预算 -- 已预占金额 -- 可用余额 -- 预算归属 - -审批详情增加预算区块: - -- 当前单据金额 -- 对应预算额度 -- 已预占 -- 已核销 -- 剩余额度 -- 超预算原因 - -## 付款中心预留 - -第一版付款中心可以暂缓实现完整页面,但预算中心需要为付款预留: - -- 付款来源单据 -- 付款金额 -- 付款状态 -- 是否已核销预算 -- 是否存在预算异常 - -## 归档中心 - -归档包需要包含预算信息: - -- 预算归属 -- 预算交易流水 -- 申请预占记录 -- 报销核销记录 -- 超预算审批意见 - -## 验收 - -- [ ] 审批人能看到预算是否充足。 -- [ ] 超预算审批能看到超额金额。 -- [ ] 归档详情能看到预算台账摘要。 -- [ ] 预算异常不会在付款/归档阶段丢失。 - diff --git a/document/development/budget-center/day_6_budget_analytics_ontology.md b/document/development/budget-center/day_6_budget_analytics_ontology.md deleted file mode 100644 index a9ff6a1..0000000 --- a/document/development/budget-center/day_6_budget_analytics_ontology.md +++ /dev/null @@ -1,90 +0,0 @@ -# Day 6 - 预算看板、本体识别与AI解释 - -## 目标 - -让预算中心不只是数据表,还能被 AI 对话、本体识别和经营分析调用。 - -## 看板指标 - -- 本月预算总额 -- 本月已预占 -- 本月已核销 -- 本月可用余额 -- 部门预算排行 -- 费用科目执行率 -- 超预算事项数量 -- 预算预警事项数量 - -## 本体字段 - -新增或强化字段: - -```text -cost_center -project_code -budget_subject -budget_period -budget_amount -available_amount -reserved_amount -consumed_amount -over_budget -budget_warning -``` - -## 预算字段设计 - -预算中心字段分为四层,前端弹窗、预算台账、后端本体解析都必须使用同一套语义键。 - -### 预算主信息 - -- `budget_period`:预算周期,支持年度、季度、月份。 -- `department`:所属部门,来自真实组织/部门数据。 -- `cost_center`:成本中心,跟随部门归属。 -- `budget_owner`:预算负责人。 -- `budget_version`:预算版本,例如 `V1.0(初始版本)`。 -- `budget_status`:预算状态,第一版限定为 `编制中 / 已发布 / 已冻结`。 -- `budget_description`:预算说明。 - -### 预算明细 - -- `budget_subject`:预算科目,对应页面费用类型。 -- `budget_subject_code`:预算科目编码,例如 `travel / office / training`。 -- `budget_amount`:预算金额。 -- `warning_threshold`:预警线,例如 `70% / 80%`。 -- `control_action`:控制动作,第一版限定为 `正常 / 提醒 / 管控`。 -- `budget_remark`:明细备注。 - -### 预算执行 - -- `reserved_amount`:已占用/已预占金额。 -- `consumed_amount`:已发生/已核销金额。 -- `available_amount`:剩余可用金额。 -- `budget_usage_rate`:预算执行率。 -- `over_budget`:是否超预算。 -- `budget_warning`:是否触发预算预警。 - -### 本体映射规则 - -- 页面字段使用驼峰变量,但提交/上下文统一映射为 snake_case 本体字段。 -- 本体 `scenario=budget` 负责预算编制、预算查询、预算预警、预算占用、预算不足解释。 -- 费用申请/报销仍使用 `scenario=expense`,但预算占用字段必须引用 `budget_subject / budget_period / cost_center`。 -- 问句中出现“预算金额、可用预算、剩余预算、预算占用、成本中心、预警线、超预算、预算不足”等词,应优先识别为 `budget` 场景。 -- 本体输出中,预算字段优先进入 `entities`;金额类查询同步进入 `metrics`;筛选口径进入 `constraints`。 - -## AI解释能力 - -需要支持的问题: - -- 这个申请为什么预算不足? -- 这个报销占用了哪个预算? -- 本月哪个部门预算快超了? -- 某个项目还剩多少预算? -- 超预算申请需要谁审批? - -## 验收 - -- [ ] 本体能识别预算相关问题。 -- [ ] AI能解释预算不足原因。 -- [ ] 首页预算看板来自后端真实汇总。 -- [ ] 预算中心和AI回答的金额一致。 diff --git a/document/development/budget-center/day_7_hardening_acceptance.md b/document/development/budget-center/day_7_hardening_acceptance.md deleted file mode 100644 index baf0d19..0000000 --- a/document/development/budget-center/day_7_hardening_acceptance.md +++ /dev/null @@ -1,64 +0,0 @@ -# Day 7 - 联调、测试与演示验收 - -## 目标 - -冻结新增需求,只修预算闭环缺口,确保演示链路稳定。 - -## 端到端验收链路 - -链路一:预算内申请到报销 - -```text -创建预算 -> 发起费用申请 -> 预占预算 -> 审批通过 --> 转报销 -> 报销审批通过 -> 核销预算 -> 归档 -``` - -链路二:预算不足 - -```text -创建低额度预算 -> 发起高金额申请 -> 预算不足 --> 阻断提交或进入超预算复核 -> 审批意见留痕 -``` - -链路三:申请驳回释放预算 - -```text -申请提交 -> 预算预占 -> 审批驳回 -> 预算释放 -> 台账可追溯 -``` - -链路四:重复操作防护 - -```text -重复提交 / 重复审批 / 重复核销 -> 后端阻断 -> 台账不重复 -``` - -## 测试要求 - -- [ ] 后端预算服务单元测试。 -- [ ] 申请预算预占测试。 -- [ ] 报销预算核销测试。 -- [ ] 预算不足阻断测试。 -- [ ] 前端预算中心列表测试。 -- [ ] 前端预算详情台账测试。 -- [ ] 首页预算汇总测试。 - -## 演示数据 - -至少准备: - -- 一个预算充足的部门预算。 -- 一个预算不足的部门预算。 -- 一个项目预算。 -- 一个会议培训大额预算。 -- 一个已经预占的申请。 -- 一个已经核销的报销。 -- 一个超预算待审批事项。 - -## 最终验收 - -- [ ] 预算中心能解释每一分钱从哪里来、到哪里去。 -- [ ] 费用申请不能绕过预算。 -- [ ] 报销审批不能绕过预算。 -- [ ] 审批、归档、看板显示同一套预算数据。 -- [ ] 演示链路可连续跑通。 - diff --git a/document/development/employee-behavior-profile/CONCEPT.md b/document/development/employee-behavior-profile/CONCEPT.md index 237fd8b..fdac8b0 100644 --- a/document/development/employee-behavior-profile/CONCEPT.md +++ b/document/development/employee-behavior-profile/CONCEPT.md @@ -623,6 +623,14 @@ $$ AI 协作、审批效率和审批把关默认放在运营视图或管理员视图中展示。审批详情如需展示,必须明确标注“不参与费用风险裁决”。 +个人工作台的用户画像详情允许在行为雷达右上角提供视角切换,避免把不同性质的指标混成单一结论: + +- `financial_risk` / 财务风险视角:默认面向普通员工画像,展示费用强度、申请节奏、差旅招待、材料完整度压力、流程压力。 +- `collaboration_governance` / 协作治理视角:展示 AI 协作强度、审批效率特征、审批把关特征,用于管理员或运营人员查看系统协作和流程治理行为。 +- `all_behavior` / 全部行为视角:展示全部雷达维度,满足用户查看完整操作和行为细节的需求。 + +切换只改变雷达图可视维度和雷达下方的行为标签过滤结果,不改变后端画像快照、上方画像标签总列表、标签证据和审批优先级分。审批详情的“风险审核画像”仍默认只展示费用审核相关维度。 + ## 8. 测试方案 - 单元测试:覆盖归一化、同组降级、四类画像评分、等级映射、审核建议生成。 diff --git a/document/development/employee-behavior-profile/TODO.md b/document/development/employee-behavior-profile/TODO.md index 61e28c0..dbf6762 100644 --- a/document/development/employee-behavior-profile/TODO.md +++ b/document/development/employee-behavior-profile/TODO.md @@ -142,3 +142,12 @@ docker exec x-financial-main bash -lc "cd /app && timeout 60s npm --prefix web r - [x] 新增前端构建或组件测试,确认标签和雷达图在正常态、空态、低样本态下展示稳定。[CONCEPT: 前端展示] 证据:`npm --prefix web run build` 通过。 - [x] 后端验证在 Docker 容器执行,命令设置 60s 超时。[CONCEPT: 测试方案] 证据:`pytest ... -q` 结果 `9 passed in 6.20s`,Ruff `All checks passed!`。 - [ ] 前端验证通过后补充截图或交互验证说明,并回勾阶段 9 未完成项。[CONCEPT: 指标与验收] + +## 阶段 12:个人画像雷达视角切换 + +- [x] 在 `CONCEPT.md` 补充个人画像详情的雷达视角切换契约,明确财务风险、协作治理、全部行为三档。[CONCEPT: 行为雷达图] 证据:`CONCEPT.md` 7.10 已补充三档视角和边界。 +- [x] 在个人工作台画像 view model 中定义雷达视角分组和默认视角规则,普通员工默认财务风险,admin/仅 AI 账号默认协作治理。[CONCEPT: 行为雷达图] 证据:`employeeProfileViewModel.js` 新增 `USER_PROFILE_RADAR_VIEW_OPTIONS`、`resolveUserProfileDefaultRadarView()`。 +- [x] 在 `ExpenseProfileDetailModal.vue` 的行为雷达标题右上角增加小型下拉切换,复用 Element Plus 控件。[CONCEPT: 前端展示] 证据:弹窗使用 `ElSelect` / `ElOption` 渲染雷达视角下拉。 +- [x] 切换雷达视角时过滤展示维度和雷达下方行为标签,不改变上方画像标签、核心指标和最近操作列表。[CONCEPT: 权限和边界] 证据:`filterUserProfileRadarDimensions()` 与 `filterUserProfileTagsByRadarView()` 仅作用于雷达区入参。 +- [x] 保持审批详情 `EmployeeProfileRiskCard.vue` 不混入协作治理维度。[CONCEPT: 审批详情卡片] 证据:本次未修改审批详情风险卡片。 +- [x] 运行前端构建,并用浏览器确认个人画像详情的三档切换可用、空态稳定。[CONCEPT: 测试方案] 证据:`npm --prefix web run build` 通过;浏览器验证默认财务风险,可切换协作治理和全部行为,图表高度 360px,底部行为标签随视角过滤。 diff --git a/document/development/hermes-risk-graph-algorithm/CONCEPT.md b/document/development/hermes-risk-graph-algorithm/CONCEPT.md new file mode 100644 index 0000000..0f6e238 --- /dev/null +++ b/document/development/hermes-risk-graph-algorithm/CONCEPT.md @@ -0,0 +1,1019 @@ +# 数字员工财务行为图谱风险算法方案 + +更新日期:2026-05-29 + +## 1. 功能一句话 + +以数字员工为后台执行入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。 + +## 2. 背景与问题 + +X-Financial 已经具备费用申请、报销、审批、规则中心、知识制度归集、Agent 运行记录和数字员工任务入口。当前系统的风险能力主要来自规则命中、人工审核和局部页面展示,后续如果只继续增加单点技能,会出现几个问题: + +- 风险判断缺少统一载体,不同页面、规则和数字员工各自输出,难以汇总为算法资产。 +- 知识制度、员工画像、票据、审批链和历史反馈之间没有形成稳定关联。 +- 单据详情能看到风险描述,但很难直观看到“为什么异常”和“和谁相比异常”。 +- 分析看板如果只从散表拼统计图,会成为展示型页面,不能反映算法效果。 +- 数字员工如果只做定时任务,会变成调度工具,不能形成核心壁垒。 + +本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、规则发现。 + +## 3. 核心判断 + +数字员工可以做后台数据分析、画像分析和风险分析,但壁垒不在“多几个技能”,而在: + +- 公司制度的结构化能力。 +- 报销、预算、票据、审批、供应商、员工和部门之间的业务图谱。 +- 员工、部门、供应商和费用类型的长期行为基线。 +- 审批结果、退回原因、人工复核结论和误报反馈。 +- 可解释的风险观察模型和持续反馈闭环。 + +最终目标是让系统回答六个问题: + +- 谁异常。 +- 哪个业务环节异常。 +- 和什么基线相比异常。 +- 关联哪条制度或规则。 +- 历史相似情况怎么处理。 +- 当前应该怎么处置。 + +### 3.1 不可复制壁垒设计 + +别人可以复制页面、规则名称、图谱展示甚至部分算法论文,但很难复制 X-Financial 长期沉淀的业务语义、过程数据、人工反馈和证据闭环。系统壁垒必须设计在这些不可外购、不可快速补齐的资产上。 + +第一层壁垒:专有财务语义本体。 + +```text +公司制度条款 + -> 费用类型本体 + -> 风险信号本体 + -> 审批场景本体 + -> 预算科目本体 + -> 票据与附件要求本体 +``` + +该本体不是通用词典,而是由公司制度、历史审批、费用类型、预算口径、组织结构和管理员修订持续训练出来。外部系统即使知道字段名,也缺少本体版本、别名映射、风险信号口径和制度条款绑定关系。 + +第二层壁垒:对象中心财务事件日志。 + +```text +申请 -> 预算占用 -> 票据上传 -> 审批 -> 退回 -> 修改 -> 付款 -> 归档 -> 复盘 +``` + +每个事件同时绑定员工、部门、费用类型、供应商、审批人、票据、制度条款和 AgentRun。长期积累后,系统知道“正常流程是什么”“异常流程如何出现”“哪些异常最终被确认”,这是单纯规则库无法复制的。 + +第三层壁垒:风险观察反馈池。 + +每条 `RiskObservation` 都沉淀: + +```text +风险信号 +证据路径 +本体解析 +规则命中 +画像偏离 +图谱异常 +人工处理结果 +误报/确认反馈 +算法版本 +``` + +这会形成自有训练集、评测集和规则优化样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。 + +第四层壁垒:人机共审行为数据。 + +系统不仅记录“AI 判断什么”,还记录“人如何处理 AI 的判断”: + +```text +采纳 +驳回 +改写 +退回 +补件 +升级审批 +标记误报 +生成候选规则 +``` + +这些反馈会反向影响规则质量、抽审比例、自动化门控和数字员工能力考核。越使用越贴近企业自己的财务控制风格。 + +第五层壁垒:可回放评测资产。 + +每个风险版本都必须能离线回放: + +```text +同一批历史单据 +同一本体版本 +同一规则版本 +同一算法版本 +同一反馈标签 +``` + +这样系统不是“调参数看感觉”,而是可以证明新算法是否降低误报、提高确认率、减少人工审核量。长期回放集会成为核心资产。 + +因此,本项目的算法壁垒不是某一个模型,而是: + +```text +专有本体 ++ 对象中心事件日志 ++ 风险观察反馈池 ++ 人机共审行为数据 ++ 可回放评测资产 ++ 规则/图谱/画像/制度的版本化证据链 +``` + +这些资产必须在产品和数据库设计阶段就开始沉淀,否则后续即使接入复杂模型,也只会是可复制的通用能力。 + +## 4. 目标与非目标 + +### 4.1 目标 + +- 建立统一的 `RiskObservation` 风险观察模型,承接规则中心、数字员工、单据审核和图谱分析结果。 +- 建立财务行为图谱,关联员工、部门、供应商、票据、费用类型、单据、审批人、制度条款和风险规则。 +- 建立员工、部门、供应商、费用类型画像,提供同类基线和异常偏离度。 +- 在单据详情中展示风险证据链,解决单个风险解释问题。 +- 在数字员工工作记录详情中展示本次分析结果和图谱构建结果,解决数字员工“做了什么”的解释问题。 +- 在分析看板中增加风险看板,解决整体风险态势、算法效果和待处理风险管理问题。 +- 把人工反馈、审批处理结果和误报标记写回风险闭环。 + +### 4.2 非目标 + +- 第一版不做独立“大图谱中心”,避免做成展示型页面。 +- 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和候选规则发现。 +- 不用画像自动惩罚员工,不给员工永久贴标签。 +- 不在员工技能详情中展示知识归集图谱;图谱结果只进入工作记录详情、单据风险详情或画像详情。 +- 不让数字员工自动上线规则;规则候选必须经过管理员审核。 + +## 5. 用户与场景 + +### 5.1 审批人 + +入口:单据详情。 + +关注点: + +- 当前单据为什么被标为风险。 +- 和本人历史、同部门同级别、同费用类型相比是否异常。 +- 命中了哪条制度或规则。 +- 历史相似单据是通过、退回还是调整金额。 +- 当前建议动作是什么。 + +### 5.2 财务与审计人员 + +入口:风险看板、风险详情、数字员工工作记录。 + +关注点: + +- 哪些部门、费用类型、供应商、员工风险集中。 +- 哪些风险重复出现。 +- 哪些规则误报率高。 +- 图谱发现了哪些异常关系。 +- 哪些风险需要优先复核。 + +### 5.3 管理员 + +入口:数字员工、规则中心、系统设置。 + +关注点: + +- 数字员工运行是否成功。 +- 本次分析处理了多少数据、产出多少风险观察。 +- 候选规则是否有足够证据。 +- 是否需要调整技能、规则、制度知识或调度配置。 + +### 5.4 管理层 + +入口:分析看板中的风险看板。 + +关注点: + +- 整体风险态势是否恶化。 +- 涉及金额和高风险待处理量。 +- 部门、费用类型和供应商风险分布。 +- 风险处理效率和算法确认率。 + +## 6. 功能能力 + +### 6.1 数字员工能力分层 + +第一版建议保留四类核心数字员工: + +- 制度整理员工:把公司财务制度整理成条款、适用范围、费用类型、触发条件和引用关系。 +- 风险扫描员工:定期扫描新增单据、票据、供应商、审批链和画像偏离,生成风险观察。 +- 画像更新员工:定期更新员工、部门、供应商、费用类型画像和同类基线。 +- 规则发现员工:从历史退回、误报、漏报、制度变化和高频异常中生成候选规则。 + +### 6.2 图谱体现方式 + +图谱不应该默认展示成全量关系网。第一版按业务场景拆成四种可读形态: + +- 单据详情:风险证据链。 +- 风险详情:小范围关系穿透图。 +- 工作记录详情:本次分析图谱和产出摘要。 +- 风险看板:风险地图、分布、趋势和算法效果。 + +### 6.3 单据详情风险证据链 + +建议在现有风险说明或审核建议附近增加“风险证据链”区块: + +```text +员工张三 + -> 提交差旅报销 4,860 元 + -> 命中住宿费用异常 + -> 同部门同级别平均 680 元/晚,本单 1,260 元/晚 + -> 关联制度:差旅住宿标准第 3 条 + -> 历史相似单据 12 笔,其中 8 笔被退回 + -> 建议:补充说明或人工复核 +``` + +显示内容: + +- 风险结论。 +- 证据链节点。 +- 同类基线对比。 +- 命中制度条款。 +- 历史相似案例。 +- 建议动作。 + +### 6.4 数字员工工作记录详情 + +工作记录详情展示数字员工本次任务结果,不展示员工技能定义。 + +建议展示: + +- 本次扫描范围。 +- 处理实体数量。 +- 生成风险观察数量。 +- 关键异常关系。 +- 失败原因和跳过数据。 +- 可点击的风险观察列表。 +- 对知识制度整理类任务,展示知识制度记录图谱。 + +### 6.5 分析看板风险看板 + +风险看板放在分析看板中作为独立页签,定位是风险算法驾驶舱。 + +第一版模块: + +- 风险总览:今日新增风险数、高风险待处理数、涉及金额、已确认风险数、误报数量。 +- 风险分布:按部门、费用类型、风险类型、供应商、员工职级分布。 +- 风险趋势:7 天 / 30 天风险走势、高风险占比、重复触发趋势、处理完成率。 +- 异常排行:风险最多部门、偏离基线最大员工、高频异常供应商、高频触发规则。 +- 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。 + +风险看板必须从 `RiskObservation` 聚合,不直接从散表临时拼图表。 + +### 6.6 画像详情 + +画像详情作为第二阶段,不抢第一版主线。 + +可支持: + +- 员工画像:费用结构、历史风险、同级对比、供应商关联、退回率。 +- 部门画像:预算压力、费用结构、风险热力、审批效率。 +- 供应商画像:关联员工、关联部门、重复票据、金额集中度。 +- 费用类型画像:周期性、异常波动、制度命中频率。 + +## 7. 方案设计 + +### 7.1 总体架构 + +```text +业务数据 + -> 财务事件层 + -> 实体图谱层 + -> 画像基线层 + -> 风险推理层 + -> RiskObservation 风险观察池 + -> 单据详情 / 工作记录详情 / 风险看板 / 规则候选 + -> 人工反馈 + -> 画像、规则、制度知识持续优化 +``` + +### 7.2 事件层 + +统一沉淀业务事件,避免不同模块重复解释业务动作。 + +候选事件: + +```text +claim_created +claim_submitted +invoice_uploaded +approval_passed +approval_returned +amount_adjusted +budget_reserved +payment_completed +policy_document_ingested +risk_rule_triggered +digital_employee_run_completed +manual_feedback_submitted +``` + +### 7.3 实体图谱层 + +节点类型: + +```text +employee +department +position +vendor +invoice +expense_claim +expense_item +expense_type +approval_user +policy_clause +risk_rule +risk_observation +digital_employee_run +``` + +边类型: + +```text +belongs_to +submitted +contains_item +uses_invoice +paid_to_vendor +approved_by +matches_policy_clause +triggered_rule +similar_to +generated_by_run +confirmed_by_feedback +``` + +第一版不要求必须引入图数据库。可以先在关系型数据库中存储节点、边和 `path_json`,等查询复杂度上升后再接入专用图数据库。 + +### 7.4 本体与风险图谱桥接 + +本体层是风险图谱的语义骨架,不是图谱的替代品。图谱负责记录“谁和谁有关”,本体负责定义“这个关系在财务语义上是什么”。风险图谱中的节点、边、风险信号、规则匹配和看板聚合都必须引用本体标准化结果。 + +现有系统已有 `/api/v1/ontology/parse`、`SemanticParseLog`、`scenario`、`intent`、`entities`、`risk_flags`、`missing_slots` 等基础能力。风险图谱不应另建解析器,而应复用本体解析结果,并在必要时扩展本体词典和风险信号。 + +本体输出进入风险图谱的最小协议: + +```text +ontology_parse_id +ontology_version +domain +scenario +intent +entities +constraints +risk_signals +missing_slots +confidence +``` + +本体实体映射到图谱节点: + +```text +ontology entity graph node +employee employee +department department +expense_type expense_type +document_type invoice / expense_claim / contract / payment_record +vendor vendor +policy_clause policy_clause +risk_signal risk_observation / risk_signal +budget_subject budget_subject +project project +location location +``` + +图谱节点必须保留本体标准键: + +```text +node_type employee / claim / invoice / vendor / policy_clause / ... +ontology_type expense_type / document_type / risk_signal / organization / ... +canonical_key hotel / travel / duplicate_invoice / over_standard / ... +canonical_id 标准实体 ID,可为空但必须记录 canonical_key +source_object_type expense_claim / invoice / policy_document / agent_run / ... +source_object_id +ontology_parse_id +ontology_version +``` + +图谱边必须来自白名单,不能让数字员工自由创造运行时边类型: + +```text +submitted 员工提交单据 +belongs_to 员工归属部门 +contains_item 单据包含费用明细 +uses_invoice 费用明细使用票据 +paid_to_vendor 付款或票据关联供应商 +approved_by 单据由某审批人审批 +matches_policy_clause 单据或风险观察关联制度条款 +triggered_risk_signal 单据触发本体风险信号 +triggered_rule 单据命中规则 +similar_to 与历史对象相似 +generated_by_run 由数字员工运行生成 +confirmed_by_feedback 由人工反馈确认 +``` + +风险信号必须本体化。不同文本说法要映射到同一标准风险信号,例如: + +```text +住宿超标 / 酒店超标 / 差旅住宿异常 + -> risk_signal = accommodation_standard_deviation + -> scenario = travel_reimbursement + -> expense_type = hotel + +重复发票 / 发票重复 / 票据重复报销 + -> risk_signal = duplicate_invoice + -> scenario = invoice_validation + -> document_type = invoice +``` + +规则中心、图谱引擎和风险看板必须按同一套本体口径聚合: + +```text +规则中心:scenario + expense_type + risk_signal 决定规则适用范围 +图谱引擎:canonical node + whitelisted edge 构造证据路径 +风险观察:ontology fields + evidence path 输出可解释结论 +风险看板:按 ontology scenario / expense_type / risk_signal 聚合 +数字员工:只能产出本体可识别的候选风险信号和候选规则 +``` + +当本体置信度不足时,风险图谱必须降级: + +```text +confidence >= 0.85 可进入自动规则匹配和图谱证据构建 +0.60 <= confidence < 0.85 进入人工复核或半自动模式 +confidence < 0.60 只记录候选观察,不触发强拦截 +``` + +### 7.5 统一风险观察模型 + +核心结构: + +```text +RiskObservation +- id +- subject_type employee / department / vendor / claim / invoice / expense_type +- subject_id +- risk_type +- severity low / medium / high / critical +- score 0-100 +- status open / confirmed / false_positive / resolved / ignored +- evidence_items_json +- evidence_path_json +- related_policy_clauses_json +- related_entities_json +- comparable_baseline_json +- suggested_actions_json +- source_type rule / graph / profile / policy / digital_employee +- source_id +- ontology_parse_id +- ontology_version +- domain +- scenario +- intent +- ontology_entities_json +- ontology_constraints_json +- risk_signals_json +- canonical_subject_key +- algorithm_version +- feedback_status +- created_at +- updated_at +``` + +### 7.6 API 契约建议 + +单据详情读取风险证据链: + +```text +GET /api/v1/risk-observations/by-claim/{claim_id} +``` + +风险观察详情: + +```text +GET /api/v1/risk-observations/{observation_id} +``` + +风险看板聚合: + +```text +GET /api/v1/risk-observations/analytics/summary +``` + +数字员工工作记录关联风险观察: + +```text +GET /api/v1/agent-runs/{run_id}/risk-observations +``` + +人工反馈: + +```text +POST /api/v1/risk-observations/{observation_id}/feedback +``` + +本体图谱映射调试接口建议只面向管理员: + +```text +GET /api/v1/risk-graph/ontology-mapping/{object_type}/{object_id} +``` + +返回当前对象关联的本体解析、标准实体、图谱节点、图谱边和降级原因,用于排查风险解释是否来自正确语义。 + +### 7.7 前端入口关系 + +```text +分析看板 + -> 风险看板:整体态势和算法效果 + +单据详情 + -> 风险证据链:解释单个单据风险 + +数字员工 + -> 员工技能:配置技能和 Markdown 源文件 + -> 工作记录详情:展示本次任务结果和图谱结果 + +规则中心 + -> 风险规则详情:展示规则定义、条件、执行逻辑 + +画像详情 + -> 员工 / 部门 / 供应商长期画像 +``` + +## 8. 算法与公式 + +### 8.1 风险总分 + +第一版使用可解释加权模型,不直接使用黑盒模型给最终分。 + +$$ +risk\_score = +clip(0.35S_{rule} + 0.25S_{anomaly} + 0.20S_{graph} + 0.15S_{policy} + 0.05S_{history}, 0, 100) +$$ + +变量定义: + +- \(S_{rule}\):确定性规则命中分,来自规则中心或制度规则。 +- \(S_{anomaly}\):画像和同类基线偏离分。 +- \(S_{graph}\):图谱关系异常分,例如重复票据、供应商集中、审批链异常。 +- \(S_{policy}\):制度语义相关分,例如说明、票据和制度条款不一致。 +- \(S_{history}\):历史反馈分,例如相似案例退回率、确认风险率。 + +### 8.2 同类基线偏离 + +对金额、频次、天数、退回率等数值型指标,优先使用稳健统计: + +$$ +deviation = \frac{x - median(peer)}{max(IQR(peer), \epsilon)} +$$ + +$$ +S_{anomaly} = 100 \times sigmoid(k(deviation - \tau)) +$$ + +变量定义: + +- \(x\):当前员工、部门、供应商或单据指标。 +- \(peer\):同部门、同职级、同费用类型、同城市级别等同类样本。 +- \(IQR\):四分位距。 +- \(\epsilon\):防止分母为零的最小值。 +- \(\tau\):异常阈值。 +- \(k\):放大系数。 + +### 8.3 图谱异常分 + +第一版图谱异常分可以采用可解释信号累加: + +$$ +S_{graph} = min(100, \sum_{i=1}^{n} w_i g_i) +$$ + +候选信号: + +- 重复票据:同一票据号、金额、供应商或影像指纹重复。 +- 供应商集中:某员工或部门在窗口期内高度集中到少数供应商。 +- 审批链异常:审批人和申请人关系异常、审批路径绕行或过短。 +- 时间地点异常:业务地点、票据地点、行程地点不一致。 +- 相似单据异常:与历史退回或调整金额单据高度相似。 + +### 8.4 人工反馈校准 + +人工反馈不直接覆盖算法,但会影响后续权重和候选规则优先级: + +$$ +confirmed\_rate = \frac{confirmed}{confirmed + false\_positive + ignored} +$$ + +$$ +rule\_quality = 0.6 \times confirmed\_rate + 0.4 \times coverage +$$ + +其中 `coverage` 表示该规则或风险类型在目标场景中的有效覆盖率。 + +## 9. 公开竞品资料借鉴 + +### 9.1 资料边界 + +本节只基于公开资料提炼可借鉴的算法模式,不假设能够获得用友费用或合思费控的内部算法实现。公开资料能证明的是产品能力、架构方向、典型场景和治理方法;工程实现仍需要结合 X-Financial 的本体、规则中心、数字员工、AgentRun 和业务数据重新设计。 + +参考资料: + +- [用友 YonBIP 财务云智能费控服务白皮书](https://mks.yybip.com/group1/M00/07/EB/CgoRC2JVTMGAPdWmAEdtt5GGOf0756.pdf) +- [用友数智化财务资料:商旅费控、事项法会计与 AI 能力](https://mks.yybip.com/group1/M00/0A/29/CgoRC2XvFQuAKvNtACX8GJS9Zgo009.pdf) +- [合思 AI 官网:7 大 Agent、700+ skills、财务审核专家](https://www.ekuaibao.com/hoseai.html) +- [合思 AI 财务审核专家](https://www.ekuaibao.com/aifinancialapproval.html) +- [合思企业内控解决方案](https://www.ekuaibao.com/solutionsr/control.html) +- [合思 AI 审核风控升级文章](https://www.ekuaibao.com/blog/620731.html) + +### 9.2 用友费用可借鉴模式 + +公开资料中,用友费控的重点不是单点审核,而是“事前规划、事中控制、事后分析”的端到端费控链路。可借鉴点如下: + +- 流程前置:在出差申请、商旅预订、报销、稽核、核算、结算、归档全流程内嵌管控,而不是只在报销提交后审核。 +- 规则模板库:公开资料提到“超级差规”基于规则引擎,并预置大量差旅规则模板。X-Financial 应建设按本体场景绑定的规则模板族,而不是只存自由文本规则。 +- 商旅比价与推荐:根据出行时间、目的地、企业标准和供应商价格推荐合规方案。X-Financial 可将其抽象为“候选行为合规推荐”,服务事前控制。 +- 多服务连接:连接商旅、用车、餐饮、采购、电子发票等外部服务,交易数据自动同步。X-Financial 可把这些数据统一成图谱事件,而不是只做附件。 +- 预算刚柔控制:预算规则支持事前、事中、刚性、柔性控制。X-Financial 的风险观察应记录 `control_stage` 和 `control_mode`,区分禁止、预警、复核、提示。 +- 信用与抽审:公开架构中出现信用管理、指标管理、评价管理、抽审规则和抽审单据。X-Financial 可借鉴为“风险分层抽审”,不是所有单据都同等审核。 + +落地到 X-Financial,建议新增三个算法组件: + +```text +PolicyTemplateLibrary + -> 按 ontology scenario / expense_type / risk_signal 绑定规则模板 + +PreControlRecommender + -> 在申请和预订阶段给出合规选项、预算影响和风险提示 + +RiskSamplingPlanner + -> 根据风险分、历史误报率、员工画像和规则质量决定抽审比例 +``` + +### 9.3 合思费控可借鉴模式 + +公开资料中,合思更强调 AI 审核、人机共审、专属规则、跨材料校验和持续反馈。可借鉴点如下: + +- AI 数字员工治理:官网强调类似新员工管理的“培训、授权、监督、考核”。X-Financial 的数字员工也应该有能力边界、授权范围、低置信度转人工和运行质量考核。 +- 结构化信息提取:公开资料强调从单据、发票、流水、合同、票据等材料提取结构化信息。X-Financial 应把 OCR、附件解析和本体实体统一进证据模型。 +- 跨材料交叉验证:公开资料提到单据、发票、支付水单、事前申请事项的比对,以及单据、发票、流水一致性校验。X-Financial 应把它抽象为“多凭证一致性图谱”。 +- 时空推理:公开资料提到结合时空推理还原真实轨迹。X-Financial 可在差旅、用车、住宿、餐饮场景中引入时间、地点、行程、票据地点一致性信号。 +- 自然语言规则配置:公开资料提到财务可用日常语言编辑规则、制度导入导出、离线测试调优。X-Financial 应让制度整理员工产出规则候选,但上线必须走模板、测试和审核。 +- 三种自动化模式:辅助审批、半自动、全自动。X-Financial 应将其产品化为风险自动化等级,而不是一开始追求全自动。 +- 正负样本与对抗样本:公开资料提到正负向样本、反事实、噪声干扰、低置信度转人工。X-Financial 的反馈闭环应明确样本池和模型评测集。 +- 结果可追溯:合思公开页面强调风险点、依据、建议、分级可溯和看板下钻。X-Financial 的 `RiskObservation` 必须保留证据、来源、算法版本和反馈状态。 + +落地到 X-Financial,建议新增四个算法组件: + +```text +MultiEvidenceReconciler + -> 单据、发票、流水、合同、行程、申请之间的一致性校验 + +SpatioTemporalRiskEngine + -> 时间、地点、行程、消费、开票之间的时空一致性检查 + +HumanInLoopAutomationGate + -> 按置信度、风险等级、证据覆盖、历史误报率决定辅助/半自动/自动 + +RiskEvaluationDataset + -> 正样本、负样本、反事实样本、噪声样本和历史误报样本 +``` + +### 9.4 对当前方案的补强 + +结合公开资料,本方案需要补强五点: + +1. 从“事后风险识别”扩展为“事前申请、事中消费、事后审核”的分阶段风险控制。 +2. 从“规则命中”扩展为“本体模板 + 多凭证一致性 + 时空推理 + 行为画像”的组合判断。 +3. 从“统一风险分”扩展为“风险分 + 自动化等级 + 抽审策略”。 +4. 从“单次数字员工任务”扩展为“训练、授权、监督、考核”的数字员工治理。 +5. 从“人工反馈记录”扩展为“样本池、离线回放、规则质量、模型质量”的持续评测体系。 + +建议在 `RiskObservation` 增加字段: + +```text +control_stage pre_application / booking / submission / approval / payment / archive +control_mode block / warn / require_review / suggest / observe +automation_mode assist / semi_auto / auto +confidence_score 0-1 +evidence_coverage_count +sampling_strategy full_review / risk_sampling / random_sampling / exempt +evaluation_case_id +``` + +自动化等级建议使用可审计门控: + +$$ +automation = +\begin{cases} +auto, & confidence \ge \theta_{auto} \land severity \le medium \land evidence\_coverage \ge 2 \land false\_positive\_rate \le \alpha \\ +semi\_auto, & confidence \ge \theta_{semi} \land evidence\_coverage \ge 1 \\ +assist, & otherwise +\end{cases} +$$ + +这个门控比“AI 直接通过/驳回”更适合企业费控,因为它把自动化建立在置信度、风险等级、证据覆盖和历史误报率之上。 + +### 9.5 可集成的外部算法技术栈 + +除友商公开能力外,还可以引入一组成熟的公开技术,形成更深的算法壁垒。核心原则是:先把技术映射到 X-Financial 的本体、图谱、风险观察和反馈闭环,不为了复杂而复杂。 + +参考资料: + +- [OCEL 2.0 Object-Centric Event Log](https://www.ocel-standard.org/) +- [IEEE Task Force Process Mining Manifesto](https://www.tf-pm.org/resources/manifesto) +- [Temporal Graph Networks](https://arxiv.org/abs/2006.10637) +- [GraphSAGE](https://papers.neurips.cc/paper/6703-inductive-representation-learning-on-large-graphs) +- [metapath2vec](https://www.kdd.org/kdd2017/papers/view/metapath2vec-scalable-representation-learning-for-heterogeneous-networks) +- [Heterogeneous Graph Attention Network](https://arxiv.org/abs/1903.07293) +- [Isolation Forest](https://cs.nju.edu.cn/zhouzh/zhouzh.files/publication/icdm08b.pdf) +- [Fellegi-Sunter record linkage](https://pmc.ncbi.nlm.nih.gov/articles/PMC9336505/) +- [SHAP](https://arxiv.org/abs/1705.07874) +- [Decision Model and Notation](https://www.omg.org/dmn/) +- [Open Policy Agent](https://www.openpolicyagent.org/docs/latest) +- [DoWhy causal inference](https://www.pywhy.org/dowhy/v0.9.1/user_guide/effect_inference/index.html) +- [OpenLineage](https://openlineage.io/) +- [Great Expectations](https://docs.greatexpectations.io/docs/guides/validation/validate_data_overview/) + +#### 9.5.1 对象中心过程挖掘 + +传统流程分析通常按单一 case 展开,例如一张报销单。但财务真实流程是多对象交织:申请、报销、票据、合同、付款、供应商、预算、审批人同时参与。OCEL 2.0 的对象中心事件日志适合承载这种复杂关系。 + +建议新增 `FinancialObjectEventLog`: + +```text +event_id +activity claim_submitted / invoice_uploaded / approval_returned / payment_completed +timestamp +actor_id +object_refs_json claim / invoice / vendor / budget / payment / policy_clause +attributes_json +ontology_parse_id +source_run_id +``` + +可形成的风险能力: + +- 流程偏离:实际审批路径和标准流程不一致。 +- 返工循环:反复退回、补件、重提。 +- 跳步审批:缺少关键节点或审批路径过短。 +- 付款前异常:付款早于必要审批或票据校验。 +- 供应商集中流程:同一供应商相关单据绕过常规节点。 + +对应算法组件: + +```text +ObjectCentricProcessMiner + -> 从业务表和 AgentRun 构造对象中心事件日志 + +ConformanceRiskDetector + -> 对比标准流程模型和实际事件路径,输出流程偏离风险 +``` + +#### 9.5.2 实体解析与主数据归一 + +风险图谱的质量取决于实体是否归一。供应商、商户、酒店、员工姓名、发票销售方、银行户名经常存在别名、错别字、简称和大小写差异。Fellegi-Sunter 记录链接、主动学习实体去重和人工确认机制都可以借鉴。 + +建议新增 `EntityResolutionService`: + +```text +entity_type vendor / merchant / hotel / employee / bank_account +raw_name +canonical_key +canonical_id +match_score +match_method exact / rule / probabilistic / embedding / human +evidence_json +review_status +``` + +可形成的风险能力: + +- 同一供应商多名称拆分识别。 +- 发票销售方和付款户名疑似同一主体识别。 +- 酒店、商户、供应商别名归一。 +- 员工、审批人、外部联系人身份消歧。 + +对应算法组件: + +```text +FinancialEntityResolver + -> 字符串相似度 + 频率权重 + 业务字段校验 + 人工确认 + +CanonicalEntityRegistry + -> 维护财务图谱中的标准主体 ID +``` + +#### 9.5.3 异构图与时序图学习 + +X-Financial 的图谱天然是异构图:员工、部门、供应商、票据、单据、制度条款和规则都不是同一种节点。metapath2vec、HAN、GraphSAGE、Temporal Graph Networks 等技术可以用于发现人工规则难以覆盖的结构风险。 + +第一版不建议直接上深度 GNN 作为主判定。建议先用可解释图特征,再把图嵌入作为候选信号: + +```text +metapath features: +employee -> claim -> vendor +employee -> claim -> invoice -> vendor +department -> employee -> claim -> vendor +claim -> expense_type -> policy_clause +approver -> claim -> employee -> department + +temporal features: +最近 7 / 30 / 90 天边新增速度 +供应商集中度变化 +审批链长度变化 +同类风险信号传播路径 +``` + +对应算法组件: + +```text +HeterogeneousRiskGraphFeatureBuilder + -> 构造元路径、中心性、团簇、邻域风险密度等可解释图特征 + +TemporalRiskGraphMonitor + -> 监控关系在时间上的突增、消失、迁移和异常传播 +``` + +#### 9.5.4 多模型异常检测组合 + +单一异常检测模型不适合作为最终风控结论,但可以作为风险观察的候选信号。建议组合稳健统计、Isolation Forest、局部离群、时间序列突变和规则命中。 + +候选信号: + +```text +robust_z_score 同类分位偏离 +isolation_score 多维行为孤立度 +local_outlier_score 邻域异常 +change_point_score 时间序列突变 +seasonality_score 周期偏离 +``` + +组合方式: + +$$ +S_{anomaly\_ensemble} = +0.35S_{robust} + 0.20S_{isolation} + 0.15S_{local} + 0.20S_{change} + 0.10S_{seasonality} +$$ + +该分数只作为 `RiskObservation` 的一类证据,不能单独触发强拦截。 + +#### 9.5.5 决策建模与策略即代码 + +规则中心适合承载确定性规则,但复杂审批策略需要拆成“业务决策表 + 策略执行 + 审计日志”。DMN 和 Open Policy Agent 的思路可以借鉴。 + +建议拆分: + +```text +DecisionTable + -> 业务可读决策表,例如不同职级、城市、费用类型的标准 + +PolicyRuntime + -> 执行策略、权限、自动化门控、抽审策略 + +DecisionTrace + -> 记录输入、命中行、输出、版本、解释 +``` + +这样规则中心负责版本和发布,风险图谱引擎消费 `DecisionTrace` 作为证据。 + +#### 9.5.6 可解释与不确定性控制 + +企业费控不能只输出分数,需要输出证据贡献、置信度、低置信度原因和人工复核建议。SHAP 类解释方法、置信区间、保守门控和人工反馈可以组合使用。 + +建议每条风险观察保存: + +```text +feature_contributions_json +confidence_score +uncertainty_reasons_json +required_human_review +explanation_template_key +``` + +门控原则: + +- 强制度规则命中:优先使用规则解释。 +- 多证据一致:允许进入半自动。 +- 只有模型异常:只进入候选观察或抽审池。 +- 低置信度本体解析:不触发强拦截。 + +#### 9.5.7 因果分析与反事实建议 + +风险看板不应只回答“哪里风险多”,还要回答“哪些控制动作真的降低风险”。DoWhy 类因果推断框架可以用于后续分析,但第一版先做可回放的反事实建议。 + +示例: + +```text +如果补充酒店水单,当前风险分从 76 降到 48 +如果选择协议酒店,住宿标准异常消失 +如果审批链增加预算负责人复核,付款前风险等级降为中 +``` + +对应组件: + +```text +CounterfactualRiskAdvisor + -> 给出降低风险分的可执行补救动作 + +ControlEffectAnalyzer + -> 评估某条规则、抽审策略或数字员工上线前后的风险变化 +``` + +#### 9.5.8 数据血缘与质量门禁 + +复杂风险算法的前提是数据可信。OpenLineage 和 Great Expectations 的思路可以用于记录数据来源、校验质量和阻止低质量数据进入强风控。 + +建议新增: + +```text +RiskDataLineage + -> 记录风险观察使用了哪些表、文档、OCR、AgentRun、规则版本和本体版本 + +RiskDataQualityGate + -> 校验金额、日期、供应商、费用类型、票据号、审批状态等字段完整性 +``` + +质量门禁策略: + +- 缺少关键金额或票据号:不生成强结论。 +- 本体费用类型低置信度:只生成候选观察。 +- OCR 关键字段冲突:进入多凭证一致性复核。 +- 来源数据过旧:风险观察标记为 stale。 + +## 10. 测试方案 + +### 10.1 后端测试 + +- 风险观察模型序列化测试。 +- 单据风险观察查询接口测试。 +- 风险看板聚合接口测试。 +- 画像基线计算单元测试。 +- 图谱路径构建单元测试。 +- 人工反馈状态流转测试。 +- 自动化门控模式测试。 +- 多凭证一致性校验测试。 +- 风险抽审策略测试。 + +后端验证优先在 Docker 容器中执行: + +```bash +docker exec x-financial-main sh -lc "cd /app && pytest -q" +``` + +单元测试超时建议设置 60s,避免任务卡死。 + +### 10.2 前端测试 + +- 单据详情存在风险观察时展示证据链。 +- 单据详情无风险观察时不占用主流程。 +- 工作记录详情只展示本次任务相关图谱,不污染员工技能详情。 +- 风险看板筛选、趋势、排行和卡片数据一致。 +- 风险观察点击后能回到单据、规则或工作记录。 + +### 10.3 回归测试 + +- 规则中心风险规则详情不被图谱展示污染。 +- 数字员工员工技能详情不显示知识归集图谱。 +- 系统日志和数字员工工作记录边界保持清晰。 +- 分析看板原有指标不受风险看板影响。 + +## 11. 指标与验收 + +### 11.1 业务验收 + +- 审批人能在单据详情看到风险结论、证据链、基线对比、制度条款和建议动作。 +- 财务人员能在风险看板看到整体风险态势、分布、趋势、排行和算法效果。 +- 管理员能在数字员工工作记录详情看到本次任务产出的风险观察和异常关系。 +- 规则候选必须带证据、来源、置信度和人工审核状态。 + +### 11.2 技术验收 + +- 所有风险输出统一落到 `RiskObservation` 或兼容结构。 +- 风险看板只读取风险观察聚合接口,不直接拼接业务散表。 +- 风险观察详情接口返回结构化证据链。 +- 每条风险观察保留算法版本、来源、时间和反馈状态。 +- 关键接口有单元测试或接口测试覆盖。 +- 自动化模式必须有门控原因,不允许只记录 AI 判断结果。 +- 风险抽审策略必须可回放,保留当时的分数、阈值和样本策略。 + +### 11.3 算法验收 + +- 风险观察证据覆盖率不低于 95%。 +- 高风险观察必须至少包含 2 类证据:规则、画像、图谱、制度、历史中的任意两类。 +- 人工确认率、误报率、忽略率可统计。 +- 同类基线样本不足时必须记录降级口径。 +- 自动化放行单据的抽检误报/漏报结果可统计。 +- 评测集必须包含正样本、负样本、反事实样本、噪声样本和历史误报样本。 + +## 12. 风险与开放问题 + +- 是否需要引入图数据库:第一版建议先用关系表和 JSON 路径,后续根据查询复杂度评估。 +- 历史数据质量不足会影响画像基线,需要记录样本量和降级口径。 +- 制度条款结构化质量会直接影响语义风险,需要把制度整理员工作为基础能力持续完善。 +- 风险看板如果没有 `RiskObservation` 统一模型,会退化为普通统计页面。 +- 图谱展示必须小范围、场景化,避免全量关系图降低可读性。 +- 公开竞品资料只能提供产品能力和方法论参考,不能直接证明其内部算法实现;X-Financial 必须以自有数据和可审计实现为准。 diff --git a/document/development/hermes-risk-graph-algorithm/PUBLIC_COMPETITOR_REFERENCE.md b/document/development/hermes-risk-graph-algorithm/PUBLIC_COMPETITOR_REFERENCE.md new file mode 100644 index 0000000..08b874f --- /dev/null +++ b/document/development/hermes-risk-graph-algorithm/PUBLIC_COMPETITOR_REFERENCE.md @@ -0,0 +1,91 @@ +# 公开竞品资料校准与自有算法映射 + +更新日期:2026-05-30 + +## 资料边界 + +本文件只使用公开资料做产品能力和方法论校准,不推断竞品内部算法实现。 +X-Financial 的落地实现必须以自有数据、本体、规则中心、风险观察池、反馈池、 +决策追踪和可回放测试为准。 + +公开资料来源: + +- [用友 YonBIP 财务云智能费控服务白皮书](https://mks.yybip.com/group1/M00/07/EB/CgoRC2JVTMGAPdWmAEdtt5GGOf0756.pdf) +- [用友数智化财务资料:商旅费控、事项法会计与 AI 能力](https://mks.yybip.com/group1/M00/0A/29/CgoRC2XvFQuAKvNtACX8GJS9Zgo009.pdf) +- [合思 AI 财务审核专家](https://www.ekuaibao.com/aifinancialapproval.html) +- [合思 AI 审核解决方案](https://www.ekuaibao.com/solutionsr/check.html) +- [合思企业内控解决方案](https://www.ekuaibao.com/solutionsr/control.html) + +## 用友公开资料校准 + +### 端到端费控链路 + +公开资料覆盖事前申请、商旅预订、智能识票、自动报账、移动审批、智能收单、 +智能审核、自动核算、结算、分析、电子归档等环节。 + +X-Financial 映射: + +- 用 `ObjectCentricEvent` 建立申请、预订、报销、审批、付款、归档、复盘事件。 +- 用 `RiskObservation` 承接每个阶段产生的风险结论。 +- 用 `RiskDataLineage` 记录每条结论引用的单据、票据、规则、本体和 AgentRun。 + +### 规则模板、预算刚柔控制、信用抽审、商旅推荐 + +公开资料中可借鉴的能力包括规则引擎/规则模板、预算事前事中控制、刚性/柔性 +控制、信用管理与抽审规则,以及基于出发时间、目的地、差旅标准和多供应商比价 +的商旅推荐。 + +X-Financial 映射: + +- `PolicyTemplateLibrary`:把制度条款沉淀为按场景、本体实体、费用类型和角色 + 绑定的规则模板族。 +- `PreControlRecommender`:在提交前给出预算、差标、商旅供应商、住宿和交通 + 标准建议。 +- `RiskSamplingPlanner`:结合风险分、员工画像、信用等级、历史误报率和反馈 + 标签,生成抽审策略、阈值和回放桶。 +- `ProfileBaselineUpdater`:定期更新员工、部门、供应商、费用类型基线,为信用 + 抽审和预算柔性控制提供自有画像数据。 + +## 合思公开资料校准 + +### AI 审核与人机共审 + +公开资料强调 AI 先完成规则型检查、风险标记和建议输出,再由财务处理异常、 +灰区和制度优化。X-Financial 不应让 AI 直接替代规则中心,而应把 AI 产出转成 +可解释、可审计、可回放的风险观察。 + +X-Financial 映射: + +- `HumanInLoopAutomationGate`:按置信度、风险等级、证据来源数、历史误报率和 + 数据质量决定自动放行、辅助、人工复核或候选观察。 +- `DecisionTrace`:保留输入、命中行、贡献项、不确定性原因和解释模板。 +- `RiskObservationFeedback`:把确认、误报、忽略、补件、升级、候选规则来源 + 转为闭环样本。 + +### 多凭证校验与时空推理 + +公开资料中,多凭证校验覆盖报销单、发票、水单、订单、小票、合同、行程等材料; +时空校验覆盖消费时间、地点、轨迹、行程逻辑和异常地点。 + +X-Financial 映射: + +- `MultiEvidenceReconciler`:把单据、发票、附件、流水、合同、行程和事前申请 + 统一成证据项,输出字段一致性和缺失项。 +- `SpatioTemporalRiskEngine`:基于发生时间、提交时间、明细时间、地点、行程、 + 开票地点和供应商地点构造时空一致性信号。 +- `RiskDataQualityGate`:证据不足或字段缺失时封顶风险分,避免低质量数据触发 + 强结论。 + +## 转成 X-Financial 自有壁垒 + +竞品资料只作为能力校准。真正不可复制的部分必须沉淀在以下资产中: + +1. 自有财务本体:场景、意图、实体、约束、风险信号、权限、置信度。 +2. 自有对象中心事件日志:每个报销和风控过程可回放。 +3. 自有画像基线:员工、部门、供应商、费用类型、规则、制度条款长期演化。 +4. 自有反馈池:人工确认、误报、补件、升级和候选规则来源。 +5. 自有回放集:正样本、负样本、反事实样本、噪声样本和历史误报样本。 +6. 自有解释资产:证据链、制度条款、相似案例、贡献项、决策追踪和数据血缘。 + +因此,后续实现原则是:不复制竞品页面、术语和流程包装;只吸收公开资料中可验证 +的能力方向,并转译为 X-Financial 的结构化数据、确定性算法、人工反馈和回放测试。 diff --git a/document/development/hermes-risk-graph-algorithm/RISK_SOURCE_AND_MOAT.md b/document/development/hermes-risk-graph-algorithm/RISK_SOURCE_AND_MOAT.md new file mode 100644 index 0000000..1d317f5 --- /dev/null +++ b/document/development/hermes-risk-graph-algorithm/RISK_SOURCE_AND_MOAT.md @@ -0,0 +1,112 @@ +# 风险图谱数据来源与壁垒资产清单 + +更新日期:2026-05-30 + +## 风险相关数据来源 + +1. 报销单主表:`ExpenseClaim` + - 关键字段:`id`、`claim_no`、`employee_id`、`employee_name`、`department_id`、`department_name`、`expense_type`、`amount`、`currency`、`invoice_count`、`occurred_at`、`submitted_at`、`status`、`approval_stage`、`risk_flags_json`。 + - 用途:风险主体、金额基线、流程阶段、规则命中、图谱 claim 节点。 + +2. 报销明细:`ExpenseClaimItem` + - 关键字段:`item_id`、`item_type`、`item_amount`、`item_location`、`item_date`、`invoice_id`。 + - 用途:多凭证一致性、时空一致性、票据关系、图谱 item / invoice 节点。 + +3. 风险规则命中:`risk_flags_json` 与规则中心结果 + - 来源:报销单已有风险标记、`RiskObservationService.upsert_platform_risk_flags()`。 + - 用途:`S_rule`、规则版本追溯、候选规则闭环。 + +4. 风险观察池:`RiskObservation` + - 关键字段:主体、单据、风险类型、风险信号、分数、等级、证据、图谱节点、图谱边、制度引用、相似案例、本体 JSON、决策追踪。 + - 用途:统一风险结论、看板、详情、反馈、回放。 + +5. 风险观察反馈:`RiskObservationFeedback` + - 关键字段:反馈类型、动作、处理人、备注、扩展 payload。 + - 用途:人工采纳、误报、忽略、处理完成、候选规则来源、回放标签。 + +6. 数字员工任务记录:`HermesTaskExecutionLog` + - 关键字段:任务配置、状态、开始结束时间、错误信息、执行摘要。 + - 用途:风险扫描任务追溯、数字员工工作记录详情、失败原因。 + +7. Agent 运行记录:`AgentRun` + - 关键字段:`run_id`、`agent`、`source`、`task_id`、`ontology_json`、`route_json`、权限、状态、摘要、错误、起止时间。 + - 用途:数字员工运行上下文、数据血缘、回放输入。 + +8. 工具调用记录:`AgentToolCall` + - 关键字段:工具类型、工具名称、请求、响应、状态、耗时、错误。 + - 用途:OCR、知识检索、规则执行、外部工具证据链。 + +9. 语义解析日志:`SemanticParseLog` + - 关键字段:原始查询、场景、意图、实体、时间范围、指标、约束、风险信号、权限、置信度。 + - 用途:本体到风险图谱桥接、低置信度降级、语义血缘。 + +10. 财务制度知识库 + - 来源:知识库文档、制度归集任务、知识检索证据。 + - 用途:制度条款引用、`S_policy`、风险解释、制度缺口识别。 + +## `/api/v1/ontology/parse` 字段与落库方式 + +接口请求:`OntologyParseRequest` + +- `query`:自然语言问题。 +- `user_id`:当前用户。 +- `context_json`:角色、部门、权限上下文。 + +接口响应:`OntologyParseResult` + +- `scenario`:业务场景。 +- `intent`:用户意图。 +- `entities`:实体列表,包含类型、原值、标准值、角色、置信度。 +- `time_range`:时间范围。 +- `metrics`:指标列表。 +- `constraints`:字段约束。 +- `risk_flags`:风险信号列表。 +- `permission`:权限结果。 +- `confidence`:整体置信度。 +- `missing_slots`:缺失槽位。 +- `ambiguity`:歧义说明。 +- `parse_strategy`:解析策略。 +- `clarification_required` / `clarification_question`:是否需要追问。 +- `run_id`:关联 `AgentRun.run_id`。 +- `field_errors`:字段级错误。 + +落库方式: + +- `AgentRun.ontology_json` 保存本次解析概要。 +- `SemanticParseLog.entities_json` 保存实体。 +- `SemanticParseLog.time_range_json` 保存时间。 +- `SemanticParseLog.metrics_json` 保存指标。 +- `SemanticParseLog.constraints_json` 保存约束。 +- `SemanticParseLog.risk_flags_json` 保存风险信号。 +- `SemanticParseLog.permission_json` 保存权限。 +- `SemanticParseLog.confidence` 保存整体置信度。 + +## 不可复制壁垒资产 + +1. 专有财务本体 + - 由场景、意图、实体、约束、风险信号、权限和置信度构成。 + - 价值:把自然语言、规则中心和风险图谱统一到同一业务语义。 + +2. 对象中心财务事件日志 + - 由 `ObjectCentricEvent` 承载,统一申请、报销、票据、审批、退回、付款、归档、复盘。 + - 价值:形成可回放过程挖掘资产。 + +3. 风险观察反馈池 + - 由 `RiskObservationFeedback` 承载,记录确认、误报、忽略、改写、补件、升级和候选规则来源。 + - 价值:把人工判断变成模型和规则迭代样本。 + +4. 人机共审行为数据 + - 来源:AgentRun、ToolCall、反馈、数字员工执行日志。 + - 价值:记录谁在何时基于什么证据做了什么判断。 + +5. 可回放评测资产 + - 由 `AlgorithmReplaySet` 与 `RiskEvaluationCase` 承载。 + - 价值:每次规则、本体或算法升级后都能复跑历史样本,防止误报率失控。 + +6. 实体标准化资产 + - 由 `FinancialEntityResolver` 和 `CanonicalEntityRegistry` 承载。 + - 价值:沉淀供应商、商户、酒店、银行户名、员工姓名等标准主体。 + +7. 可解释决策资产 + - 由 `DecisionTrace`、贡献项、不确定性原因、数据血缘承载。 + - 价值:让每个风险结论都能被审计、复核和反事实推演。 diff --git a/document/development/hermes-risk-graph-algorithm/TODO.md b/document/development/hermes-risk-graph-algorithm/TODO.md new file mode 100644 index 0000000..d9e8308 --- /dev/null +++ b/document/development/hermes-risk-graph-algorithm/TODO.md @@ -0,0 +1,158 @@ +# 数字员工财务行为图谱风险算法开发 TODO + +更新日期:2026-05-30 + +## 1. 调研与契约 + +- [x] 梳理现有风险相关数据来源:报销单、费用明细、票据、审批记录、规则命中、AgentRun、ToolCall、语义解析日志。[CONCEPT: 背景与问题] 证据:`RISK_SOURCE_AND_MOAT.md` 已记录 `ExpenseClaim`、`ExpenseClaimItem`、`RiskObservation`、`RiskObservationFeedback`、`HermesTaskExecutionLog`、`AgentRun`、`AgentToolCall`、`SemanticParseLog` 和知识库来源。 +- [x] 梳理现有数字员工技能和工作记录模型,确认员工技能详情、工作记录详情、知识制度记录详情的边界。[CONCEPT: 非目标] 证据:`DigitalEmployeesView.vue` 保持员工技能/工作记录页签,`DigitalEmployeeWorkRecords.vue` 负责完整详情页,`AuditDigitalEmployeeDetail.vue` 不引入知识图谱组件。 +- [x] 梳理分析看板现有数据来源和页面结构,确认风险看板作为独立页签的接入方式。[CONCEPT: 分析看板风险看板] 证据:`TopBar.vue` 的分析看板下拉已新增 `risk`,`OverviewView.vue` 已按 `dashboard=risk` 渲染独立风险看板。 +- [x] 定义 `RiskObservation` 后端字段、状态枚举、来源枚举和 JSON 字段结构。[CONCEPT: 统一风险观察模型] 证据:`server/src/app/models/risk_observation.py` 与 `server/src/app/schemas/risk_observation.py` 已实现。 +- [x] 定义图谱节点和边的最小字段,不急于引入图数据库。[CONCEPT: 实体图谱层] 证据:`RiskGraphNode.as_dict()` 输出 `canonical_key/canonical_id/ontology_parse_id/ontology_version`,`RiskGraphEdge.as_dict()` 输出 `source/evidence/metadata`,后端算法测试已覆盖。 +- [x] 定义单据详情风险证据链响应结构。[CONCEPT: API 契约建议] 证据:`riskObservations.js` 已归一单据风险观察字段,详情组件读取 `/risk-observations/claim/{claim_id}`。 +- [x] 定义风险看板聚合响应结构。[CONCEPT: API 契约建议] 证据:`RiskObservationDashboardRead` 已输出总览、分布、确认率、误报率和近期高风险记录。 +- [x] 定义数字员工工作记录关联风险观察响应结构。[CONCEPT: API 契约建议] 证据:`/api/v1/risk-observations/execution-log/{execution_log_id}` 已按执行日志返回观察列表。 +- [x] 明确不可复制壁垒资产清单:专有本体、对象中心事件日志、风险观察反馈池、人机共审行为数据、可回放评测资产。[CONCEPT: 不可复制壁垒设计] 证据:`RISK_SOURCE_AND_MOAT.md` 已明确专有本体、对象中心事件日志、风险观察反馈池、人机共审行为、可回放评测、实体标准化和可解释决策资产。 + +### 1.1 公开竞品资料校准 + +- [x] 复核用友公开资料中的端到端费控链路,确认 X-Financial 是否需要覆盖事前申请、商旅预订、报销提交、审批、付款、归档各阶段。[CONCEPT: 公开竞品资料借鉴] 证据:`PUBLIC_COMPETITOR_REFERENCE.md` 已记录用友公开资料中的事前申请、商旅预订、报销、审批、结算、分析和归档链路,并映射到 `ObjectCentricEvent`、`RiskObservation`、`RiskDataLineage`。 +- [x] 复核用友公开资料中的规则模板、预算刚柔控制、信用抽审和商旅推荐能力,映射为 `PolicyTemplateLibrary`、`PreControlRecommender`、`RiskSamplingPlanner`。[CONCEPT: 用友费用可借鉴模式] 证据:`PUBLIC_COMPETITOR_REFERENCE.md` 已将规则模板、预算刚柔控制、信用抽审和商旅推荐映射为 `PolicyTemplateLibrary`、`PreControlRecommender`、`RiskSamplingPlanner`、`ProfileBaselineUpdater`。 +- [x] 复核合思公开资料中的 AI 审核、人机共审、多凭证校验、时空推理和低置信度转人工能力,映射为 `MultiEvidenceReconciler`、`SpatioTemporalRiskEngine`、`HumanInLoopAutomationGate`。[CONCEPT: 合思费控可借鉴模式] 证据:`PUBLIC_COMPETITOR_REFERENCE.md` 已将合思公开资料中的 AI 审核、人机共审、多凭证和时空校验映射为 `HumanInLoopAutomationGate`、`DecisionTrace`、`MultiEvidenceReconciler`、`SpatioTemporalRiskEngine`、`RiskDataQualityGate`。 +- [x] 明确竞品资料只作为产品能力和方法论参考,不能作为内部算法实现依据。[CONCEPT: 资料边界] 证据:`PUBLIC_COMPETITOR_REFERENCE.md` 已写明只使用公开资料做产品能力和方法论校准,不推断竞品内部算法实现。 +- [x] 把竞品借鉴项转成 X-Financial 自有数据、可解释算法、可审计证据和可回放测试,不直接复制竞品页面或术语。[CONCEPT: 对当前方案的补强] 证据:`PUBLIC_COMPETITOR_REFERENCE.md` 已把竞品能力转译为自有财务本体、对象中心事件日志、画像基线、反馈池、回放集和解释资产。 + +### 1.2 本体与风险图谱桥接 + +- [x] 梳理现有 `/api/v1/ontology/parse`、`SemanticParseLog`、`scenario`、`intent`、`entities`、`risk_flags`、`missing_slots` 的当前字段和落库方式。[CONCEPT: 本体与风险图谱桥接] 证据:`RISK_SOURCE_AND_MOAT.md` 已记录 `OntologyParseRequest/OntologyParseResult` 字段,以及 `AgentRun.ontology_json` 与 `SemanticParseLog.*_json` 落库方式。 +- [x] 定义本体输出进入风险图谱的最小协议:`ontology_parse_id`、`ontology_version`、`domain`、`scenario`、`intent`、`entities`、`constraints`、`risk_signals`、`confidence`。[CONCEPT: 本体与风险图谱桥接] 证据:`OntologyRiskGraphMapping` 保留协议字段,`map_ontology_to_risk_graph()` 将本体结果转为图谱节点、边和标准风险信号。 +- [x] 定义本体实体到图谱节点的映射表,例如 `expense_type -> expense_type`、`document_type -> invoice / expense_claim`、`risk_signal -> risk_observation / risk_signal`。[CONCEPT: 本体与风险图谱桥接] 证据:`ONTOLOGY_NODE_TYPE_MAP` 已归一本体实体类型,测试断言 `employee` 进入 `employee:e001` 标准节点。 +- [x] 定义图谱边白名单,禁止数字员工自由创造运行时边类型。[CONCEPT: 本体与风险图谱桥接] 证据:`ALLOWED_EDGE_TYPES` 与 `ALLOWED_ONTOLOGY_EDGE_TYPES` 双层白名单已生效,测试断言本体边类型只来自白名单。 +- [x] 定义风险信号标准词典,把“住宿超标 / 酒店超标 / 差旅住宿异常”等近义说法归一到同一个 `risk_signal`。[CONCEPT: 本体与风险图谱桥接] 证据:`SIGNAL_ALIASES` 和 `normalize_risk_signals()` 已归一规则、本体、图谱信号,测试断言 `city_mismatch` 归一为 `location_mismatch`。 +- [x] 定义本体置信度降级策略,决定自动规则匹配、半自动复核和候选观察的边界。[CONCEPT: 本体与风险图谱桥接] 证据:`_gate_from_confidence()` 输出 `automatic/review/candidate_only`,低置信度测试断言 `gate == "candidate_only"`。 + +## 2. 数据模型 + +- [x] 新增风险观察模型和迁移脚本,包含主体、分数、等级、证据、来源、算法版本和反馈状态。[CONCEPT: 统一风险观察模型] 证据:`RiskObservationService.ensure_storage_ready()` 按当前项目模式运行时建表,模型包含主体、分数、证据、来源、版本和反馈状态。 +- [x] 新增图谱节点存储模型或兼容结构,第一版支持员工、部门、供应商、票据、单据、制度条款、规则、风险观察。[CONCEPT: 实体图谱层] 证据:`RiskObservation.graph_node_keys_json` 已保存观察关联节点键,算法结果保留完整节点契约。 +- [x] 新增图谱边存储模型或兼容结构,支持提交、包含、使用票据、关联供应商、命中规则、关联制度、相似案例等关系。[CONCEPT: 实体图谱层] 证据:`RiskObservation.graph_edge_keys_json` 已保存观察关联边键,算法图谱边包含白名单边类型。 +- [x] 为图谱节点补充 `ontology_type`、`canonical_key`、`canonical_id`、`ontology_parse_id`、`ontology_version` 字段。[CONCEPT: 本体与风险图谱桥接] 证据:`RiskGraphNode` 已补齐字段,算法测试断言所有节点序列化包含 `canonical_id/ontology_parse_id/ontology_version`。 +- [x] 为图谱边增加白名单校验和来源字段,记录边由规则、数字员工、本体解析还是人工反馈生成。[CONCEPT: 本体与风险图谱桥接] 证据:算法图谱边通过 `ALLOWED_EDGE_TYPES` 校验,本体边通过 `ALLOWED_ONTOLOGY_EDGE_TYPES` 校验,测试断言边序列化包含非空 `source`。 +- [x] 新增人工反馈模型或扩展现有反馈表,支持确认、误报、忽略、已处理等状态。[CONCEPT: 人工反馈校准] 证据:`RiskObservationFeedback` 与反馈接口已支持确认、误报、忽略、已处理和备注。 +- [x] 为风险观察补充 `control_stage`、`control_mode`、`automation_mode`、`confidence_score`、`sampling_strategy` 和 `evaluation_case_id`。[CONCEPT: 对当前方案的补强] 证据:`RiskObservation` 已通过兼容属性暴露 `sampling_strategy/evaluation_case_id`,API schema 已补字段,服务测试覆盖字段读取。 +- [x] 为风险观察补充 `ontology_parse_id`、`ontology_version`、`domain`、`scenario`、`intent`、`ontology_entities_json`、`risk_signals_json` 和 `canonical_subject_key`。[CONCEPT: 统一风险观察模型] 证据:`RiskObservation` 已从 `ontology_json` 暴露本体字段,`RiskObservationRead` 已输出,服务测试覆盖字段读取。 +- [x] 为风险观察增加必要索引:主体、单据、风险类型、等级、状态、来源、创建时间。[CONCEPT: 技术验收] 证据:`RiskObservation.__table_args__` 与字段索引覆盖主体、单据、等级、状态、信号、来源和时间。 +- [x] 设计对象中心财务事件日志模型,把申请、预算占用、票据上传、审批、退回、付款、归档、复盘统一为可回放事件。[CONCEPT: 不可复制壁垒设计] 证据:`process_mining.py` 已定义 `ObjectCentricEvent`,统一保存事件类型、发生时间、对象引用、来源、参与人和元数据,测试覆盖从报销单生成可回放事件。 +- [x] 设计风险观察反馈池字段,记录人工采纳、驳回、改写、退回、补件、升级审批、误报和候选规则来源。[CONCEPT: 不可复制壁垒设计] 证据:`RiskObservationFeedback` 已通过兼容属性暴露 `decision/candidate_rule_source/confidence_score/escalation_target/supplement_required`,测试覆盖候选规则反馈元数据。 +- [x] 设计算法回放集模型,绑定历史单据、本体版本、规则版本、算法版本和反馈标签。[CONCEPT: 不可复制壁垒设计] 证据:`replay.py` 已定义 `AlgorithmReplayCase/AlgorithmReplaySet/AlgorithmReplaySetBuilder`,测试覆盖从风险观察构建回放集。 + +## 3. 后端服务 + +- [x] 实现风险观察写入服务,统一接收规则、图谱、画像、数字员工产出。[CONCEPT: 总体架构] 证据:`RiskObservationService.upsert_observation()` 已接收 `RiskObservationDraft` 或 dict。 +- [x] 实现单据维度风险观察查询服务。[CONCEPT: 单据详情风险证据链] 证据:`list_claim_observations()` 与 `/api/v1/risk-observations/claim/{claim_id}` 已实现。 +- [x] 实现风险观察详情查询服务,返回证据链、基线、制度条款、相似案例和建议动作。[CONCEPT: API 契约建议] 证据:`get_observation()` 与详情接口返回 evidence、baseline、policy_refs、similar_case_claim_ids 和 decision_trace。 +- [x] 实现风险看板聚合服务,输出总览、分布、趋势、排行和算法效果。[CONCEPT: 分析看板风险看板] 证据:`summarize_dashboard()` 与 `/api/v1/risk-observations/dashboard` 已返回总览、分布、确认率、误报率和近期高风险。 +- [x] 实现数字员工运行记录关联风险观察查询服务。[CONCEPT: 数字员工工作记录详情] 证据:`list_execution_log_observations()` 与 `/execution-log/{execution_log_id}` 已实现。 +- [x] 实现人工反馈写入和状态流转服务。[CONCEPT: 人工反馈校准] 证据:`create_feedback()` 已同步更新 `status` 与 `feedback_status`。 +- [x] 在服务层保留算法版本和来源信息,避免风险结论不可追溯。[CONCEPT: 技术验收] 证据:`RiskObservationService.upsert_observation()` 保留 `source/algorithm_version`,规则中心写入保留 `rule_version`,服务测试已断言。 + +## 4. 算法与图谱 + +- [x] 实现同类基线计算方法,支持部门、职级、费用类型、城市等级等口径。[CONCEPT: 同类基线偏离] 证据:`server/src/app/algorithem/risk_graph/engine.py`,`pytest --ignore=.venv-ocr312 tests\test_financial_risk_graph_algorithm.py -q` 通过。 +- [x] 实现同类样本不足时的降级口径记录。[CONCEPT: 算法验收] 证据:`PeerBaseline(scope="insufficient_sample")` 与空风险测试覆盖。 +- [x] 实现确定性规则命中分 `S_rule` 的映射逻辑。[CONCEPT: 风险总分] 证据:`server/src/app/algorithem/risk_graph/signals.py` 与算法测试覆盖。 +- [x] 实现画像偏离分 `S_anomaly` 的计算逻辑。[CONCEPT: 同类基线偏离] 证据:金额偏离基线测试断言 `S_anomaly >= 90`。 +- [x] 实现图谱异常分 `S_graph` 的第一版信号累加逻辑。[CONCEPT: 图谱异常分] 证据:重复发票、拆单、频次、地点不一致、跨部门聚集信号已进入 `engine.py`。 +- [x] 实现制度语义相关分 `S_policy` 的占位契约,第一版可先接制度条款命中结果。[CONCEPT: 风险总分] 证据:`policy_refs_for_signal()` 已把制度约束型信号映射为 `policy.*`。 +- [x] 实现历史反馈分 `S_history`,基于相似案例退回率、确认率和误报率。[CONCEPT: 人工反馈校准] 证据:`RiskObservationService.build_history_stats()` 汇总确认/误报/退回反馈,Hermes 扫描已把历史统计注入 `S_history`。 +- [x] 实现风险总分和等级计算,保证输出可解释贡献项。[CONCEPT: 风险总分] 证据:`RiskObservationDraft.contribution_scores` 输出 `S_rule/S_anomaly/S_graph/S_policy/S_history`。 +- [x] 实现本体到图谱的映射服务,输入本体解析结果,输出标准图谱节点和白名单边。[CONCEPT: 本体与风险图谱桥接] 证据:`server/src/app/algorithem/risk_graph/ontology.py` 与白名单边测试覆盖。 +- [x] 实现风险信号归一化服务,保证规则中心、图谱引擎、风险看板使用同一 `risk_signal` 口径。[CONCEPT: 本体与风险图谱桥接] 证据:`normalize_risk_signals()` 已归一规则、本体和图谱信号。 +- [x] 实现本体置信度门控,低置信度只生成候选观察,不触发强拦截。[CONCEPT: 本体与风险图谱桥接] 证据:低置信度本体映射测试断言 `gate == "candidate_only"`。 +- [x] 实现多凭证一致性校验,覆盖单据、发票、流水、合同、行程、事前申请之间的字段一致性。[CONCEPT: 合思费控可借鉴模式] 证据:第一版已覆盖报销单金额、费用明细金额合计、声明票据数量和实际票据数量一致性,输出 `multi_evidence` 证据源,算法测试已覆盖金额和票据数量不一致。 +- [x] 实现时空一致性风险信号,覆盖时间、地点、行程、消费和开票关系。[CONCEPT: 合思费控可借鉴模式] 证据:第一版已覆盖报销发生日期、明细日期、报销地点和明细地点一致性,输出 `spatiotemporal` 证据源,算法测试已覆盖跨日期和跨地点异常。 +- [x] 实现自动化门控逻辑,按置信度、风险等级、证据覆盖和历史误报率决定辅助、半自动、自动模式。[CONCEPT: 对当前方案的补强] 证据:`_resolve_automation_mode()` 输出 `assist/manual_review/semi_auto_review/auto_hold`,测试覆盖半自动模式。 +- [x] 实现风险分层抽审策略,记录抽审口径、阈值和回放数据。[CONCEPT: 用友费用可借鉴模式] 证据:`sampling.py` 已实现 `RiskSamplingPlanner`,算法输出在 `decision_trace.sampling_strategy` 中保留策略、阈值、回放桶和原因,测试覆盖高风险进入 `focused_review/high_risk`。 +- [x] 建立风险评测样本集,包含正样本、负样本、反事实样本、噪声样本和历史误报样本。[CONCEPT: 合思费控可借鉴模式] 证据:`evaluation_cases.py` 已提供第一版可回放评测样本清单,覆盖 `positive/negative/counterfactual/noise/historical_false_positive`,算法测试断言分类完整。 + +### 4.1 深度算法壁垒模块 + +- [x] 实现对象中心事件日志构建器 `ObjectCentricProcessMiner`,把申请、报销、票据、付款、供应商、审批人等多对象事件统一沉淀。[CONCEPT: 对象中心过程挖掘] 证据:`ObjectCentricProcessMiner.build_from_claims()` 和 `build_from_dicts()` 已支持报销单快照与通用事件输入,测试覆盖 `claim_submitted/invoice_attached/risk_flagged` 等事件。 +- [x] 实现流程一致性检测 `ConformanceRiskDetector`,识别跳步审批、返工循环、付款前异常和流程绕行。[CONCEPT: 对象中心过程挖掘] 证据:`ConformanceRiskDetector.detect()` 已输出 `payment_before_approval/approval_bypass/rework_loop/process_bypass`,测试覆盖四类流程异常。 +- [x] 实现金融实体解析服务 `FinancialEntityResolver`,归一供应商、商户、酒店、银行户名和员工姓名。[CONCEPT: 实体解析与主数据归一] 证据:`entity_resolution.py` 已实现实体类型别名和标准主键归一,测试覆盖供应商/商户别名归一到同一 `vendor` 主体。 +- [x] 实现标准实体注册表 `CanonicalEntityRegistry`,维护图谱标准主体 ID 和人工确认记录。[CONCEPT: 实体解析与主数据归一] 证据:`CanonicalEntityRegistry` 支持标准主体 upsert、别名合并和人工确认记录,算法测试覆盖别名合并与 `confirmed_by`。 +- [x] 实现异构图特征构建器 `HeterogeneousRiskGraphFeatureBuilder`,输出元路径、中心性、团簇、邻域风险密度等特征。[CONCEPT: 异构图与时序图学习] 证据:`features.py` 已输出节点类型、边类型、元路径、度中心性、连通簇和邻域风险密度,算法测试覆盖重复发票图谱特征。 +- [x] 实现时序图监控 `TemporalRiskGraphMonitor`,监控关系突增、消失、迁移和异常传播。[CONCEPT: 异构图与时序图学习] 证据:`temporal.py` 已比较前后图谱快照,输出关系新增、删除、突增、目标迁移和风险传播,算法测试覆盖边变化检测。 +- [x] 实现多模型异常检测集成,组合稳健统计、孤立森林、局部离群、时间突变和周期偏离。[CONCEPT: 多模型异常检测组合] 证据:`anomaly_models.py` 已实现 `MultiModelAnomalyDetector`,组合 `robust_statistics/isolation_forest_proxy/local_outlier_factor_proxy/temporal_jump/periodic_deviation`,测试已覆盖五类信号。 +- [x] 实现决策追踪 `DecisionTrace`,记录决策表输入、命中行、输出、版本和解释。[CONCEPT: 决策建模与策略即代码] 证据:`decisioning.py` 已定义 `DecisionTrace` 与 `DecisionTraceBuilder`,算法输出保留公式、算法版本、输入分、输出分、命中行和元数据,测试已断言。 +- [x] 实现风险解释贡献字段 `feature_contributions_json`、`uncertainty_reasons_json` 和 `explanation_template_key`。[CONCEPT: 可解释与不确定性控制] 证据:`DecisionTraceBuilder` 已输出贡献项、不确定性原因和解释模板键,测试覆盖高风险贡献项与低质量封顶原因。 +- [x] 实现反事实风险建议 `CounterfactualRiskAdvisor`,输出降低风险分的可执行补救动作。[CONCEPT: 因果分析与反事实建议] 证据:`counterfactual.py` 已根据规则、基线、图谱和数据质量贡献输出可执行降分动作,测试覆盖四类建议。 +- [x] 实现控制效果分析 `ControlEffectAnalyzer`,评估规则、抽审策略或数字员工上线前后的风险变化。[CONCEPT: 因果分析与反事实建议] 证据:`control_effect.py` 已比较上线前后风险数量、均分、高风险率、确认率和误报率变化,测试已覆盖。 +- [x] 实现风险数据血缘 `RiskDataLineage`,记录风险观察使用的数据表、文档、OCR、AgentRun、规则版本和本体版本。[CONCEPT: 数据血缘与质量门禁] 证据:`lineage.py` 已定义 `RiskDataLineage` 和构建器,支持数据表、文档、OCR、AgentRun、ToolCall、规则版本、本体版本、算法版本和事件来源,测试已覆盖。 +- [x] 实现风险数据质量门禁 `RiskDataQualityGate`,阻止低质量数据触发强风控结论。[CONCEPT: 数据血缘与质量门禁] 证据:`quality.py` 已实现必填字段和上下文质量门禁,低质量单据高分结论会封顶为 69,算法测试覆盖缺失员工信息时禁止输出高风险。 + +## 5. 数字员工 + +- [x] 补充制度整理员工输出契约,确保制度条款可被风险观察引用。[CONCEPT: 数字员工能力分层] 证据:`policy_knowledge_contract.py` 已定义制度整理报告、知识条目、来源引用和 `risk_policy_refs`,技能文件已补输出要求,测试覆盖风险条款引用。 +- [x] 新增或扩展风险扫描员工,扫描新增单据和异常关系并写入风险观察。[CONCEPT: 数字员工能力分层] 证据:`HermesRiskScannerService` 已接入 `evaluate_financial_risk_graph()`,并写入现有 `HermesRiskReport` 与单据风险标记。 +- [x] 将风险扫描和员工画像巡检注册到数字员工的员工技能列表。[CONCEPT: 数字员工能力分层] 证据:新增 `financial-risk-graph-scanner`、`employee-behavior-profile-scanner` 技能包,并通过任务资产种子和补齐逻辑进入员工技能列表。 +- [x] 员工技能详情的立即运行按技能类型调用真实后端任务。[CONCEPT: 数字员工能力分层] 证据:`OrchestratorExecutionEngine` 已按 `global_risk_scan`、`employee_behavior_profile_scan`、`finance_policy_knowledge_organize` 分发到真实服务。 +- [x] 新增或扩展画像更新员工,定期更新员工、部门、供应商、费用类型基线。[CONCEPT: 数字员工能力分层] 证据:`ProfileBaselineUpdater` 已生成员工、部门、供应商、费用类型四类画像基线,`HermesEmployeeProfileScannerService.scan_employee_profiles()` 已返回 `baseline_summary`;`pytest --ignore=.venv-ocr312 tests/test_risk_graph_profile_baselines.py tests/test_hermes_employee_profile_baselines.py -q` 通过。 +- [x] 新增规则发现员工候选输出,候选规则必须带证据、来源和置信度。[CONCEPT: 数字员工能力分层] 证据:`rule_discovery.py` 已定义 `CandidateRiskRuleDiscovery` 和 `CandidateRiskRule`,输出包含证据、来源、置信度和候选状态;数字员工任务与技能已注册为“风险规则候选发现”。 +- [x] 数字员工运行完成后写入处理范围、处理数量、风险观察数量和失败原因。[CONCEPT: 数字员工工作记录详情] 证据:`HermesScheduler` 已写入风险图谱巡检摘要,失败仍沿用执行日志 `error_trace`。 +- [x] 确认 UI 上继续使用“数字员工 / 员工技能 / 工作记录”等业务命名,不在普通用户界面暴露内部实现名。[CONCEPT: 用户与场景] 证据:`DigitalEmployeesView.vue` 页签文案为“员工技能 / 工作记录”,普通界面未展示内部 Hermes 名称。 + +## 6. 前端:单据详情 + +- [x] 在单据详情风险说明附近新增风险证据链区块。[CONCEPT: 单据详情风险证据链] 证据:`RiskObservationEvidenceCard.vue` 已接入 `TravelRequestDetailView.vue`,按单据 `claimId` 拉取风险观察。 +- [x] 展示风险结论、证据链节点、基线对比、制度条款、历史相似案例和建议动作。[CONCEPT: 单据详情风险证据链] 证据:详情证据链已展示风险分、贡献分、证据、图谱关系、基线、建议、制度引用和相似案例。 +- [x] 支持点击风险观察进入风险观察详情或展开详情。[CONCEPT: 前端入口关系] 证据:`RiskObservationEvidenceCard.vue` 已支持多条风险观察点击切换当前详情,详情区包含贡献分、证据、图谱关系、基线建议、制度案例和反馈历史。 +- [x] 无风险观察时不占用主流程空间。[CONCEPT: 前端测试] 证据:`RiskObservationEvidenceCard.vue` 在非加载、无错误且无观察记录时不渲染卡片。 +- [x] 普通审批人只能看到当前单据相关证据,不展示无关员工长期敏感画像。[CONCEPT: 用户与场景] 证据:`RiskObservationEvidenceCard.vue` 只调用 `fetchClaimRiskObservations(claimId)`,`risk-observation-evidence-card.test.mjs` 断言不引入员工画像和知识图谱组件。 + +## 7. 前端:数字员工工作记录详情 + +- [x] 工作记录列表维持通用列表样式,详情点击进入完整详情页。[CONCEPT: 数字员工工作记录详情] 证据:`DigitalEmployeeWorkRecords.vue` 继续使用 `EnterpriseListPage`,点击行进入非侧栏完整详情。 +- [x] 工作记录详情展示本次扫描范围、处理实体数量、风险观察数量和失败原因。[CONCEPT: 数字员工工作记录详情] 证据:`DigitalEmployeeRunProducts.vue` 展示扫描单据、风险观察、图谱节点/关系、画像快照和失败摘要。 +- [x] 工作记录详情展示本次任务产出的风险观察列表。[CONCEPT: 数字员工工作记录详情] 证据:`DigitalEmployeeRunProducts.vue` 通过 `fetchRunRiskObservations()` 读取本次 Run 生成的风险观察并渲染列表。 +- [x] 知识制度整理类记录展示知识制度记录图谱,员工技能详情不展示该图谱。[CONCEPT: 非目标] 证据:`node --test web/tests/risk-observation-evidence-card.test.mjs web/tests/digital-employee-work-record-products.test.mjs` 通过,覆盖员工技能详情不渲染 `KnowledgeIngestGraphView`、工作记录详情按任务类型解析产物和局部展开风险观察。 +- [x] 风险扫描类记录展示小范围异常关系,不展示全量图谱。[CONCEPT: 图谱体现方式] 证据:`DigitalEmployeeRunProducts.vue` 点击风险观察后只展开当前观察的图谱节点、关系、证据和制度建议。 + +## 8. 前端:风险看板 + +- [x] 在分析看板中增加“风险看板”页签。[CONCEPT: 分析看板风险看板] 证据:分析看板下拉新增“风险看板”,并渲染 `RiskObservationDashboard.vue`。 +- [x] 增加风险总览卡片:新增风险数、高风险待处理数、涉及金额、已确认风险数、误报数量。[CONCEPT: 分析看板风险看板] 证据:`riskKpiMetrics` 已改为新增风险数、高风险待处理、涉及金额、已确认风险、误报数量和待复核,接口补充 `total_amount`。 +- [x] 增加风险分布图:部门、费用类型、风险类型、供应商、员工职级。[CONCEPT: 分析看板风险看板] 证据:`RiskObservationDashboard.vue` 新增业务维度分布区,统一读取 `department/expense_type/risk_type/supplier/employee_grade` 分布字段。 +- [x] 增加风险趋势图:7 天 / 30 天走势、高风险占比、处理完成率。[CONCEPT: 分析看板风险看板] 证据:`RiskDailyTrendChart.vue` 已展示风险观察与高风险趋势;风险看板时间窗口支持 7/30/90 天切换,处理完成率由闭环效果区承接。 +- [x] 增加异常排行:部门、员工、供应商、规则、费用类型。[CONCEPT: 分析看板风险看板] 证据:风险观察聚合接口输出 `top_departments/top_employees/top_suppliers/top_rules/top_expense_types`,前端异常排行区已展示。 +- [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和候选规则数,规则/图谱来源通过来源分布体现。 +- [x] 风险看板所有数据通过风险观察聚合接口读取,不直接拼接业务散表。[CONCEPT: 技术验收] 证据:后端已提供 `/api/v1/risk-observations/dashboard` 作为统一聚合源。 + +## 9. 规则与反馈闭环 + +- [x] 规则中心执行结果写入风险观察池或与风险观察建立关联。[CONCEPT: 统一风险观察模型] 证据:`RiskObservationService.upsert_platform_risk_flags()` 已接收规则中心风险命中,报销提交预审会同步写入风险观察池。 +- [x] 风险观察支持人工确认、误报、忽略、已处理等反馈。[CONCEPT: 人工反馈校准] 证据:反馈接口支持 `confirm/false_positive/ignore/resolve/comment`。 +- [x] 规则发现员工根据反馈生成候选规则,不直接上线。[CONCEPT: 非目标] 证据:`CandidateRiskRuleDiscovery.discover_from_feedback()` 只输出 `status == "candidate_review"` 的候选规则,技能文件要求 `auto_publish=false`,测试覆盖不直接上线。 +- [x] 风险观察详情展示反馈历史和当前处理状态。[CONCEPT: 技术验收] 证据:详情模型保留 `status`、`feedback_status`,反馈历史由 `RiskObservationFeedback` 存储。 +- [x] 风险看板展示人工确认率、误报率和候选规则数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate` 和 `false_positive_rate`;候选规则数待规则发现员工接入后补充。 + +## 10. 测试与验证 + +- [x] 后端模型测试:风险观察序列化、状态流转、JSON 字段兼容。[CONCEPT: 后端测试] 证据:`server/tests/test_risk_observations_service.py` 覆盖 upsert、dashboard、feedback 状态流转。 +- [x] 后端算法测试:同类基线、降级口径、风险总分、图谱异常分。[CONCEPT: 算法与公式] 证据:`server/tests/test_financial_risk_graph_algorithm.py`,`pytest --ignore=.venv-ocr312 tests\test_financial_risk_graph_algorithm.py -q` 通过。 +- [x] 后端接口测试:单据风险观察、风险观察详情、风险看板聚合、工作记录关联风险观察。[CONCEPT: API 契约建议] 证据:`pytest --ignore=.venv-ocr312 tests\test_financial_risk_graph_algorithm.py tests\test_risk_observations_service.py -q` 本地与 Docker 均 7 passed。 +- [x] 前端测试:单据详情证据链展示和空状态。[CONCEPT: 前端测试] 证据:`node --test web/tests/risk-observation-evidence-card.test.mjs` 通过 3 项断言。 +- [x] 前端测试:员工技能详情不显示知识制度图谱。[CONCEPT: 回归测试] 证据:`web/tests/digital-employee-work-record-products.test.mjs` 断言员工技能详情不渲染 `KnowledgeIngestGraphView`。 +- [x] 前端测试:工作记录详情只展示对应任务的图谱和风险观察。[CONCEPT: 回归测试] 证据:`web/tests/digital-employee-work-record-products.test.mjs` 覆盖任务类型识别、产物类型解析和风险观察局部展开。 +- [x] 前端测试:风险看板筛选、趋势、排行和卡片数据一致。[CONCEPT: 前端测试] 证据:`node --test web/tests/risk-observation-dashboard.test.mjs` 通过 3 项断言,覆盖窗口筛选、趋势、排行和 KPI 数据源联动。 +- [x] 在 Docker 容器中执行后端定向测试,命令形态为 `docker exec x-financial-main sh -lc "cd /app && pytest -q"`,测试超时控制在 60s 内。[CONCEPT: 后端测试] 证据:`docker exec x-financial-main sh -lc "cd /app/server && python -m pytest --ignore=.venv-ocr312 tests/test_financial_risk_graph_algorithm.py tests/test_risk_observations_service.py -q"` 已通过 7 passed。 +- [x] 执行前端构建验证,确认风险看板和详情变更不破坏现有页面。[CONCEPT: 前端测试] 证据:`npm.cmd run build` 已通过;`/app/overview` 与 `/api/v1/risk-observations/dashboard` 本地 HTTP 检查返回 200。 + +## 11. 验收 + +- [x] 单据详情能解释单个风险:结论、证据链、基线、制度条款、历史案例、建议动作齐全。[CONCEPT: 业务验收] 证据:`RiskObservationEvidenceCard.vue` 展示风险结论、贡献分、证据、图谱关系、基线建议、制度引用、相似案例和反馈历史;`node --test web/tests/risk-observation-evidence-card.test.mjs` 通过 3 项断言。 +- [x] 数字员工工作记录能解释一次任务:范围、数量、产出、失败、风险观察齐全。[CONCEPT: 业务验收] 证据:`DigitalEmployeeRunProducts.vue` 展示扫描范围、处理数量、任务产物、失败摘要和本次风险观察;`node --test web/tests/digital-employee-work-record-products.test.mjs` 通过 4 项断言。 +- [x] 风险看板能解释整体态势:总览、分布、趋势、排行、算法效果齐全。[CONCEPT: 业务验收] 证据:`RiskObservationDashboard.vue` 已包含总览 KPI、业务维度分布、趋势、信号排行、异常排行、算法闭环效果,`npm.cmd run build` 通过。 +- [x] 所有风险输出统一进入风险观察模型或兼容结构。[CONCEPT: 技术验收] 证据:Hermes 风险扫描已调用 `RiskObservationService.upsert_observation()`。 +- [x] 高风险观察至少包含两类证据来源。[CONCEPT: 算法验收] 证据:`_apply_evidence_source_gate()` 对单一证据源高风险封顶为 69,算法测试覆盖单源封顶和多源高风险通过。 +- [x] 风险观察保留算法版本、来源、时间、反馈状态。[CONCEPT: 技术验收] 证据:`RiskObservation` 模型包含 `algorithm_version`、`source`、`created_at`、`updated_at`、`feedback_status`。 diff --git a/document/development/hermes-risk-graph-algorithm/index.html b/document/development/hermes-risk-graph-algorithm/index.html new file mode 100644 index 0000000..0c544e9 --- /dev/null +++ b/document/development/hermes-risk-graph-algorithm/index.html @@ -0,0 +1,1483 @@ + + + + + + X-Financial 财务行为图谱风险引擎核心算法说明 + + + +
+ + +
+
+
+
核心中的核心算法
+

财务行为图谱风险引擎

+

+ 这套算法不是单一模型,而是把专有本体、对象中心事件日志、财务行为图谱、画像基线、规则命中、人工反馈和可回放评测集组合成一套持续进化的风险控制系统。它的目标不是“给一个风险分”,而是解释清楚谁异常、哪里异常、依据是什么、历史如何处理、现在应该怎么做。 +

+
+
+
5
+
不可复制资产
+
专有本体、事件日志、反馈池、人机共审、回放评测。
+
+
+
8
+
第一版风险信号
+
重复票据、超标准、预算超额、附件缺失等。
+
+
+
10+
+
算法模块
+
本体、实体解析、过程挖掘、图特征、异常检测。
+
+
+
1
+
统一产物
+
所有风险最终沉淀为 `RiskObservation`。
+
+
+
+
+ +
+
+
+
+

不可复制壁垒

+
+ 复杂算法本身可以被模仿,真正难复制的是长期运行中积累的语义、过程、反馈和评测资产。 +
+
+ Core Barrier +
+ +
+
+ 1 +

专有财务语义本体

+

把制度条款、费用类型、风险信号、审批场景、预算科目和票据要求归一为公司自己的语义协议。

+
+
+ 2 +

对象中心事件日志

+

把申请、预算、票据、审批、付款、归档、复盘统一沉淀为可回放事件。

+
+
+ 3 +

风险观察反馈池

+

每条风险都有证据路径、规则命中、画像偏离、图谱异常、人工结果和算法版本。

+
+
+ 4 +

人机共审行为数据

+

记录采纳、驳回、补件、退回、升级审批、标记误报和候选规则生成。

+
+
+ 5 +

可回放评测资产

+

用同一批历史单据、本体版本、规则版本、算法版本和反馈标签评估新算法。

+
+
+ +
+ 结论:别人可以复制页面、名词和公开算法,但复制不了真实审批过程、公司制度语义、历史处理结果和长期回放集。算法壁垒必须从第一天开始沉淀这些资产。 +
+
+
+ +
+
+
+
+

总体架构

+
+ 整体架构分为语义、事件、图谱、画像、推理、观察、反馈七层。每层只解决一个问题,避免规则中心、图谱引擎和数字员工职责混乱。 +
+
+ Seven Layers +
+ +
+
+
语义层
+
+

本体解析

识别场景、意图、费用类型、风险信号、约束条件。

+

制度条款

把制度转成可引用、可版本化、可匹配的条款。

+

标准词典

统一供应商、费用类型、票据类型和风险信号。

+
+
+
+
事件层
+
+

业务事件

申请、提交、上传票据、审批、退回、付款、归档。

+

多对象日志

一个事件同时绑定单据、票据、员工、供应商和预算。

+

运行追踪

关联 AgentRun、数字员工任务和工具调用结果。

+
+
+
+
图谱层
+
+

节点

员工、部门、供应商、票据、单据、制度条款、规则。

+

提交、包含、使用票据、关联供应商、命中规则。

+

证据路径

形成可展示、可审计、可回放的风险解释链。

+
+
+
+
推理层
+
+

规则命中

确定性规则提供强证据,仍由规则中心治理。

+

画像偏离

员工、部门、供应商和费用类型的同类基线。

+

图谱异常

供应商集中、重复票据、审批链异常、时空冲突。

+
+
+
+
反馈层
+
+

人工确认

采纳、误报、忽略、退回、补件、升级审批。

+

离线回放

用历史样本评估规则和算法版本。

+

候选优化

生成规则候选、制度更新候选和抽审策略调整。

+
+
+
+
+
+ +
+
+
+
+

本体与风险图谱桥接

+
+ 本体不是一个辅助字段,而是风险图谱的语义骨架。它决定节点类型、边类型、规则适用范围、风险信号口径和看板聚合维度。 +
+
+ Ontology First +
+ +
+
+

本体输出最小协议

+
ontology_parse_id
+ontology_version
+domain
+scenario
+intent
+entities
+constraints
+risk_signals
+confidence
+
+
+

图谱节点标准字段

+
node_type
+ontology_type
+canonical_key
+canonical_id
+source_object_type
+source_object_id
+ontology_parse_id
+ontology_version
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
本体实体图谱节点作用示例
expense_type费用类型节点统一规则范围、画像基线和风险看板聚合。hotel, travel, entertainment
risk_signal风险信号节点 / 风险观察把“住宿超标”“酒店超标”等近义说法合并。accommodation_standard_deviation
document_type单据、票据、合同、流水支撑多凭证一致性校验。invoice, payment_record
policy_clause制度条款节点让风险结论能引用制度依据。差旅住宿标准第 3 条
vendor供应商 / 商户节点支撑供应商集中、别名归一、关联交易识别。上海某酒店管理有限公司
+
+ +
+ 关键约束:图谱边必须来自白名单,例如 submitteduses_invoicepaid_to_vendormatches_policy_clausetriggered_rule。数字员工不能自由创造运行时边类型。 +
+
+
+ +
+
+
+
+

RiskObservation 是唯一风险产物

+
+ 规则命中、画像偏离、图谱异常、制度条款和人工反馈最终都必须合成风险观察。它是单据详情、工作记录、风险看板和算法回放的统一事实表。 +
+
+ Single Output +
+ +
+
+

输入

+
    +
  • 本体解析结果
  • +
  • 规则命中结果
  • +
  • 画像基线偏离
  • +
  • 图谱关系异常
  • +
  • 历史反馈和相似案例
  • +
+
+
+

输出

+
    +
  • 风险分和风险等级
  • +
  • 证据链和制度依据
  • +
  • 建议动作和自动化模式
  • +
  • 算法版本和置信度
  • +
  • 人工反馈状态
  • +
+
+
+

用途

+
    +
  • 单据详情解释风险
  • +
  • 工作记录展示任务产出
  • +
  • 风险看板聚合态势
  • +
  • 规则优化和误报分析
  • +
  • 离线回放和算法评测
  • +
+
+
+ +
+

核心结构

+
RiskObservation
+  subject_type / subject_id
+  risk_type / risk_signals_json
+  severity / score / confidence_score
+  ontology_parse_id / ontology_version
+  domain / scenario / intent
+  evidence_items_json / evidence_path_json
+  related_policy_clauses_json
+  comparable_baseline_json
+  suggested_actions_json
+  source_type / source_id
+  automation_mode / control_stage / control_mode
+  algorithm_version / feedback_status
+
+
+
+ +
+
+
+
+

风险评分模型

+
+ 第一版采用可解释加权模型,不用黑盒模型直接给最终结论。复杂模型只提供候选证据,强结论必须能解释。 +
+
+ Explainable Score +
+ +
+
+risk_score = clip( + 0.35 * S_rule ++ 0.25 * S_anomaly ++ 0.20 * S_graph ++ 0.15 * S_policy ++ 0.05 * S_history, + 0, 100 +) +
+
+ S_rule 来自规则中心命中,S_anomaly 来自画像基线偏离,S_graph 来自图谱关系异常,S_policy 来自制度语义,S_history 来自历史反馈。 +
+
+ +
+
+

同类基线偏离

+
deviation = (x - median(peer)) / max(IQR(peer), epsilon) +S_anomaly = 100 * sigmoid(k * (deviation - tau))
+
用于金额、频次、天数、退回率等指标。样本不足时必须记录降级口径。
+
+
+

图谱异常分

+
S_graph = min(100, sum(w_i * g_i))
+
候选信号包括重复票据、供应商集中、审批链异常、时空冲突和相似单据异常。
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
分数项证据来源可解释依据是否可单独强拦截
S_rule规则中心 / 决策表规则版本、命中条件、输入快照。强制度规则可拦截。
S_anomaly画像基线同部门、同职级、同费用类型分位偏离。不可单独拦截。
S_graph图谱路径供应商集中、重复票据、异常审批路径。高证据覆盖时可复核拦截。
S_policy制度条款费用说明、票据、制度条款是否一致。需结合规则和证据。
S_history人工反馈相似单据退回率、确认率、误报率。不单独拦截,影响权重和门控。
+
+
+
+ +
+
+
+
+

证据链如何展示

+
+ 图谱不要默认展示成全量关系网。产品上要展示小范围、场景化、可解释的证据链。 +
+
+ Evidence Path +
+ +
+
员工张三,P8,研发中心
+
单据差旅报销 4,860 元
+
票据酒店发票,1,260 元/晚
+
供应商上海某酒店
+
基线同级别 P90 为 760 元/晚
+
制度住宿标准第 3 条
+
历史相似 12 笔,8 笔退回
+
+ +
+
+

单据详情

+

面向审批人,解释当前单据为什么异常、应该怎么处理。

+
+
+

工作记录详情

+

面向管理员,解释数字员工本次任务处理了哪些实体、发现了哪些风险。

+
+
+

风险看板

+

面向财务和管理层,展示整体风险态势、算法效果和抽审结果。

+
+
+
+
+ +
+
+
+
+

深度算法层

+
+ 这些模块构成算法复杂度,但必须全部回到可解释证据和 `RiskObservation`,不能变成不可审计黑盒。 +
+
+ Algorithm Stack +
+ +
+
+

对象中心过程挖掘

+

把申请、票据、预算、审批、付款、供应商放入多对象事件日志,识别跳步审批、流程绕行、返工循环和付款前异常。

+
+
+

实体解析与主数据归一

+

归一供应商、商户、酒店、银行户名、员工姓名,避免同一主体被拆成多个节点。

+
+
+

异构图与时序图特征

+

计算员工、部门、供应商、票据、规则、制度之间的元路径、中心性、邻域风险密度和关系突增。

+
+
+

多模型异常检测组合

+

组合稳健统计、孤立森林、局部离群、时间突变和周期偏离,但只作为候选风险证据。

+
+
+

决策建模与策略即代码

+

把规则中心的确定性规则拆成决策表、策略运行和决策追踪,保证版本化和可审计。

+
+
+

反事实风险建议

+

给出降低风险分的可执行补救动作,例如补充酒店水单、选择协议酒店或增加预算负责人复核。

+
+
+

数据血缘与质量门禁

+

记录风险观察使用了哪些表、文档、OCR、AgentRun、规则版本和本体版本;低质量数据不触发强风控。

+
+
+

可解释与不确定性控制

+

保存特征贡献、置信度、低置信度原因和人工复核要求,让每个结论都能被复查。

+
+
+
+
+ +
+
+
+
+

人机共审门控

+
+ 系统不应让 AI 直接通过或驳回单据。自动化必须由置信度、风险等级、证据覆盖、历史误报率和金额影响共同决定。 +
+
+ Human in the Loop +
+ +
+
+if confidence >= theta_auto + and severity <= medium + and evidence_coverage >= 2 + and false_positive_rate <= alpha: + automation_mode = "auto" +elif confidence >= theta_semi + and evidence_coverage >= 1: + automation_mode = "semi_auto" +else: + automation_mode = "assist" +
+
+ `assist` 只给建议,`semi_auto` 可半自动但保留抽审,`auto` 只允许低风险、高置信、证据充分、历史误报低的场景。 +
+
+ +
+

高风险

全量复核,必须展示完整证据链和制度依据。

+

中风险

按风险类型、金额和历史误报率进入抽审。

+

低风险

可进入小比例随机抽审,用于监控漏报。

+

低置信

只生成候选观察,不触发强拦截。

+
+
+
+ +
+
+
+
+

产品体现

+
+ 核心算法不能只存在后端。它必须在用户能理解的地方体现:详情解释单点风险,看板解释整体态势,工作记录解释数字员工产出。 +
+
+ Product Surface +
+ +
+
+
单据详情风险证据链、基线对比、制度条款、建议动作。
+
工作记录详情任务范围、处理数量、风险观察、异常关系。
+
风险看板总览、分布、趋势、排行、算法效果。
+
规则中心规则定义、版本、测试、发布、候选规则审核。
+
画像详情员工、部门、供应商和费用类型长期基线。
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
页面用户回答的问题数据来源
单据详情审批人这张单为什么异常,应该怎么处理。RiskObservation + evidence_path
数字员工工作记录管理员这次任务处理了什么,产出了什么风险。AgentRun + RiskObservation
风险看板财务 / 管理层整体风险态势、确认率、误报率、抽审效率。RiskObservation 聚合
规则中心管理员规则如何定义、测试、发布和回滚。risk_rules + rule_runs
+
+
+
+ +
+
+
+
+

实施路线

+
+ 第一版不要一次引入全部复杂算法。先打通最小闭环,然后逐步增强图谱、过程挖掘和评测体系。 +
+
+ Execution Plan +
+ +
+
+
+ 建立本体桥接和 RiskObservation +

把本体解析、规则命中、画像偏离、图谱路径和人工反馈统一进风险观察模型。

+
+
+
+
+ 实现第一批 8 个高价值风险信号 +

重复票据、金额超标准、预算超额、附件缺失、报销发票金额不一致、申请报销不一致、时间地点不一致、供应商集中异常。

+
+
+
+
+ 落地三个产品入口 +

单据详情风险证据链、数字员工工作记录详情、分析看板风险看板。

+
+
+
+
+ 沉淀反馈与回放集 +

记录人工采纳、误报、退回、补件和升级审批;建立历史样本回放能力。

+
+
+
+
+ 引入深度算法模块 +

逐步加入实体解析、对象中心过程挖掘、异构图特征、异常检测组合和反事实建议。

+
+
+
+
+
+ +
+ +
+
+ + +
+ + diff --git a/document/development/hermes_agent/01_architecture_overview.md b/document/development/hermes_agent/01_architecture_overview.md deleted file mode 100644 index 3fa4da4..0000000 --- a/document/development/hermes_agent/01_architecture_overview.md +++ /dev/null @@ -1,41 +0,0 @@ -# Hermes 后台智能体架构总览 - -## 1. 定位与愿景 -Hermes 是 X-Financial 系统中的**后台自动巡检与数据洞察中枢**。与处理实时对话的 UserAgent 不同,Hermes 专注于异步、长周期、大批量的任务,核心价值在于提供事前的**深度风险挖掘**和定期的**业财洞察报告**。 - -## 2. 系统拓扑图 -```mermaid -graph TD - subgraph 调度层 - A[Cron Scheduler] -->|定时触发| B[Task Queue] - end - - subgraph Hermes Agent 层 - B -->|消费任务| C(Hermes Worker) - C --> D[Task Skills Router] - D --> E{RiskScanner Skill} - D --> F{ExpenseReport Skill} - D --> G{KnowledgeCheck Skill} - end - - subgraph X-Financial 核心服务 - E <--> H[(Expense DB)] - F <--> H - G <--> I[(LightRAG Graph/Vector)] - C <--> J[LLM Gateway / OpenAI] - C --> K[Notification Service / 企业微信] - end -``` - -## 3. 核心设计原则 -1. **防抖与限流**:后台全量扫表时,必须分片执行,防止对主数据库造成 I/O 拥堵。 -2. **幂等性保障**:每一个扫描任务和报告生成任务都应该具备唯一幂等键,避免因进程重启导致的重复发信或重复扣减信用分。 -3. **隔离性**:Hermes 的进程应与对外提供 API 服务的 Web Server 物理/逻辑隔离,大模型限流策略(Token Rate Limit)应配置相互独立的账单通道。 - -## 4. 核心执行链路(示例:夜间风控巡检) -1. 凌晨 02:00,Cron 触发 `trigger_risk_scan` 任务。 -2. Worker 拉取状态为 `draft` 和 `submitted` 且 `risk_scanned=False` 的单据。 -3. 将近三个月的相关人员单据聚类,构建 Context。 -4. 调用大模型,寻找“拆单”、“合谋”、“时间/地点异常”等隐蔽风险。 -5. 将发现的风险写入 `hermes_risk_report` 表,并标记对应单据。 -6. 任务结束,更新执行日志,等待早晨财务主管查阅。 diff --git a/document/development/hermes_agent/02_database_design.md b/document/development/hermes_agent/02_database_design.md deleted file mode 100644 index a379d98..0000000 --- a/document/development/hermes_agent/02_database_design.md +++ /dev/null @@ -1,46 +0,0 @@ -# Hermes 数据库表结构设计 - -为了支持后台异步任务的执行和长期记忆(风险标记、执行结果归档),我们需要在数据库中增加(或扩充)以下表结构。 - -## 1. 任务调度与执行表 - -### `hermes_task_config` (定时任务配置表) -用于管理所有的后台巡检和推送任务,支持动态调整频率与开关。 -- `id`: string (UUID) -- `task_type`: string (enum: `global_risk_scan`, `weekly_expense_report`, `kb_validation`...) -- `cron_expression`: string (e.g., `0 2 * * *`) -- `is_enabled`: boolean (默认 True) -- `payload_template`: jsonb (预留参数,如扫描的时间窗口、特定部门过滤条件等) -- `updated_at`: datetime - -### `hermes_task_execution_log` (任务执行日志表) -记录每次任务的执行状态,便于排错与溯源。 -- `id`: string (UUID) -- `config_id`: string (外键,关联 `hermes_task_config`) -- `started_at`: datetime -- `completed_at`: datetime -- `status`: string (enum: `running`, `success`, `failed`) -- `result_summary`: string (执行结果的简要说明,如“扫描了 1500 条单据,发现 12 条高危”) -- `error_trace`: text (如果失败,存储错误堆栈) - -## 2. 深度分析结果表 - -### `hermes_risk_report` (深度风险报告表) -用于存储 LLM 找出的深层逻辑风险。 -- `id`: string (UUID) -- `claim_id`: string (外键,关联存疑的主单据 `expense_claim`) -- `execution_log_id`: string (外键,由哪次扫描任务产生的) -- `risk_level`: string (enum: `low`, `medium`, `high`, `critical`) -- `risk_type`: string (enum: `split_billing` 拆单, `collusion` 合谋, `policy_violation` 违规...) -- `risk_description`: text (大模型生成的自然语言报告,如“该单据与前天提交的单据存在拆分可能...”) -- `related_claim_ids`: jsonb (存储关联的同谋/相关单据 ID 列表,提供上下文线索) -- `status`: string (enum: `pending_review` 待人工复核, `confirmed` 已确认为风险, `dismissed` 已忽略) - -## 3. 现有表的平滑改造 - -### 修改 `employee` 表 (员工信用分预留) -- **新增字段** `compliance_score`: int (默认 100,由 Hermes 动态扣减或恢复,用于风控引擎调节对该员工的抽查率和宽容度) - -### 修改 `expense_claim` 表 (风控标记) -- **新增字段** `hermes_scanned_at`: datetime (记录该单据上次被 Hermes 扫描的时间,防止重复扫描) -- **新增字段** `hermes_risk_flag`: boolean (快速判断该单子是否被挂载了 `hermes_risk_report`) diff --git a/document/development/hermes_agent/03_risk_scan_module.md b/document/development/hermes_agent/03_risk_scan_module.md deleted file mode 100644 index 95daef8..0000000 --- a/document/development/hermes_agent/03_risk_scan_module.md +++ /dev/null @@ -1,32 +0,0 @@ -# 深度风险扫描模块设计 (Risk Scan Module) - -## 1. 业务目标 -将单点硬规则风控(如:发票大于 500 元是否合规)升级为**图谱式全局风控**。Hermes 将利用大语言模型(LLM)的逻辑推理能力,在海量历史数据中寻找隐藏的违规模式。 - -## 2. 核心扫描链路 -本模块将作为一个独立的 Skill 被定时任务触发。 - -### 第一步:数据快照聚合 -- **提取目标**:拉取状态为 `draft`、`submitted` 且最近 30 天内活跃的报销单,同时带出相关的发票明细。 -- **降维处理**:为避免超出大模型的 Token 上下文限制,必须对单据信息进行降维。仅提取:`申请人、时间、地点、商户名、金额、报销类型` 形成精简的 CSV 或 JSON Lines 格式。 - -### 第二步:大模型批量推理 (LLM Batch Inference) -- **风险定义植入**:通过 System Prompt 将目前财务最头疼的几类风险定义给模型(如拆单、套现、虚假连号发票)。 -- **执行方式**:将数据按“同部门”或“同地域”分块 (Chunking) 喂给大模型。 -- **Prompt 示例**: - ```markdown - 你是一个内控审计 Agent。以下是某部门近半个月的报销流水清单。 - 请找出其中是否存在: - 1. 拆单行为(同人、同地点、连日、小额累加) - 2. 聚众套现行为(不同人、同偏僻餐馆、同日极高额) - 如果发现风险,请输出对应的单号集合以及你的推理过程。 - ``` - -### 第三步:风险标记与处置 -- 解析大模型返回的结构化 JSON。 -- 对被判定的高危单据,在主库中插入 `hermes_risk_report` 记录。 -- **动作反馈**:如果该单据正处于 `submitted` 状态,并且得分极高(如虚假连号发票),可以通过 X-Financial 原有接口自动注入“退回”动作,并附加大模型的分析日志。 - -## 3. 防抖与自我迭代 -- **扫描去重**:利用 `expense_claim.hermes_scanned_at` 防止已经出具过报告的单据被重复投入分析队列。 -- **人工纠偏 (Human-in-the-loop)**:当财务在前端驳回 Hermes 的风险提示(即认为没问题)时,事件将被记录。Hermes 可通过夜间的反思任务优化下一次 Prompt 中的判定容忍度。 diff --git a/document/development/hermes_agent/04_expense_report_module.md b/document/development/hermes_agent/04_expense_report_module.md deleted file mode 100644 index 3873102..0000000 --- a/document/development/hermes_agent/04_expense_report_module.md +++ /dev/null @@ -1,34 +0,0 @@ -# 动态费控与洞察报告模块设计 (Expense Report Module) - -## 1. 业务目标 -基于海量的流水账单,定期(周/月)由 Hermes Agent 为部门管理者或财务总监自动生成具有**业务洞察力**的归因分析报告,将冷冰冰的数字转化为具有指导意义的自然语言建议。 - -## 2. 核心分析链路 - -### 第一步:BI 数据聚合 (Data Aggregation) -- 借助 ORM 或底层 Data Warehouse (如有),Hermes 执行预置的聚合查询。 -- **采集核心指标**: - - 本期各部门总花费及环比/同比变动率。 - - 各类目(如打车、机票、住宿、招待)的占比变化。 - - Top 10 花费最多的商户(如特定几家酒店或订票平台)。 - - 各类目超额/退回率最高的人员画像。 - -### 第二步:大模型归因分析 (LLM Attribution Analysis) -将硬性的聚合数据转化为结构化 Prompt,让 LLM 充当“财务分析师”。 -- **Prompt 示例**: - ```markdown - 你是企业的财务总监助理,请阅读以下【本月报销聚合数据】。 - 请帮我撰写一份 300 字以内的执行摘要报告。 - 重点指出: - 1. 哪个部门/哪类费用增长最快?原因可能是什么? - 2. 我们的长尾开销集中在哪些商户?是否存在能够跟商户谈“协议价”的谈判空间? - ``` - -### 第三步:多渠道报告分发 (Report Delivery) -- **生成制品**:Hermes 利用代码解释器 (如有) 或 Markdown 引擎,将图表与文本融合成正式的 PDF 或长图。 -- **触达渠道**: - - **推送机制**:调用企微/钉钉机器人 API,直接向管理者的工作台推送“上周费控简报”。 - - **交互追问**:管理者收到简报卡片后,可以在对话框里直接@Hermes 追问:“详细列一下研发部上周在北京住宿的那 5 万块钱是怎么花的”,Hermes 将调取缓存的报告上下文立即答复。 - -## 3. 商业价值落地 -这项功能极大地解放了财务部的报表处理时间。通过提供前置的谈判线索(如发现某经济型酒店的高频住客其实都可以导流到协议酒店),可以给公司带来直接的差旅成本节约。 diff --git a/document/development/hermes_agent/05_deployment_and_cron.md b/document/development/hermes_agent/05_deployment_and_cron.md deleted file mode 100644 index 4fe5e6c..0000000 --- a/document/development/hermes_agent/05_deployment_and_cron.md +++ /dev/null @@ -1,63 +0,0 @@ -# 部署与任务调度架构方案 (Deployment & Cron) - -## 1. 业务诉求分析 -Hermes 作为纯后台的智能体,它的执行过程长达几分钟甚至几小时。它绝不能与提供给前台 HTTP 请求的主 Web 服务混合在同一个事件循环(Event Loop)或同步进程中,否则会导致 API 严重堵塞和超时崩溃。 - -因此,Hermes 的部署需要进行**进程级解耦**。 - -## 2. 选型对比与推荐方案 - -### 方案 A:Celery + Redis (重型/工业级标准) -- **优势**:业界最成熟的 Python 异步任务队列,支持极其复杂的 Cron 配置,原生支持任务重试、失败回调以及分布式扩展。 -- **劣势**:增加系统组件(必须额外部署 Redis / RabbitMQ 容器),运维成本相对较高。 -- **结论**:如果 X-Financial 后续要承载上千人的企业报销,这是**首选必经之路**。 - -### 方案 B:APScheduler + Background Worker (中型/轻量级) -- **优势**:直接在 Python 进程内运行,无需额外的消息队列组件。可以用一个单独的 Docker 容器运行 `python run_hermes_scheduler.py`。 -- **劣势**:多节点部署时难以控制并发(可能会多个节点同时执行同样的任务),需要引入基于数据库表或 Redis 的分布式锁。 -- **结论**:适合初期快速跑通 MVP 的方案。 - -## 3. 推荐架构:基于 Redis 分布式锁的独立容器方案 -结合当前现状,建议采用 **方案B 叠加 分布式锁** 的轻量微服务架构。 - -### 部署拓扑: -```yaml -# docker-compose.yml 示例切片 -services: - x-financial-api: - build: . - command: uvicorn main:app - ports: - - "8000:8000" - - x-financial-hermes: - build: . - command: python scripts/start_hermes_daemon.py - # 这个容器不暴露外部端口,纯粹在后台运行定时任务和消费队列 -``` - -### 执行伪代码 (`start_hermes_daemon.py`) -```python -from apscheduler.schedulers.blocking import BlockingScheduler -from app.services.system_hermes import SystemHermesService - -scheduler = BlockingScheduler() -hermes = SystemHermesService() - -# 每天凌晨 3 点执行深度风控扫表 -@scheduler.scheduled_job('cron', hour=3, minute=0) -def job_risk_scan(): - # 获取分布式锁防止集群脑裂重复执行 - if acquire_redis_lock("hermes:lock:risk_scan"): - try: - hermes.run_query("执行全局风控扫描技能...", skills=["global_risk_scan"]) - finally: - release_redis_lock("hermes:lock:risk_scan") - -if __name__ == "__main__": - scheduler.start() -``` - -## 4. 容灾与可观测性保障 -- **日志采集**:确保 `hermes.run_query` 及其后台生成的 stdout/stderr 日志能够写入 ELK 或文件系统中,方便第二天的运维排查。 -- **告警链路**:如果调度系统挂掉或者大模型连续多次返回失败状态码,必须通过 Webhook 飞书/钉钉及时告警系统管理员。 diff --git a/document/development/knowledge-answers/TODO.md b/document/development/knowledge-answers/TODO.md deleted file mode 100644 index 012f8e6..0000000 --- a/document/development/knowledge-answers/TODO.md +++ /dev/null @@ -1,56 +0,0 @@ -# Knowledge Answers TODO - -更新时间:2026-05-16 - -目标: -- 让知识库问答的主路径从“LightRAG 检索 + 慢模型二次整理”改为“结构化证据优先 + 模型可选总结”。 -- 让问答能力尽量依赖当前文档内容本身,而不是依赖某一份制度、某一个城市或某一种表格写法。 -- 参考 Yuxi 的优点,优先补齐 `统一解析思路 + 文档类型友好的结构增强 + 检索后原文证据回退`,不照搬其完整平台基础设施。 - -Yuxi 调研结论: -- [x] 已完成 Yuxi 调研与方案提炼 - 备注:Yuxi 的通用性主要来自三层:统一文档解析、可切换的 chunk/preset、检索不足时回到解析后 Markdown 继续取证;并不是靠给某个文档写死回答逻辑。 - -本轮改造原则: -- [x] 先撤掉文档特化硬编码,再补通用结构能力。 -- [x] 真实答案只能来自当前命中文档的内容,代码里不固化制度金额、地区档位或条款结论。 -- [x] 即使问题不是表格表达,也要能基于章节、条款、列表、键值对、上下文段落给出可读答案。 -- [x] 模型只负责“压缩表达”,不负责“凭空补事实”;模型超时时也必须能返回像样的证据型答复。 - -实施清单: -- [x] 移除当前临时文档特化 fast path - 备注:删除当前围绕差旅表格、城市档位、职级档位的临时规则,避免系统继续向单文档 hardcode 演化。 - -- [x] 入库增强:补通用结构附录 - 备注:参考 Yuxi 的解析/分块思想,在现有入库文本增强中补充章节、条款、列表、键值对、表格与上下文邻接信息,让非表格关系也能被稳定命中。 - -- [x] 检索后增强:生成面向回答的证据片段 - 备注:从命中的 hits 中再次抽取更短、更结构化的 answer evidence,优先保留标题路径、条款句、列表项、表格行和与 query 强相关的上下文窗口。 - -- [x] 回答链路重构:证据驱动直答 - 备注:新增通用知识问答直答器,先根据 answer evidence 生成可直接展示的短答案;只有在证据不足或问题需要更自然表达时才调用模型。 - -- [x] 模型总结收口:缩小上下文面,保留原文约束 - 备注:把传给模型的上下文从“整段命中 chunk”收缩到“高置信 answer evidence”,既降延迟,也降低答非所问和错列风险。 - -- [x] 降级回答升级:从“命中摘抄”改成“证据摘要” - 备注:即使模型超时或失败,也要返回按证据组织好的结论、依据和缺失信息,而不是大段原文拼贴。 - -- [x] 测试补齐 - 备注:覆盖非表格制度文本、表格文本、列表/键值对文本、模型超时降级、去除硬编码路径等关键回归点。 - -- [x] 真实验证与回填 TODO - 备注:已重建当前知识库索引并完成真实验证。当前“回答整理”阶段已降到亚秒级,但 LightRAG 首次/冷启动检索仍受 embedding 与 rerank 耗时影响,后续如要继续压缩总耗时,应进一步优化检索参数与模型链路。 - -验收标准: -- [x] 常规知识问答不再长时间卡在“正在整理答案”。 -- [x] 文档不是表格表达时,仍能基于章节/条款/列表/上下文回答。 -- [x] 文档内容变动后,不需要改业务代码里的制度结论或金额常量。 -- [x] 模型超时时仍能返回结构清楚、证据明确的答案。 -- [x] 相关测试通过,且没有破坏现有知识库问答流程。 - -验证记录: -- [x] 单测通过:`test_user_agent_service.py`、`test_knowledge_normalizer.py`、`test_knowledge_rag_service.py` 共 35 项全部通过。 -- [x] 当前知识库文档已按新规则 `force` 重建索引成功。 -- [x] 真实问答抽检:`餐补标准是什么?`、`费用发生后多久内提交报销申请?`、`前往北京出差的报销标准是什么?` - 备注:回答生成阶段约 `0.24s ~ 0.30s`;其中“前往北京出差”问题会明确提示当前证据未直接给出“北京”地区档位映射,不再硬猜。 diff --git a/document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html b/document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html deleted file mode 100644 index e45a4fa..0000000 --- a/document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html +++ /dev/null @@ -1,896 +0,0 @@ - - - - - - X-Financial 轻量知识库归集与问答优化开发文档 - - - -
- - -
-
-
- 开发文档 · 先定边界再实现 -

X-Financial 轻量知识库归集与问答优化方案

-

- 本方案不把 X-Financial 改造成专业知识库平台,而是在现有 - LightRAGHermesAgentRun - 和知识库 UI 上补齐最薄弱的归集、分块、召回和证据回答能力。 - Yuxi 只作为成熟设计参考,借鉴其统一解析、分块预设和评估思想。 -

-
- 保留现有 LightRAG - 轻量 Parser - 条款级分块 - 混合召回 - 证据化回答 -
-
- -
-
-
核心目标
-
准、快、可解释
-
-
-
改造范围
-
归集与召回链路,不重做平台
-
-
-
并发预期
-
5-10 用户查询可降级、有上限
-
-
-
- -
-
-
-

定位与边界

-

- 知识库在 X-Financial 中是业务辅助能力,不是独立知识管理产品。 - 因此实现必须克制:不引入重型多租户平台,不替换现有业务数据模型, - 不把知识库 UI 做成复杂后台,只补齐影响问答质量的关键薄层。 -

-
- 轻量优先 -
- -
-
-

要解决的问题

-
    -
  • Word、PDF、Excel 等文件进入 RAG 前缺少统一结构。
  • -
  • 制度类文档如果按普通 chunk 切分,条款容易被切散。
  • -
  • 问答质量依赖向量召回,缺少关键词、标题、条款补召回。
  • -
  • 效果优化缺少固定评测集,容易靠体感判断。
  • -
-
-
-

保留的现有能力

-
    -
  • KnowledgeService 继续负责文件库和状态入口。
  • -
  • KnowledgeRagService 继续封装 LightRAG 查询和入库。
  • -
  • KnowledgeIndexTaskManager 继续承接 Hermes 增量任务。
  • -
  • 前端知识管理继续保持简单文件夹与文件列表形态。
  • -
-
-
-

明确不做

-
    -
  • 不整体引入 Yuxi 平台。
  • -
  • 不把存储改成 Milvus + Neo4j。
  • -
  • 不一次性接入全量 OCR 引擎。
  • -
  • 不新增复杂多租户知识库后台。
  • -
-
-
-
- -
-
-
-

轻量架构

-

- 新增能力只放在 LightRAG 前后两侧:前侧负责把文件变成稳定 Markdown 和业务友好 chunk, - 后侧负责混合召回、证据重排和可靠回答。LightRAG 仍是主召回核心。 -

-
- 薄层增强 -
- -
原始文件 - ├── docx / pdf / xlsx / pptx / csv / txt - ↓ -轻量 Parser - ├── 统一 Markdown - ├── 表格上下文 - └── 页码 / sheet / 条款路径 - ↓ -Chunk Preset - ├── laws:制度条款 - ├── qa:常见问答 - └── table:表格行组 - ↓ -现有 LightRAG / Qdrant - ↓ -混合召回 - ├── LightRAG 语义召回 - ├── 标题与条款关键词召回 - └── 轻量重排 top 3-5 - ↓ -证据化回答 - ├── 命中证据 - ├── 直接结论 - └── 缺失信息说明
-
- -
-
-
-

Yuxi 借鉴点

-

- Yuxi 的价值不在于整套平台,而在于成熟的归集分层思想: - 文件先解析成 Markdown,再按场景分块,再索引,再评估。 - 这些思想可以小规模落地到现有服务内。 -

-
- 借鉴而非搬运 -
- -
-
-

统一 Parser

-

学习 Yuxi 把多格式文件统一转 Markdown 的入口设计,但只实现 X-Financial 当前需要的格式。

-
-
-

分块 Preset

-

借鉴 RAGFlow-like preset。先做 lawsqatable 三类。

-
-
-

两阶段状态

-

内部区分解析和索引。UI 仍可显示简单归纳状态,后台记录真实失败点。

-
-
-

轻量评测

-

不做评估平台,只维护 JSON 用例和脚本,持续检查召回与回答质量。

-
-
- - -
- -
-
-
-

模块设计

-

- 新增模块必须小而清楚,避免把逻辑继续堆进单个 Service。 - 单个核心文件控制在 800 行以内,优先按解析、分块、召回、评测拆分。 -

-
- 职责拆分 -
- -
-
-
knowledge_parser.py
-
- 负责把 docx、pdf、xlsx、csv、txt 等文件转成 Markdown。 - 输出正文、标题路径、页码、sheet、表头、解析告警。 -
-
-
-
knowledge_chunking.py
-
- 根据文件夹、文件类型和文档特征选择分块策略。 - 第一批只实现制度、问答、表格三类。 -
-
-
-
knowledge_retrieval.py
-
- 在 LightRAG 命中结果外补充关键词、条款标题和文件名召回。 - 最终输出小而准的证据块。 -
-
-
-
knowledge_eval.py
-
- 读取轻量评测用例,检查 expected 文件、关键词、证据和答案约束。 - 用于每次调整参数后的回归验证。 -
-
-
-
- -
-
-
-

召回与回答策略

-

- 目标不是让模型更会猜,而是让系统给模型更可靠的证据。 - 制度问题优先命中条款,表格问题保留表头与行上下文,回答必须暴露依据和缺失信息。 -

-
- 证据优先 -
- -
-
-

召回层

-
    -
  • LightRAG 继续提供语义召回。
  • -
  • 条款号、标题、文件名、关键词做补召回。
  • -
  • 召回候选数量有上限,避免并发下无限扩张。
  • -
-
-
-

重排层

-
    -
  • 优先保留含问题关键词、标题路径和条款语义的块。
  • -
  • 制度类按条款完整度加权。
  • -
  • 最终给回答链路 3-5 条高质量证据。
  • -
-
-
-

回答层

-
    -
  • 能直接基于证据回答时,不强制二次模型整理。
  • -
  • 模型只做压缩表达,不凭空补事实。
  • -
  • 证据不足时明确说明缺什么。
  • -
-
-
-
- -
-
-
-

实施路线

-

- 分四步小步交付。每一步都能单独验证,不把解析、索引、召回和评测揉成一次大改。 -

-
- 渐进落地 -
- -
-
-
P0 / 文档落地
-
- 先明确轻量边界 - 完成本文档,确认不做重平台、不替换存储、不一次性引入复杂 OCR。 -
-
-
-
P1 / 统一解析
-
- 补齐文件归集质量 - 新增 Parser,把 Word、PDF、Excel、CSV、TXT 稳定转为 Markdown,并保存解析产物供索引复用。 -
-
-
-
P2 / 场景分块
-
- 提升制度与表格命中率 - 实现 laws、qa、table 三类分块。制度按章、节、条、款保留完整语义,表格保留 sheet、表头和行上下文。 -
-
-
-
P3 / 混合召回
-
- 减少答偏和漏召回 - 在 LightRAG 命中外补充关键词、条款标题、文件名召回,输出可控数量的证据块。 -
-
-
-
P4 / 轻量评测
-
- 把效果优化变成可回归 - 建设 30-50 条远光软件制度风格问答用例,覆盖报销、差旅、发票、预算、税务等高频问题。 -
-
-
-
- -
-
-
-

验收标准

-

- 验收不只看页面状态,而要看文件是否真实入库、召回是否命中文档依据、 - 回答是否引用证据,以及并发访问时是否能稳定降级。 -

-
- 真实验证 -
- -
-
Word、PDF、Excel、CSV、TXT 文件能生成可读 Markdown,且解析产物可复用。
-
制度类文件能按章、节、条、款形成相对完整的证据块。
-
Excel 表格问答能保留 sheet、表头、关键列和业务行上下文。
-
Hermes 增量任务能区分解析失败、索引失败和归纳失败。
-
常见制度问答优先返回证据化直接答案,模型超时时仍有可读降级答案。
-
5-10 个用户同时访问时,查询候选数、重排数、模型调用数都有明确上限。
-
轻量评测集覆盖至少 30 条问题,并记录命中文件、关键词和答案约束。
-
不引入 Yuxi 平台级依赖,不改变现有知识库 UI 的主体交互。
-
- -

- 后续实现时,优先在现有定向测试基础上补充 Parser、Chunking、Retrieval 和 Knowledge Eval 的小测试。 - 后端验证优先在 Docker 容器 x-financial-main 中运行。 -

-
- -
- X-Financial 轻量知识库归集与问答优化开发文档 · 放置位置:document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html -
-
-
- - diff --git a/document/development/risk-rule-explainable-flow/CONCEPT.md b/document/development/risk-rule-explainable-flow/CONCEPT.md new file mode 100644 index 0000000..aedade8 --- /dev/null +++ b/document/development/risk-rule-explainable-flow/CONCEPT.md @@ -0,0 +1,546 @@ +# 风险规则可解释流程判断改造方案 + +## 功能一句话 + +把风险规则从“自然语言生成一段 JSON”升级为“自然语言、字段本体、可执行 JSON DSL、流程判断图、测试命中路径、版本修改”一致闭环,让业务用户能看懂规则怎么判断,让系统按同一套逻辑执行。 + +## 背景与问题 + +当前风险规则已经具备自然语言创建、JSON 风险规则、风险评分、详情页流程图、仿真测试和上线启用能力,但仍有几个关键缺口: + +- 规则解释不够稳定。用户输入复杂业务规则后,系统可能把“城市是否一致、日期是否越界、是否存在合理说明”解释成“是否出现关键词”,这会导致业务语义失真。 +- 流程图容易变成展示装饰。如果流程图不严格从可执行 JSON DSL 派生,就会出现“页面看起来是 A,后端实际执行是 B”的问题。 +- 测试结果缺少路径解释。用户上传票据和输入意图后,需要知道系统识别到了哪些字段、走过哪些判断节点、为什么命中或未命中。 +- 规则修改缺少版本化闭环。已上线规则不能直接覆盖,应创建修订版本,旧版本继续生效,新版本测试通过后再替换。 +- 常见费控规则需要模板化扩展。预算、票据、差旅、招待、采购/AP、企业卡等规则应进入规则模板库,但仍必须走同一套解释和执行链路。 + +## 目标与非目标 + +### 目标 + +- [G1] 自然语言规则经过 Hermes 语义理解后,生成结构稳定、可校验、可执行的 JSON DSL。 +- [G2] 流程判断图必须由 JSON DSL 派生,不能直接由自然语言单独生成。 +- [G3] 详情页展示“文字流程解释 + 流程图 + 使用字段 + 风险分数 + 规则状态”,让业务用户能确认系统理解是否正确。 +- [G4] 测试规则时展示本次样例或票据仿真的字段识别结果、判断路径、命中节点和最终结论。 +- [G5] 用户觉得规则不合理时,通过“创建修订版本”修改,线上版本保持稳定。 +- [G6] 常见费控规则模板库可以复用同一套 DSL、流程图和测试机制。 + +### 非目标 + +- [NG1] 本期不做流程图编辑器,不允许拖拽、改节点、缩放编辑或在线画图。 +- [NG2] 本期不让大模型作为风险命中裁判。Hermes 只负责理解、生成、解释和辅助解析,最终命中由规则执行器决定。 +- [NG3] 本期不把所有复杂政策一次性建成完整专家系统,先保证规则表达、解释和执行一致。 +- [NG4] 本期不直接覆盖已上线规则,所有线上修改都走修订版本。 + +## 用户与场景 + +- 财务规则制定者:创建风险规则,查看系统理解是否正确,修改草稿规则。 +- 高级财务人员 / admin:审核、上线、下线、启用、停用、删除未发布规则。 +- 普通报销用户:在真实业务命中风险时看到简明原因,可反馈误判或漏判。 +- 系统执行链路:报销、费用申请、预算占用、票据识别、采购/AP 等场景只加载已上线且已启用的规则。 + +核心场景: + +1. 新建规则:输入规则标题、费用业务环节、费用领域、是否需要附件、自然语言规则。 +2. 生成规则:Hermes 结合字段本体输出 JSON DSL、业务说明、风险评分、流程模型和 SVG。 +3. 查看详情:用户确认“系统理解的字段、判断条件、例外说明、命中动作”是否正确。 +4. 仿真测试:用户上传附件并输入测试意图,系统统一识别字段,再由执行器判断当前规则。 +5. 修改规则:未上线规则直接编辑;已上线规则创建修订版本,测试通过后发布替换。 + +## 功能能力 + +### C1. 自然语言输入能力 + +新建风险规则表单应包含: + +- 规则标题。 +- 业务环节:费用申请、报销、预算控制、付款/采购等。 +- 费用领域:差旅、住宿、交通、招待、办公、培训、会议、软件服务、通讯、福利、预算、发票、采购/AP、通用。 +- 是否需要附件:需要时测试弹窗开放附件上传;不需要时隐藏上传入口。 +- 自然语言规则描述。 + +风险等级不允许用户手动选择,由评分模型输出风险分数和等级。 + +### C2. 语义理解与字段本体映射 + +Hermes 需要输出一份中间语义计划,而不是直接写死 JSON: + +- 规则意图:判断什么业务风险。 +- 适用范围:业务环节、费用领域、费用科目、单据类型。 +- 所需字段:中文解释、英文字段名、来源、是否必填。 +- 票据字段:OCR 或文档智能识别得到的城市、日期、金额、销售方、发票号等。 +- 判断步骤:按顺序表达条件、分支、例外说明和命中动作。 +- 例外条件:例如延期、改签、跨城办事、临时任务等说明。 +- 风险动作:提醒、人工复核、要求补充说明、退回修改、禁止提交。 + +字段展示统一为: + +```text +申报目的地[claim.destination_city] +明细发生地点[item.location_city] +交通票行程城市[receipt.transport_route_cities] +住宿发票城市[receipt.hotel_city] +出差开始日期[trip.start_date] +出差结束日期[trip.end_date] +报销事由[claim.reason] +``` + +### C3. 可执行 JSON DSL + +JSON DSL 应表达规则执行逻辑,而不是保存自然语言摘要。建议基本结构: + +```json +{ + "rule_id": "risk.travel.city_mismatch", + "version": "v1", + "scope": { + "business_stage": "reimbursement", + "expense_types": ["travel", "lodging"] + }, + "required_fields": [ + { + "label": "申报目的地", + "field": "claim.destination_city", + "source": "claim", + "required": true + } + ], + "conditions": { + "all": [ + { + "op": "in_scope", + "field": "expense.type", + "values": ["travel", "lodging"] + }, + { + "op": "any_present", + "fields": [ + "receipt.transport_route_cities", + "receipt.hotel_city", + "item.location_city" + ] + }, + { + "op": "none_match", + "left_fields": [ + "receipt.transport_route_cities", + "receipt.hotel_city" + ], + "right_fields": [ + "claim.destination_city", + "item.location_city", + "trip.route_cities" + ], + "matcher": "city_equivalent" + }, + { + "op": "not_contains_any", + "field": "claim.reason", + "values": ["延期", "改签", "跨城办事", "临时任务", "绕行"] + } + ] + }, + "action": { + "risk_level": "high", + "risk_score": 76, + "decision": "review_required", + "message": "票据城市与申报目的地或行程城市不一致,且未说明合理原因。" + } +} +``` + +核心要求: + +- 城市、日期、金额、人员、供应商等字段必须使用专门比较算子,不能退化成“关键词出现”。 +- 复杂规则允许多层条件组合:`all`、`any`、`not`、`branch`、`exists`、`range`、`compare`、`semantic_contains`。 +- 例外说明可以使用语义包含,但只能影响“是否进入复核/降级/豁免”,不能替代结构化字段判断。 +- DSL 生成后必须通过 schema 校验和执行器 dry-run。 + +### C4. 流程判断图 + +流程图不是编辑器,也不是自然语言插图。流程图必须由 JSON DSL 转换成 `flow_model`,再生成 SVG。 + +建议 `flow_model`: + +```json +{ + "nodes": [ + { + "id": "start", + "type": "start", + "title": "开始", + "description": "进入差旅住宿报销风险检查" + }, + { + "id": "scope", + "type": "decision", + "title": "是否属于差旅住宿报销", + "fields": ["expense.type", "claim.business_stage"] + }, + { + "id": "city_match", + "type": "decision", + "title": "票据城市是否匹配申报或行程城市", + "fields": [ + "receipt.hotel_city", + "receipt.transport_route_cities", + "claim.destination_city", + "trip.route_cities" + ] + }, + { + "id": "hit", + "type": "risk", + "title": "命中高风险", + "description": "要求补充行程说明或退回修改" + } + ], + "edges": [ + { "from": "start", "to": "scope", "label": "开始检查" }, + { "from": "scope", "to": "end_pass", "label": "否" }, + { "from": "scope", "to": "city_match", "label": "是" }, + { "from": "city_match", "to": "end_pass", "label": "是" }, + { "from": "city_match", "to": "hit", "label": "否" } + ] +} +``` + +流程图展示要求: + +- 详情页左侧为文字流程解释,右侧为“流程图”。 +- 判断分支用“是 / 否 / 通过 / 不通过 / 缺失 / 存在”等明确标签。 +- 风险命中框使用风险等级颜色:低风险蓝色,中风险橙色,高风险红色,极高风险深红色。 +- 普通节点保持 SaaS 白底、细边框、低饱和样式,不能整张图都染成风险色。 +- 图只做展示,不响应拖拽、编辑、缩放和节点点击。 +- 节点数量超过 8 个时,需要自动压缩文字、合并说明节点或分页展示,避免图过大。 + +### C5. 测试命中路径 + +测试规则弹窗应展示三类信息: + +1. 输入与识别结果 + - 用户自然语言测试意图。 + - 上传附件清单。 + - OCR / 文档智能识别字段。 + - Hermes 辅助规范化后的结构化字段。 + +2. 规则执行结果 + - 是否进入适用范围。 + - 每个判断节点的输入值、比较方式、判断结果。 + - 命中的风险动作。 + - 未命中的原因。 + +3. 流程图路径高亮 + - 使用同一个 `flow_model`。 + - 本次执行走过的节点和边由执行器输出 `trace`。 + - 前端按 `trace` 高亮路径。 + +执行 trace 示例: + +```json +{ + "trace_id": "run_001", + "matched": true, + "risk_level": "high", + "risk_score": 76, + "steps": [ + { + "node_id": "scope", + "result": true, + "inputs": { + "expense.type": "住宿费", + "claim.business_stage": "reimbursement" + } + }, + { + "node_id": "city_match", + "result": false, + "inputs": { + "receipt.hotel_city": "北京", + "claim.destination_city": "上海", + "trip.route_cities": ["武汉", "上海"] + } + } + ] +} +``` + +### C6. 规则修改与版本化 + +规则修改分三种: + +- 未上线规则:允许创建者或 admin 直接编辑,保存后重新生成 DSL、流程图、评分和说明。 +- 已上线规则:不允许直接覆盖,必须点击“创建修订版本”。 +- 业务用户反馈:只能提交误判/漏判反馈,由管理员决定是否创建修订版本。 + +已上线规则修改流程: + +```text +线上版本 active + ↓ +创建修订版本 draft_revision + ↓ +编辑自然语言 / 参数 / 附件要求 + ↓ +重新生成 JSON DSL + 流程图 + 评分 + ↓ +仿真测试通过 + ↓ +发布新版本 + ↓ +旧版本归档,新版本 active +``` + +版本记录必须包含: + +- 修改人。 +- 修改原因。 +- 修改前后自然语言差异。 +- 修改前后 DSL 差异。 +- 测试报告。 +- 发布时间。 +- 是否替换线上版本。 + +## 方案设计 + +### 总体链路 + +```text +自然语言规则 + ↓ +字段本体召回与业务域约束 + ↓ +Hermes 生成语义计划 semantic_plan + ↓ +语义计划校验与补全 + ↓ +生成 JSON DSL + ↓ +Schema 校验 + 执行器 dry-run + ↓ +风险评分 risk_score / risk_level + ↓ +DSL 转 flow_model + ↓ +flow_model 转 flow_diagram_svg + ↓ +详情展示 + 仿真测试 + 上线执行 +``` + +### 前端设计 + +涉及入口: + +- `AuditRuleDialogs.vue`:新建风险规则表单,后续增加修订版本编辑入口。 +- `AuditJsonRiskRuleDetail.vue`:详情页展示基本信息、测试状态、流程解释、流程图、操作按钮。 +- `RiskRuleFlowDiagram.vue`:只负责展示 SVG 或由 `flow_model` 派生的静态图,不做编辑。 +- `RiskRuleTestDialog.vue`:仿真测试窗口,展示输入识别、执行路径、测试报告。 +- `auditViewRiskRuleModel.js` / `auditViewModel.js`:规则详情视图模型、列表字段和状态映射。 + +详情页建议结构: + +```text +Topbar:规则标题、状态、风险分数、风险等级、上线/启用状态 + +基本信息:费用领域、业务环节、附件要求、创建人、上线时间、最后操作、测试状态 + +判断流程: + 左侧:文字流程解释 + 右侧:流程图 + +测试与版本: + 最近测试报告 + 修订版本 / 历史版本 + 操作按钮 +``` + +修改规则界面建议采用左右布局: + +- 左侧:自然语言规则编辑、规则标题、费用领域、附件要求。 +- 右侧:系统解释预览,包括字段、本体映射、流程解释、风险分数。 +- 底部:重新生成、保存草稿、测试规则、提交上线。 + +### 后端设计 + +已有相关模块应优先复用: + +- `risk_rule_generation.py`:规则生成主流程。 +- `risk_rule_generation_prompt.py`:Hermes 提示词。 +- `risk_rule_generation_ontology.py`:字段本体和费用领域约束。 +- `risk_rule_generation_semantics.py`:自然语言语义解释。 +- `risk_rule_generation_interpreter.py`:解释结果到 DSL。 +- `risk_rule_scoring.py`:风险评分。 +- `risk_rule_flow_diagram.py`:流程图 SVG 生成。 +- `risk_rule_manifest_normalizer.py`:规则 manifest 规范化。 +- `risk_rule_template_executor.py`:规则执行器。 +- `agent_asset_risk_rule_testing.py`:规则测试、删除、发布、启用。 +- `agent_asset_risk_rule_simulation.py`:仿真测试对话。 + +后端需要补齐的能力: + +- 生成 `semantic_plan` 并持久化到 `config_json` 或版本内容中。 +- 生成并持久化 `flow_model`,再生成 `flow_diagram_svg`。 +- 执行器输出 `trace`,用于测试解释和流程图高亮。 +- 支持创建修订版本,避免直接覆盖 active 版本。 +- 支持从常见模板创建规则,模板也走同一套生成和校验链路。 + +### 接口设计 + +建议新增或调整: + +```text +POST /agent-assets/risk-rules/generate + 根据自然语言创建生成任务,返回生成中资产。 + +POST /agent-assets/{asset_id}/risk-rules/regenerate + 对草稿或修订版本重新生成 DSL、评分和流程图。 + +POST /agent-assets/{asset_id}/risk-rules/revisions + 基于已上线规则创建修订版本。 + +PATCH /agent-assets/{asset_id}/risk-rules/draft + 保存未上线规则或修订版本的编辑内容。 + +POST /agent-assets/{asset_id}/risk-rule-tests/simulate + 独立仿真测试,返回字段识别、执行结果、trace。 + +GET /agent-assets/{asset_id}/risk-rule-tests/latest + 返回最近测试摘要。 + +POST /agent-assets/{asset_id}/publish + 发布通过测试的规则版本。 +``` + +### 数据设计 + +建议在风险规则版本内容或 `config_json` 中保留: + +```json +{ + "source_text": "用户输入的自然语言规则", + "semantic_plan": {}, + "dsl": {}, + "flow_model": {}, + "flow_diagram_svg": "...", + "flow_explanation": [], + "risk_score": 76, + "risk_level": "high", + "required_attachment": true, + "required_fields": [], + "last_operation": { + "action": "publish", + "actor": "admin", + "at": "2026-05-30T10:00:00+08:00" + } +} +``` + +测试记录保留: + +```json +{ + "test_type": "simulation", + "input_text": "我去北京出差 3 天,上传武汉到上海车票", + "attachments": [], + "recognized_fields": {}, + "normalized_fields": {}, + "execution_result": {}, + "trace": {}, + "passed": true, + "tester": "admin", + "tested_at": "2026-05-30T10:10:00+08:00" +} +``` + +## 算法与公式 + +### 风险评分 + +风险评分由模型辅助判断,但必须结构化输出。建议使用可解释加权模型: + +$$ +score = \min(100, base + \sum_{i=1}^{n} w_i x_i + c + e) +$$ + +变量说明: + +- $base$:业务领域基础风险分。预算、发票、付款类通常高于普通提示类。 +- $x_i$:风险因子是否存在或强度,例如金额影响、附件缺失、字段冲突、越权、历史重复。 +- $w_i$:风险因子权重。 +- $c$:复杂度修正,例如多字段交叉、跨单据、跨时间窗口、跨附件识别。 +- $e$:例外说明修正。存在合理说明时可降低,但不能直接清零。 + +等级映射: + +- 0-30:低风险。 +- 31-60:中风险。 +- 61-80:高风险。 +- 81-100:极高风险。 + +### 流程复杂度控制 + +为了避免流程图过大,建议定义流程复杂度: + +$$ +complexity = node_count + 0.5 \times edge_count + branch_count +$$ + +处理规则: + +- `complexity <= 12`:单图展示。 +- `12 < complexity <= 20`:合并说明节点,保留关键判断。 +- `complexity > 20`:详情页展示主流程,测试弹窗展示完整 trace。 + +## 测试方案 + +### 单元测试 + +- 语义计划生成:复杂差旅城市规则不能退化为关键词判断。 +- DSL schema 校验:缺字段、非法算子、空 action 必须失败。 +- 执行器:城市匹配、日期范围、金额阈值、附件缺失、例外说明。 +- 流程转换:同一 DSL 生成稳定的 `flow_model` 和 SVG。 +- 风险评分:低/中/高/极高边界分数。 + +### 接口测试 + +- 新建规则返回生成中资产。 +- 生成完成后包含 `dsl`、`flow_model`、`flow_diagram_svg`、`risk_score`。 +- 仿真测试返回 `recognized_fields`、`normalized_fields`、`trace`。 +- 未测试通过的规则不能发布。 +- 已上线规则创建修订版本,不覆盖线上版本。 + +### 前端测试 + +- 新建弹窗不再选择风险等级。 +- 详情页展示风险分数、流程解释、流程图。 +- 流程图不可点击、不可拖拽、无工具栏。 +- 测试弹窗显示字段识别结果和判断路径。 +- 已上线规则只能创建修订版本修改。 + +### 容器验证 + +后续开发验证默认在 Docker 容器内执行: + +```bash +docker exec x-financial-main sh -lc "cd /app/server && pytest --timeout=60" +docker exec x-financial-main sh -lc "cd /app/web && npm run build" +``` + +## 指标与验收 + +- [A1] 新建复杂差旅规则后,详情页流程解释不能出现“检查是否包含风险关键词”这类错误表达。 +- [A2] 详情页流程图与 JSON DSL 条件数量、分支方向、命中动作一致。 +- [A3] 仿真测试能显示票据识别字段,并说明为什么命中或未命中。 +- [A4] 同一条测试样例的执行 trace 可以高亮流程图路径。 +- [A5] 已上线规则修改时不会改变当前线上执行结果,只有发布修订版本后才替换。 +- [A6] 低、中、高、极高风险都能由评分模型产出,不应默认集中在中高风险。 +- [A7] 前端构建通过,后端定向测试 60s 内完成。 + +## 风险与开放问题 + +- LLM 语义理解仍可能出错,因此必须有 schema 校验、执行器 dry-run、详情解释和仿真测试兜底。 +- 字段本体不完整会限制规则表达,需要持续扩展费用、票据、预算、采购/AP 字段。 +- 复杂规则可能产生过大的流程图,需要主流程和完整 trace 分层展示。 +- 老规则没有 `semantic_plan` 或 `flow_model`,需要兼容展示并允许重新生成。 +- 常见规则模板要避免写成定制逻辑。模板只能提供默认文本、字段和 DSL 样例,最终仍走通用生成链路。 + diff --git a/document/development/risk-rule-explainable-flow/TODO.md b/document/development/risk-rule-explainable-flow/TODO.md new file mode 100644 index 0000000..e399b3d --- /dev/null +++ b/document/development/risk-rule-explainable-flow/TODO.md @@ -0,0 +1,96 @@ +# 风险规则可解释流程判断改造 TODO + +## 使用规则 + +- 每个 TODO 对应 `CONCEPT.md` 的目标、能力或验收点。 +- 只有真实完成并通过相应验证后,才能把 `[ ]` 改成 `[x]`。 +- 如果实现中发现需求变化,先更新 `CONCEPT.md`,再调整本 TODO。 +- 后端和构建验证默认在 Docker 容器 `x-financial-main` 的 `/app` 下执行。 + +## 1. 调研与边界 + +- [ ] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py` 到 `risk_rule_template_executor.py` 的真实调用关系。 +- [ ] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleTestDialog.vue` 的改造点。 +- [ ] [CONCEPT: 数据设计] 确认 `AgentAssetRead`、版本内容、`config_json` 中已有字段,确定 `semantic_plan`、`flow_model`、`flow_diagram_svg` 的落点。 +- [ ] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。 + +## 2. 语义计划与 DSL 契约 + +- [x] [CONCEPT: C2] 定义 `semantic_plan` schema,包含规则意图、适用范围、字段本体映射、判断步骤、例外条件和风险动作。证据:`risk_rule_explainability.py` 产出 `semantic_plan`,`test_risk_rule_explainability.py` 已验证。 +- [x] [CONCEPT: C3] 定义 JSON DSL schema,补齐城市、日期、金额、附件、语义说明等通用算子。证据:`risk_rule_dsl_validator.py` 定义受控 DSL 校验,`risk_rule_generation_interpreter.py` 补充 `numeric_compare`,`risk_rule_template_executor.py` 支持日期、字段集合、附件存在性、文本例外和数值比较算子。 +- [x] [CONCEPT: C3] 增加 DSL validator,禁止复杂字段判断退化为“风险关键词匹配”。证据:`validate_risk_rule_draft` 会将城市一致性关键词规则改写为结构化比较,将预算金额关键词规则改写为 `composite_rule_v1`,`test_risk_rule_dsl_validator.py` 覆盖。 +- [x] [CONCEPT: C3] 为差旅城市不一致、住宿日期越界、预算阈值、重复发票各准备一条 DSL 样例。证据:新增 `risk_rule_dsl_examples.py`,并通过 `test_risk_rule_dsl_examples.py` 覆盖四类样例的 validator 与执行器命中/未命中回归。 +- [x] [CONCEPT: C2] 字段展示统一为 `中文[英文]` 格式,并复用字段本体解释。证据:`risk_rule_explainability.py` 的 `semantic_plan.required_fields.display` 使用字段本体生成 `label[key]`。 + +## 3. Hermes 生成链路 + +- [x] [CONCEPT: 总体链路] 调整 `risk_rule_generation_prompt.py`,要求 Hermes 先输出 `semantic_plan`,再输出 DSL。证据:提示词 `required_json_shape` 改为 `{ semantic_plan, dsl }`,`test_prompt_requires_semantic_plan_then_dsl` 验证。 +- [x] [CONCEPT: C2] 在提示词中明确:城市、日期、金额、票据关系必须用结构化比较,不允许用关键词替代。证据:`risk_rule_generation_prompt.py` 补充 `numeric_compare` 和预算金额不得关键词匹配的 guardrail。 +- [ ] [CONCEPT: 后端设计] 在 `risk_rule_generation_semantics.py` 或解释层补齐语义计划解析与错误返回。进度:已新增 `risk_rule_generation_semantic_plan.py` 解析 `{ semantic_plan, dsl }` 包装,并在生成 payload 的 `metadata.model_semantic_plan` 中保留模型语义计划;错误详情返回仍待补齐。 +- [x] [CONCEPT: 后端设计] 在 `risk_rule_generation_interpreter.py` 中从 `semantic_plan` 生成标准 DSL。证据:新增 `build_dsl_from_semantic_plan`,当 Hermes 仅返回 `semantic_plan` 时生成 `composite_rule_v1` 草稿,再由 DSL validator 基于字段本体规范成受控条件;`test_semantic_plan_only_response_can_generate_standard_dsl` 通过。 +- [x] [CONCEPT: 指标与验收] 增加复杂差旅规则生成测试,确认判断依据不是关键词匹配。证据:`test_generate_complex_travel_route_rule_uses_formula_not_keyword_match` 验证复杂差旅规则生成后为结构化城市一致性规则,且 `condition_summary` 不含“风险关键词”;容器内 `test_risk_rule_generation.py` 通过。 + +## 4. 流程模型与 SVG + +- [x] [CONCEPT: C4] 定义 `flow_model` schema,包含 nodes、edges、字段引用、分支标签和风险节点。证据:`risk_rule_explainability.py` 产出 `flow_model`,生成测试验证 nodes/source/metadata 同步。 +- [x] [CONCEPT: C4] 修改 `risk_rule_flow_diagram.py`,改为从 DSL 或 `flow_model` 生成 SVG。证据:新增 `build_risk_rule_flow_diagram_spec`,优先从 `flow_model.nodes` 生成图形 spec,缺失时回退 `params.conditions`;`test_flow_diagram_spec_prefers_flow_model_nodes` 通过。 +- [x] [CONCEPT: C4] 保持 Style 7 / OpenAI Official 风格:白底、细边框、低饱和、风险节点单点强调。证据:`RiskRuleFlowDiagramRenderer` 输出白底、细边框、低饱和风险色,既有 `test_risk_rule_generation.py` 校验高风险红色、无旧绿色和无阴影滤镜。 +- [x] [CONCEPT: 算法与公式] 实现流程复杂度控制,节点过多时压缩主流程。证据:`_condition_lines_from_flow_nodes` 将超过 4 个判断节点压缩为摘要,`test_flow_diagram_spec_compresses_too_many_decision_nodes` 覆盖。 +- [x] [CONCEPT: C4] 为老规则缺少 `flow_model` 的情况保留默认静态图兜底。证据:`build_risk_rule_flow_diagram_spec` 在 `flow_model` 缺失时使用 DSL/metadata 生成 spec,`test_flow_diagram_spec_falls_back_to_dsl_when_flow_model_missing` 通过。 + +## 5. 执行器 trace 与仿真测试 + +- [x] [CONCEPT: C5] 修改 `RiskRuleTemplateExecutor`,输出每个判断节点的 trace。证据:新增 `evaluate_with_trace`,仿真测试返回 `trace.steps` 和 `path_node_ids`。 +- [ ] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。 +- [ ] [CONCEPT: C5] 测试结果中展示 OCR 原始字段、Hermes 规范化字段、执行器实际输入字段。 +- [x] [CONCEPT: C5] 测试弹窗展示命中路径、未命中原因和最终风险动作。证据:`RiskRuleTestDialog.vue` 展示“执行路径”,`riskRuleTestDialogDisplay.js` 格式化 trace。 +- [x] [CONCEPT: C5] trace 中的 `node_id` 必须能映射到流程图节点。证据:`flow_model` 使用条件 id 作为节点 id,`risk_rule_execution_trace.py` 输出同名 `node_id`。 + +## 6. 规则修改与版本化 + +- [x] [CONCEPT: C6] 未上线规则支持编辑标题、费用领域、附件要求和自然语言描述。证据:新增 `AgentAssetRiskRuleRevisionService.update_unpublished_draft` 与 `PATCH /agent-assets/{asset_id}/risk-rules/draft`,容器内 `test_risk_rule_revision_endpoints.py` 覆盖返回字段。 +- [x] [CONCEPT: C6] 已上线规则新增“创建修订版本”,不直接覆盖 active 版本。证据:新增 `AgentAssetRiskRuleRevisionService.create_revision_draft` 与 `POST /agent-assets/{asset_id}/risk-rules/revisions`,测试验证 `published_version` 保持不变且 `working_version` 进入修订版本。 +- [ ] [CONCEPT: C6] 修订版本保存后重新生成 DSL、流程图、风险评分和业务说明。 +- [ ] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。 +- [ ] [CONCEPT: C6] 普通用户误判/漏判反馈进入规则反馈记录,不直接修改规则。 + +## 7. 常见费控规则模板库 + +- [ ] [CONCEPT: C1] 增加“从常见规则模板创建”入口。 +- [ ] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。 +- [ ] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。 +- [ ] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。 + +## 8. 前端详情与交互 + +- [ ] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。 +- [ ] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。 +- [ ] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。 +- [ ] [CONCEPT: C5] 测试弹窗展示字段识别结果、规范化字段、判断路径和测试报告。 +- [x] [CONCEPT: C6] 已上线规则详情展示“创建修订版本”,草稿规则展示“编辑规则”。证据:`AuditView.vue` 底部动作区按规则状态展示按钮,`AuditRuleDialogs.vue` 提供编辑/修订弹窗,`useAuditRiskRuleActions.js` 调用草稿编辑与修订接口;容器内 `cd /app/web && npm run build` 通过。 +- [ ] [CONCEPT: 指标与验收] 列表和详情状态刷新不能造成页面闪烁。 + +## 9. 后端接口与权限 + +- [x] [CONCEPT: 接口设计] 实现或调整 `POST /agent-assets/{asset_id}/risk-rules/revisions`。证据:新增独立路由 `agent_asset_risk_rules.py`,容器内 `test_create_risk_rule_revision_endpoint_keeps_active_version` 通过。 +- [x] [CONCEPT: 接口设计] 实现或调整 `PATCH /agent-assets/{asset_id}/risk-rules/draft`。证据:新增独立路由 `agent_asset_risk_rules.py`,容器内 `test_update_risk_rule_draft_endpoint_updates_unpublished_rule` 与已上线阻断用例通过。 +- [ ] [CONCEPT: 接口设计] `POST /agent-assets/{asset_id}/risk-rules/regenerate` 返回生成状态和错误详情。 +- [x] [CONCEPT: 接口设计] 仿真测试接口返回 `recognized_fields`、`normalized_fields`、`execution_result`、`trace`。证据:`AgentAssetRiskRuleSimulationRead` 新增 `normalized_fields` 和 `trace`,仿真测试覆盖返回值。 +- [ ] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿,admin 才能删除和测试,管理员按现有权限上线/下线。 +- [ ] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。 + +## 10. 测试与验证 + +- [x] [CONCEPT: 测试方案] 后端补充语义计划、DSL validator、执行器 trace、流程图转换单元测试。证据:`test_risk_rule_explainability.py` 覆盖语义计划、flow_model、trace;`test_risk_rule_dsl_validator.py` 覆盖 DSL validator 与 `numeric_compare` 执行;容器内相关测试通过。 +- [ ] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。进度:已补草稿编辑与创建修订版本服务/接口测试,发布替换接口测试仍待补齐。 +- [ ] [CONCEPT: 测试方案] 前端补充详情页流程展示、测试弹窗字段展示、修订版本按钮状态测试。 +- [x] [CONCEPT: 容器验证] 在容器执行后端定向测试,单个命令设置 60s 超时。证据:`/tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_explainability.py -q`、`test_risk_rule_composite_generation.py -q`、`test_risk_rule_generation.py -q` 均通过。 +- [x] [CONCEPT: 容器验证] 在容器执行 `cd /app/web && npm run build`。证据:容器 `/app/web` 构建通过。 +- [x] [CONCEPT: 指标与验收] 用“武汉到上海票据 + 北京出差 3 天”样例验证城市不一致规则必须命中或给出明确不命中原因。证据:`test_simulation_returns_execution_trace_for_ticket_city_mismatch` 验证命中并返回 trace。 +- [x] [CONCEPT: 指标与验收] 用预算阈值、重复发票、住宿日期越界、招待人均超标样例做回归。证据:`risk_rule_dsl_examples.py` 已包含预算阈值、重复发票、住宿日期越界、招待人均超标样例,`test_risk_rule_dsl_examples.py` 在容器内 7 passed。 + +## 11. 文档收尾 + +- [ ] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。 +- [ ] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。 +- [ ] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。 diff --git a/document/development/risks/travel-risk-control-standard.md b/document/development/risks/travel-risk-control-standard.md deleted file mode 100644 index 0824cbb..0000000 --- a/document/development/risks/travel-risk-control-standard.md +++ /dev/null @@ -1,139 +0,0 @@ -# 差旅报销风险管控标准(模拟版) - -## 1. 目的 - -本标准用于约束个人报销中的差旅类单据审核,覆盖以下三类核心风险: - -- 行程闭环风险:出发地、目的地、返程地之间是否形成合理链路。 -- 票据地点一致性风险:酒店、交通票据与申报目的地是否一致。 -- 差标超限风险:员工职级对应的交通舱位、火车席别、住宿金额是否超标。 - -本标准先以模拟规则落地到系统,用于 AI 验审与直属领导审批前的自动筛查。 - -## 2. 适用范围 - -- 报销主类型为 `travel / hotel / transport` 的单据。 -- 或者明细附件识别出 `flight_itinerary / train_ticket / hotel_invoice / taxi_receipt / parking_toll_receipt` 的单据。 - -## 3. 基础定义 - -### 3.1 目的地 - -按以下优先级确定本次差旅的“主目的地”: - -1. 用户在报销表单中填写的业务地点 `claim.location` -2. 长途交通票据终点城市 -3. 酒店票据识别出的酒店所在城市 - -### 3.2 行程闭环 - -满足以下任一条件,视为形成合理闭环: - -- 单程票据终点与申报目的地一致。 -- 多段票据按时间顺序首尾衔接。 -- 最后一段票据返回首段出发城市。 - -### 3.3 合理例外说明 - -若出现多城市出差、中转、改签、异地返程、展会高峰导致超标等情况,用户必须在报销事由或费用说明中体现原因。示例关键词: - -- `中转` -- `转机` -- `经停` -- `改签` -- `多地出差` -- `客户临时变更` -- `展会高峰` -- `协议酒店满房` -- `无直达` - -未说明时,系统按高风险处理并退回待补充。 - -## 4. 风险规则矩阵 - -### 4.1 行程闭环规则 - -- 若存在两段及以上长途交通票据,相邻两段的 `上一段终点城市` 与 `下一段起点城市` 必须一致。 -- 若最终到达城市既不是申报目的地,也不是首段出发城市,则判定为高风险。 -- 若识别到多个目的地城市,但事由中未说明多地出差、中转或改签原因,则判定为高风险。 - -处理方式: - -- `高风险`:退回待补充。 -- `中风险`:允许流转,但要求直属领导重点复核。 - -### 4.2 酒店地点一致性规则 - -- 酒店票据识别出的城市,必须属于以下集合之一: - - 申报目的地 - - 长途交通票据中的目的地城市 - - 长途交通票据中的返程前停留城市 -- 若酒店城市与主目的地、交通链路均不一致,则判定为高风险。 - -处理方式: - -- `高风险`:退回待补充,要求说明异地住宿原因或更换票据。 - -### 4.3 职级差旅标准 - -#### 4.3.1 城市分级 - -- 一线:`北京 / 上海 / 广州 / 深圳` -- 新一线 / 重点城市:`杭州 / 南京 / 苏州 / 武汉 / 成都 / 重庆 / 西安 / 天津 / 宁波 / 厦门 / 青岛 / 长沙` -- 其他城市:除以上外的默认城市 - -#### 4.3.2 住宿标准(元 / 晚) - -| 职级带 | 一线 | 重点城市 | 其他城市 | -| --- | ---: | ---: | ---: | -| P1-P3 | 450 | 380 | 320 | -| P4-P5 | 550 | 480 | 380 | -| P6-P7 | 700 | 620 | 520 | -| M1-M2 | 900 | 820 | 720 | -| M3 及以上 / D 序列 | 1200 | 1000 | 900 | - -说明: - -- 若票据中能识别出 `X 晚 / X 间夜`,系统按 `总金额 / 间夜数` 计算每晚金额。 -- 若无法识别间夜数,默认按 1 晚处理。 - -#### 4.3.3 交通标准 - -| 职级带 | 飞机 | 火车 / 高铁 | -| --- | --- | --- | -| P1-P3 | 经济舱 | 二等座 / 硬卧 | -| P4-P5 | 经济舱 | 二等座 / 硬卧 | -| P6-P7 | 超级经济舱及以下 | 一等座 / 软卧及以下 | -| M1-M2 | 商务舱及以下 | 商务座及以下 | -| M3 及以上 / D 序列 | 不做系统硬限制,仍保留人工复核 | - -### 4.4 差标超限处理 - -- 超住宿标准且无说明:`高风险` -- 超住宿标准但有说明:`中风险` -- 飞机舱位或高铁席别超过职级标准且无说明:`高风险` -- 飞机舱位或高铁席别超过职级标准但有说明:`中风险` - -## 5. 系统落地口径 - -### 5.1 票据识别字段 - -系统优先使用以下字段做判断: - -- `route` -- `merchant_name` -- `amount` -- `date` -- OCR 原文中的舱位、席别、间夜数、城市名 - -### 5.2 AI 验审动作 - -- 高风险:提交前拦截,状态切回 `待补充` -- 中风险:允许进入直属领导审批,并附带风险标记 -- 低风险 / 通过:正常流转 - -## 6. 当前实现边界 - -- 城市识别先按常见出差城市做匹配,未覆盖全国全部区县。 -- 住宿标准与交通标准为模拟版,可后续迁移到任务规则中心做可配置化。 -- 本文档为当前开发阶段的执行依据,后续若规则中心启用动态配置,应以规则中心版本为准。 diff --git a/document/development/rules/rule-version-center-ui-plan.md b/document/development/rules/rule-version-center-ui-plan.md deleted file mode 100644 index aa2064f..0000000 --- a/document/development/rules/rule-version-center-ui-plan.md +++ /dev/null @@ -1,453 +0,0 @@ -# 规则版本中心 UI 方案 - -## 1. 背景 - -当前“任务规则中心 > 财务规则 > 公司差旅费报销规则”已经具备: - -- 在线 Excel 编辑 -- 工作版本 / 线上版本分离 -- 最近 5 个版本展示 -- 审核、上线、恢复能力 - -但页面仍然存在一个明显问题: -**版本治理能力已经有了,用户却很难第一眼看见。** - -当前版本列表更像“历史记录”,不是一个明确的“版本操作中心”。 -用户无法快速判断: - -1. 当前真正生效的是哪个版本 -2. 当前正在编辑的是哪个版本 -3. 从哪里进入版本切换 -4. 从哪里发起版本对比 -5. 某个版本经历了哪些流转动作 - -因此,需要把现有“版本列表”升级为一个真正可用的 **版本中心**。 - ---- - -## 2. 设计目标 - -### 2.1 用户一眼能看懂 - -进入规则详情页后,用户无需点击就能立即识别: - -- 当前线上版本 -- 当前工作版本 -- 是否存在未上线工作稿 -- 最近版本是否处于待审 / 已通过 / 已驳回状态 - -### 2.2 关键操作显性化 - -以下操作不能再隐藏在不明显的位置: - -- 切换查看版本 -- 与线上版本对比 -- 查看完整流转 -- 从历史版本恢复 - -### 2.3 保持 OnlyOffice 是主角 - -该页面的核心仍然是 Excel 规则表。 -版本中心必须增强治理能力,但不能把主表格压缩成附属内容。 - ---- - -## 3. 推荐方案 - -采用: - -> **左侧 OnlyOffice 主工作区 + 右侧版本中心 + 顶部显性入口 + 抽屉式详情** - -这是比“单独开二级页签”更适合当前页面的方案,因为用户经常需要: - -- 一边看表 -- 一边知道自己看的是什么版本 -- 一边进入版本对比或恢复 - ---- - -## 4. 页面整体布局 - -```text -┌────────────────────────────────────────────────────────────────────┐ -│ 标题区:公司差旅费报销规则 │ -│ 线上版本 v1.0.5 已上线 工作版本 v1.0.6 待审核 │ -│ [下载 Excel] [上传表格] [版本对比] [查看流转] │ -├───────────────────────────────────────────────┬────────────────────┤ -│ │ 版本中心 │ -│ │ │ -│ │ ┌──────────────┐ │ -│ │ │ 线上版本 │ │ -│ │ │ v1.0.5 │ │ -│ │ └──────────────┘ │ -│ OnlyOffice │ ┌──────────────┐ │ -│ 规则表主工作区 │ │ 工作版本 │ │ -│ │ │ v1.0.6 │ │ -│ │ └──────────────┘ │ -│ │ │ -│ │ 最近版本 │ -│ │ v1.0.6 待审核 │ -│ │ v1.0.5 已上线 │ -│ │ v1.0.4 历史版本 │ -│ │ │ -│ │ 最近流转 │ -│ │ [查看完整流转] │ -└───────────────────────────────────────────────┴────────────────────┘ -``` - ---- - -## 5. 顶部操作区设计 - -顶部必须保留并强化四个动作: - -| 按钮 | 用途 | -| --- | --- | -| 下载 Excel | 下载当前预览版本 | -| 上传表格 | 导入内容生成新工作稿 | -| 版本对比 | 打开对比抽屉 | -| 查看流转 | 打开流转抽屉 | - -### 5.1 版本对比按钮 - -这是一级入口,不能只藏在版本列表里。 -默认行为: - -- 基准版本:当前线上版本 -- 对比版本:当前工作版本 - -如果两者相同,则按钮仍可用,但进入后提示: - -> 当前工作版本与线上版本一致,可选择其他历史版本进行比较。 - -### 5.2 查看流转按钮 - -用于进入当前规则的完整生命周期视图。 -不应只展示审计日志,而要展示“版本业务履历”。 - ---- - -## 6. 右侧版本中心设计 - -### 6.1 顶部双版本卡片 - -```text -线上版本 -v1.0.5 -已上线 - -工作版本 -v1.0.6 -待审核 -``` - -#### 设计目的 - -用户进入页面后,最先要知道的是: - -- **谁在线上** -- **谁正在被编辑** - -而不是先看一个无上下文的历史列表。 - -### 6.2 最近版本列表 - -每个版本项包含: - -- 版本号 -- 生命周期状态 -- 创建时间 -- 变更说明 -- 操作入口 - -建议样式: - -```text -v1.0.6 待审核 -2026-05-18 09:12 -补充出差补助标准 -[查看] [与线上比] - -v1.0.5 已上线 -2026-05-18 08:40 -新增补助页签 -[查看] - -v1.0.4 历史版本 -2026-05-17 17:20 -修正住宿标准 -[查看] [恢复] -``` - -#### 规则 - -- `查看`:切换当前预览版本 -- `与线上比`:直接以线上版本为基准进入对比 -- `恢复`:仅高级管理人员可见 -- 当前 `working_version` 不显示“恢复” - -### 6.3 最近流转摘要 - -右侧版本中心底部展示最近 3 条流转: - -```text -最近流转 -09:12 曹笑竹 保存工作稿 -09:25 曹笑竹 提交审核 -10:08 顾承宇 审核通过 -[查看完整流转] -``` - ---- - -## 7. 版本流转时间线设计 - -## 7.1 入口 - -两个入口: - -1. 顶部 `查看流转` -2. 右侧版本中心底部 `查看完整流转` - -## 7.2 容器 - -使用右侧宽抽屉,不使用小弹窗。 -原因: - -- 时间线内容会逐步增长 -- 审核意见需要足够宽度展示 -- 后续可能接入版本说明、操作人、来源版本 - -## 7.3 时间线内容 - -时间线按时间倒序或正序展示,推荐默认正序: - -```text -● 2026-05-18 09:12 - v1.0.6 工作稿创建 - 曹笑竹 保存工作稿 - 变更说明:补充出差补助标准 - -● 2026-05-18 09:25 - 提交审核 - 曹笑竹 提交当前工作版本 - -● 2026-05-18 10:08 - 审核通过 - 顾承宇:口径已核对,可上线 - -○ 待正式上线 -``` - -如果版本来自恢复: - -```text -● 基于 v1.0.3 恢复生成 v1.0.7 -``` - -## 7.4 时间线事件类型 - -| 事件类型 | 说明 | -| --- | --- | -| `created` | 创建版本 | -| `submitted` | 提交审核 | -| `approved` | 审核通过 | -| `rejected` | 驳回 | -| `published` | 正式上线 | -| `restored` | 基于历史版本恢复 | - ---- - -## 8. 版本差异对比设计 - -## 8.1 入口 - -版本对比必须有两个入口: - -1. 顶部一级按钮:`版本对比` -2. 每个历史版本行内操作:`与线上比` - -这样既满足“主动进入”,也满足“看到某个版本就顺手比较”。 - -## 8.2 容器 - -使用宽抽屉,推荐宽度: - -- 桌面:页面宽度的 70% ~ 80% -- 小屏:全屏 - -不建议用普通弹窗,因为: - -- Excel 差异需要足够展示宽度 -- 版本选择器、摘要、表格都要共存 - -## 8.3 顶部区域 - -```text -版本对比 - -基准版本 [v1.0.5 已上线 ▼] -对比版本 [v1.0.6 待审核 ▼] -``` - -默认值: - -- `baseVersion = published_version` -- `targetVersion = working_version` - -## 8.4 差异摘要 - -优先先给决策信息,再给底层明细。 - -```text -差异摘要 -- 修改 2 个工作表 -- 新增 1 个工作表 -- 修改 12 个单元格 -- 删除 2 行 -``` - -如果无差异: - -```text -两个版本内容一致,没有发现表格差异。 -``` - -## 8.5 差异详情 - -第一阶段优先支持 Excel 规则表: - -| 工作表 | 位置 | 旧值 | 新值 | 类型 | -| --- | --- | --- | --- | --- | -| 出差补助标准 | B4 | 75 | 90 | 修改 | -| 差旅住宿费标准 | A106 | - | 新增城市 | 新增 | - -后续可扩展: - -- 仅看新增 -- 仅看删除 -- 仅看数值变化 -- 按工作表筛选 - -## 8.6 对比结果的业务语气 - -不要把页面做成“程序员 diff 工具”。 -它应该像制度审核页面: - -- 先讲影响 -- 再讲位置 -- 最后给证据 - ---- - -## 9. 数据接口设计 - -## 9.1 时间线接口 - -建议新增: - -```http -GET /agent-assets/{asset_id}/version-timeline -``` - -返回: - -- 版本号 -- 事件类型 -- 操作人 -- 操作时间 -- 审核意见 -- 来源版本(如有) - -## 9.2 对比接口 - -建议新增: - -```http -GET /agent-assets/{asset_id}/versions/compare?base_version=v1.0.5&target_version=v1.0.6 -``` - -返回: - -- 基准版本 -- 对比版本 -- 工作表差异摘要 -- 单元格级差异明细 - ---- - -## 10. 视觉规范 - -### 10.1 颜色 - -沿用当前系统已有色彩,不引入新风格: - -| 状态 | 建议色 | -| --- | --- | -| 已上线 | 绿色 | -| 工作稿 | 蓝色 | -| 待审核 | 橙色 | -| 已驳回 | 红色 | -| 历史版本 | 灰色 | - -### 10.2 密度 - -- 右侧版本中心应为紧凑型信息面板 -- 不要使用过大的卡片间距 -- 不能明显压缩 OnlyOffice 主区域 - -### 10.3 交互反馈 - -- 可点击元素必须有 hover -- 当前预览版本必须有 active 高亮 -- 抽屉打开后保留明确关闭按钮 -- 恢复操作必须二次确认 - ---- - -## 11. 推荐实施顺序 - -### 第一阶段 - -1. 顶部新增 `版本对比`、`查看流转` -2. Excel 详情页改成: - - 左侧 OnlyOffice - - 右侧版本中心 -3. 右侧展示: - - 线上版本 - - 工作版本 - - 最近 5 个版本 - - 最近 3 条流转 - -### 第二阶段 - -1. 实现版本流转抽屉 -2. 实现版本对比抽屉 -3. 补齐真实后端接口 - -### 第三阶段 - -1. 增加更细的工作表筛选 -2. 增加更多 diff 维度 -3. 增加版本差异导出能力 - ---- - -## 12. 本次开发目标 - -本次开发直接完成以下内容: - -1. 规则详情页出现明确的版本中心 -2. 页面上出现明确的: - - `版本对比` - - `查看流转` -3. 最近版本列表增加: - - `查看` - - `与线上比` - - `恢复为工作稿` -4. 版本流转抽屉可用 -5. 版本对比抽屉可用 -6. 对比结果至少支持 Excel 表格的: - - 工作表新增 / 删除 - - 单元格新增 / 删除 / 修改 - diff --git a/document/development/rules/rule-version-governance-plan.md b/document/development/rules/rule-version-governance-plan.md deleted file mode 100644 index c196b98..0000000 --- a/document/development/rules/rule-version-governance-plan.md +++ /dev/null @@ -1,237 +0,0 @@ -# 规则版本治理方案 - -## 1. 背景 - -当前“任务规则中心”的规则资产只有一个 `current_version` 指针。 -它同时承担了三种含义: - -1. 财务人员正在编辑的版本 -2. 审核中的候选版本 -3. 系统运行时真正生效的线上版本 - -这会直接带来三个问题: - -- 财务人员一旦修改 Excel,最新内容就会立刻成为 `current_version`,容易被误解为已经正式生效 -- 审核、上线、回滚都围绕同一个指针转,权限边界不清晰 -- 如果误上线,虽然能切换历史版本,但缺少“线上版本”和“工作版本”分离后的安全缓冲 - -## 2. 设计目标 - -这次改造要解决的不是“多存几个历史版本”,而是建立一套可长期使用的规则治理机制: - -1. 财务人员可以编辑规则,但编辑结果默认只是草稿 -2. 只有高级管理人员审核通过后,规则才能成为正式线上版本 -3. 系统运行时只能读取正式线上版本,不能读取草稿 -4. 前台要能清楚区分: - - 当前线上版本 - - 当前工作版本 - - 最近 5 个历史版本 -5. 如果误操作上线,可以快速恢复,但恢复动作仍然要留下完整审计链 - -## 3. 核心模型 - -### 3.1 双指针版本模型 - -在规则资产上新增两个版本指针: - -| 字段 | 含义 | -| --- | --- | -| `published_version` | 当前正式在线上生效的版本 | -| `working_version` | 当前最新的工作稿 / 待审稿 | - -兼容策略: - -- 现有 `current_version` 暂时保留,用于兼容历史代码 -- 对规则资产来说,后续它只承担“当前工作版本”的兼容语义 -- 运行时逻辑不再读取 `current_version`,而是优先读取 `published_version` - -### 3.2 版本状态 - -不额外在版本表中硬存一套容易失真的状态,而是根据“版本指针 + 最新审核记录”动态推导: - -| 条件 | 版本状态 | -| --- | --- | -| `version == published_version` | 已上线 | -| `version == working_version` 且无审核记录 | 草稿 | -| `version == working_version` 且最新审核为 `pending` | 待审核 | -| `version == working_version` 且最新审核为 `approved` | 已通过待上线 | -| `version == working_version` 且最新审核为 `rejected` | 已驳回 | -| 其他历史版本 | 历史版本 | - -这样可以避免“版本状态”和“审核记录”两套数据互相打架。 - -## 4. 权限边界 - -| 角色 | 能力 | -| --- | --- | -| 财务人员 `finance` | 编辑工作稿、上传/导入 Excel、提交审核 | -| 高级管理人员 `manager` / `admin` | 审核通过、驳回、正式发布、恢复历史版本 | -| 其他普通员工 | 只读 | - -### 4.1 财务人员 - -- 可以直接编辑当前 `working_version` -- 每次保存自动生成新版本,并把它设为新的 `working_version` -- 不能把草稿直接变成 `published_version` - -### 4.2 高级管理人员 - -- 可以对 `working_version` 发起: - - 审核通过 - - 驳回 - - 正式发布 -- 只有 `approved` 的工作版本才能发布 - -## 5. 发布与回滚流程 - -### 5.1 正常发布 - -1. 财务人员编辑并保存 -2. 系统生成新版本,例如 `v1.0.6` -3. `working_version = v1.0.6` -4. 财务人员提交审核 -5. 高级管理人员审核通过 -6. 高级管理人员点击“正式上线” -7. `published_version = v1.0.6` -8. 系统运行时切换到新版本 - -### 5.2 驳回 - -1. 财务人员提交审核 -2. 高级管理人员驳回 -3. 当前工作版本保留,但状态显示为“已驳回” -4. 财务人员继续编辑,形成新的工作版本 - -### 5.3 恢复历史版本 - -不直接把 `published_version` 指回旧版本,而是采用“复制恢复”的方式: - -1. 管理员在最近 5 个版本中选择一个历史版本 -2. 系统基于该历史版本内容生成一个新的恢复版本,例如 `v1.0.7` -3. 新版本写入 `working_version` -4. 审核通过后再正式发布 - -这么做的好处: - -- 不会破坏历史链路 -- 每一次恢复都有明确的责任人与时间 -- 既能快速回滚,又保留审计闭环 - -## 6. 版本保留策略 - -### 6.1 前台展示 - -- 详情页固定展示最近 5 个版本 -- 每个版本显示: - - 版本号 - - 状态 - - 创建人 - - 创建时间 - - 变更说明 - -### 6.2 后台保存 - -后台不能机械地“只保留 5 个版本”,否则可能把关键线上版本挤掉。 -建议策略: - -1. 始终保留当前 `published_version` -2. 始终保留当前 `working_version` -3. 额外保留最近 5 个历史版本 - -这样既满足前台简洁,也能避免误删关键版本。 - -## 7. 前端交互 - -### 7.1 规则详情页顶部 - -展示两个醒目的版本标签: - -- 当前线上版本 -- 当前工作版本 - -如果两者不同,需要明确提示: - -> 当前存在尚未上线的工作稿,系统运行仍以线上版本为准。 - -### 7.2 编辑区 - -- 财务人员看到“可编辑工作稿” -- 普通用户只读 -- 管理员可编辑,但主要职责仍是审核与发布 - -### 7.3 版本区 - -最近 5 个版本中每条都显示状态: - -- 已上线 -- 草稿 -- 待审核 -- 已通过待上线 -- 已驳回 -- 历史版本 - -可执行操作: - -- 查看 -- 基于该版本恢复 -- 对当前工作版本提交审核 / 审核 / 发布 - -## 8. 后端改造清单 - -1. `agent_assets` - - 新增 `published_version` - - 新增 `working_version` -2. 兼容旧数据 - - 历史规则资产初始化时: - - `published_version = current_version` - - `working_version = current_version` -3. 版本保存 - - 保存新版本后: - - 只更新 `working_version` - - `current_version` 同步为 `working_version` 以兼容旧逻辑 -4. 审核 - - 审核只针对 `working_version` -5. 发布 - - 只允许把已审核通过的 `working_version` 推到 `published_version` -6. 运行时 - - 只读取 `published_version` -7. 回滚 - - 新增“基于历史版本恢复为新工作稿”的接口 - -## 9. 前端改造清单 - -1. 资产详情模型增加: - - `publishedVersion` - - `workingVersion` - - 每个历史版本的派生状态 -2. 规则详情页展示: - - 当前线上版本 - - 当前工作版本 - - 最近 5 个版本 -3. 操作权限拆分: - - finance:编辑、上传、提交审核 - - manager/admin:审核、上线、恢复 -4. OnlyOffice 编辑逻辑: - - 默认编辑工作版本 - - 历史版本只读 -5. 正式上线按钮: - - 只有工作版本已审核通过时可用 - -## 10. 本次实现边界 - -本轮优先完成以下能力: - -1. 规则版本双指针 -2. 财务角色可编辑工作稿 -3. 正式上线只切换 `published_version` -4. 运行时只读取 `published_version` -5. 最近 5 个版本展示 -6. 基于历史版本快速恢复为新工作稿 - -后续如需要,再继续补: - -- 版本差异对比 -- 审核意见流转面板 -- 发布说明 / 审批单号 -- 定时生效 - diff --git a/document/development/ui/personal-workbench-home-reference.html b/document/development/ui/personal-workbench-home-reference.html deleted file mode 100644 index 634d783..0000000 --- a/document/development/ui/personal-workbench-home-reference.html +++ /dev/null @@ -1,746 +0,0 @@ - - - - - - X-Financial 个人工作台首页参考稿 - - - -
- - -
-
-
- 个人工作台 - 把费用申请、报销处理、进度查询和制度问答集中到一个入口。 -
-
- A - admin -
-
- -
-
-
- -
- -
-

嗨,admin,描述您想做的事,AI 会直接帮您处理

-

- 我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作, - 并把事情推进到可执行的下一步。 -

- -
-
例如:帮我查一下上周提交的差旅报销到哪一步了
- - -
-
-
- -
-
-
- - -
-

费用申请

-

发起招待、差旅、采购等费用事项

-
- -
-
- - -
-

报销处理

-

上传票据,生成草稿并核对材料

-
- -
-
- - -
-

进度查询

-

查询单据状态、审批节点和到账情况

-
- -
-
- - -
-

制度问答

-

咨询标准、附件要求和可报销边界

-
-
- -
-
-
-

报销待办

- 查看全部 -
-
-
- -
- 业务招待报销建议补参与人员 - AI 建议:补充客户单位、客户人数、我方陪同人员 -
- 去补充 -
-
- -
- 差旅报销单待提交 - 补齐出发交通,可直接生成报销单 -
- 继续填 -
-
- -
- 有 5 张票据未关联报销单 - 其中 3 张疑似交通费,可合并生成交通报销 -
- 去整理 -
-
-
- -
-
-

报销进度

- 查看全部 -
-
-
- -
- 差旅报销 - 提交时间:2026-05-03 -
-
¥3,280主管审批中
-
-
- -
- 交通报销 - 提交时间:2026-05-02 -
-
¥126财务复核中
-
-
- -
- 办公采购 - 提交时间:2026-05-01 -
-
¥458已到账
-
-
-
-
- -
-
-
-

智能概览

- 本月 -
-
-
12待处理事项
-
86%材料完整率
-
2.4天平均审批时长
-
-
- -
-
-

最新报销制度

- 查看全部 -
-
-
- -
- 差旅报销管理办法(2026版) - 更新住宿标准与交通等级规则 -
- 查看 -
-
-
-
-
-
-
- - diff --git a/document/development/ui/personal-workbench-home-reference.png b/document/development/ui/personal-workbench-home-reference.png deleted file mode 100644 index 7621545..0000000 Binary files a/document/development/ui/personal-workbench-home-reference.png and /dev/null differ diff --git a/server/rules/risk-rules/risk.application.marketing_without_campaign.json b/server/rules/risk-rules/risk.application.marketing_without_campaign.json new file mode 100644 index 0000000..09be94d --- /dev/null +++ b/server/rules/risk-rules/risk.application.marketing_without_campaign.json @@ -0,0 +1,203 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.application.marketing_without_campaign", + "name": "市场推广费无活动申请", + "description": "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "expense_control_demo", + "risk_category": "申请前置", + "ontology_signal": "application_required", + "evaluator": "template_rule", + "template_key": "keyword_match_v1", + "finance_rule_code": "expense.application.policy", + "finance_rule_sheet": "费用申请前置规则", + "business_stage": [ + "reimbursement" + ], + "expense_types": [ + "marketing" + ], + "budget_required": true, + "applies_to": { + "domains": [ + "expense" + ], + "expense_types": [ + "marketing" + ], + "business_stages": [ + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.amount", + "label": "报销金额", + "type": "number", + "source": "claim" + }, + { + "key": "claim.expense_type", + "label": "费用类型", + "type": "enum", + "source": "claim" + }, + { + "key": "application.id", + "label": "申请单", + "type": "text", + "source": "application" + }, + { + "key": "material.plan_uploaded", + "label": "方案已上传", + "type": "boolean", + "source": "material" + } + ] + }, + "params": { + "template_key": "keyword_match_v1", + "field_keys": [ + "claim.amount", + "claim.expense_type", + "claim.department_name", + "claim.reason", + "item.item_reason", + "application.id", + "application.status", + "application.approved_amount", + "application.expense_type", + "application.department_name", + "material.plan_uploaded" + ], + "search_fields": [ + "claim.reason", + "item.item_reason", + "claim.expense_type" + ], + "keywords": [ + "市场推广", + "活动申请", + "投放方案" + ], + "condition_summary": "市场推广费报销缺少活动申请或方案时触发。", + "finance_rule_code": "expense.application.policy", + "finance_rule_sheet": "费用申请前置规则", + "business_stage": [ + "reimbursement" + ], + "expense_types": [ + "marketing" + ], + "budget_required": true + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 50 + } + }, + "metadata": { + "owner": "风控与审计部", + "stability": "platform", + "source_ref": "费用管控 Demo 风险规则库", + "created_at": "2026-05-30T00:00:00Z", + "created_by": "system", + "risk_score": 50, + "risk_level": "medium", + "rule_title": "市场推广费无活动申请", + "finance_rule_code": "expense.application.policy", + "finance_rule_sheet": "费用申请前置规则", + "business_stage": [ + "reimbursement" + ], + "expense_types": [ + "marketing" + ], + "budget_required": true, + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 50, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "weights": { + "impact": 0.35, + "certainty": 0.25, + "evidence": 0.15, + "exception": 0.1, + "action": 0.1, + "sensitivity": 0.05 + }, + "components": { + "impact": 48, + "certainty": 58, + "evidence": 62, + "exception": 35, + "action": 35, + "sensitivity": 45 + }, + "calibration": { + "raw_score": 50, + "rules": [] + }, + "ai_evidence": {}, + "basis": { + "template_key": "keyword_match_v1", + "field_count": 11, + "condition_count": 0, + "expense_category": null, + "expense_category_label": "申请前置", + "requires_attachment": false + } + } + }, + "severity": "medium", + "risk_score": 50, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 50, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "weights": { + "impact": 0.35, + "certainty": 0.25, + "evidence": 0.15, + "exception": 0.1, + "action": 0.1, + "sensitivity": 0.05 + }, + "components": { + "impact": 48, + "certainty": 58, + "evidence": 62, + "exception": 35, + "action": 35, + "sensitivity": 45 + }, + "calibration": { + "raw_score": 50, + "rules": [] + }, + "ai_evidence": {}, + "basis": { + "template_key": "keyword_match_v1", + "field_count": 11, + "condition_count": 0, + "expense_category": null, + "expense_category_label": "申请前置", + "requires_attachment": false + } + } +} diff --git a/server/rules/risk-rules/risk.budget.available_balance_insufficient.json b/server/rules/risk-rules/risk.budget.available_balance_insufficient.json new file mode 100644 index 0000000..0e4f2e4 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.available_balance_insufficient.json @@ -0,0 +1,179 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.budget.available_balance_insufficient", + "name": "预算可用余额不足", + "description": "提交后预算余额为负,或当前可用预算不足以覆盖本次申请/报销金额。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "expense_control_demo", + "risk_category": "预算管控", + "ontology_signal": "budget_over_limit", + "evaluator": "template_rule", + "template_key": "keyword_match_v1", + "finance_rule_code": "budget.execution.policy", + "finance_rule_sheet": "预算执行规则", + "business_stage": [ + "expense_application", + "reimbursement", + "budget_execution" + ], + "expense_types": [ + "travel", + "hotel", + "transport", + "meal", + "meeting", + "marketing", + "office", + "training", + "software", + "communication", + "welfare" + ], + "budget_required": true, + "applies_to": { + "domains": [ + "expense" + ], + "expense_types": [ + "travel", + "hotel", + "transport", + "meal", + "meeting", + "marketing", + "office", + "training", + "software", + "communication", + "welfare" + ], + "business_stages": [ + "expense_application", + "reimbursement", + "budget_execution" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.amount", + "label": "报销金额", + "type": "number", + "source": "claim" + }, + { + "key": "claim.expense_type", + "label": "费用类型", + "type": "enum", + "source": "claim" + }, + { + "key": "budget.available_amount", + "label": "预算可用金额", + "type": "number", + "source": "budget" + }, + { + "key": "budget.status", + "label": "预算状态", + "type": "enum", + "source": "budget" + } + ] + }, + "params": { + "template_key": "keyword_match_v1", + "field_keys": [ + "claim.amount", + "claim.expense_type", + "claim.department_name", + "claim.reason", + "item.item_reason", + "budget.line_id", + "budget.available_amount", + "budget.used_rate", + "budget.status", + "budget.department_name", + "budget.quarter", + "budget.project_code" + ], + "search_fields": [ + "claim.reason", + "item.item_reason", + "claim.expense_type" + ], + "keywords": [ + "预算不足", + "可用余额不足", + "超预算" + ], + "condition_summary": "预算可用金额小于本次金额时触发。", + "finance_rule_code": "budget.execution.policy", + "finance_rule_sheet": "预算执行规则", + "business_stage": [ + "expense_application", + "reimbursement", + "budget_execution" + ], + "expense_types": [ + "travel", + "hotel", + "transport", + "meal", + "meeting", + "marketing", + "office", + "training", + "software", + "communication", + "welfare" + ], + "budget_required": true + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "manual_review", + "risk_score": 88 + } + }, + "metadata": { + "owner": "风控与审计部", + "stability": "platform", + "source_ref": "费用管控 Demo 风险规则库", + "created_at": "2026-05-30T00:00:00Z", + "created_by": "system", + "risk_score": 88, + "risk_level": "high", + "rule_title": "预算可用余额不足", + "finance_rule_code": "budget.execution.policy", + "finance_rule_sheet": "预算执行规则", + "business_stage": [ + "expense_application", + "reimbursement", + "budget_execution" + ], + "expense_types": [ + "travel", + "hotel", + "transport", + "meal", + "meeting", + "marketing", + "office", + "training", + "software", + "communication", + "welfare" + ], + "budget_required": true + }, + "severity": "high", + "risk_score": 88, + "risk_level": "high" +} diff --git a/server/src/app/algorithem/__init__.py b/server/src/app/algorithem/__init__.py index 8e8a997..eefd50c 100644 --- a/server/src/app/algorithem/__init__.py +++ b/server/src/app/algorithem/__init__.py @@ -18,21 +18,45 @@ from .employee_behavior_profile import ( score_by_bands, ) from .employee_behavior_profile_tags import build_profile_radar, build_profile_tags +from .risk_graph import ( + ALGORITHM_VERSION as FINANCIAL_RISK_GRAPH_ALGORITHM_VERSION, + RiskGraphClaimItemSnapshot, + RiskGraphClaimSnapshot, + RiskGraphEvaluationContext, + RiskGraphEvaluationResult, + RiskHistoryStats, + RiskObservationDraft, + evaluate_financial_risk_graph, + map_ontology_to_risk_graph, + normalize_risk_signal, + normalize_risk_signals, +) __all__ = [ "ApplicantExpenseProfileInput", "ApplicantExpenseProfileResult", "EMPLOYEE_BEHAVIOR_PROFILE_ALGORITHM_VERSION", + "FINANCIAL_RISK_GRAPH_ALGORITHM_VERSION", "ProfileComponent", "ProfileScoreResult", + "RiskGraphClaimItemSnapshot", + "RiskGraphClaimSnapshot", + "RiskGraphEvaluationContext", + "RiskGraphEvaluationResult", + "RiskHistoryStats", + "RiskObservationDraft", "build_review_suggestions", "build_profile_radar", "build_profile_tags", "calculate_review_priority_score", "evaluate_applicant_expense_profile", + "evaluate_financial_risk_graph", "evaluate_weighted_profile", + "map_ontology_to_risk_graph", "employee_profile_level_from_score", "normalize_by_peer_percentiles", + "normalize_risk_signal", + "normalize_risk_signals", "percentile", "score_by_bands", ] diff --git a/server/src/app/algorithem/risk_graph/__init__.py b/server/src/app/algorithem/risk_graph/__init__.py new file mode 100644 index 0000000..f4a2f2b --- /dev/null +++ b/server/src/app/algorithem/risk_graph/__init__.py @@ -0,0 +1,33 @@ +"""Financial behavior graph risk engine.""" + +from .engine import evaluate_financial_risk_graph +from .models import ( + ALGORITHM_VERSION, + RiskGraphClaimItemSnapshot, + RiskGraphClaimSnapshot, + RiskGraphEvaluationContext, + RiskGraphEvaluationResult, + RiskHistoryStats, + RiskObservationDraft, +) +from .ontology import OntologyRiskGraphMapping, map_ontology_to_risk_graph +from .profile_baselines import ProfileBaselineSnapshot, ProfileBaselineUpdater +from .signals import NormalizedRiskSignal, normalize_risk_signal, normalize_risk_signals + +__all__ = [ + "ALGORITHM_VERSION", + "NormalizedRiskSignal", + "OntologyRiskGraphMapping", + "RiskGraphClaimItemSnapshot", + "RiskGraphClaimSnapshot", + "RiskGraphEvaluationContext", + "RiskGraphEvaluationResult", + "RiskHistoryStats", + "RiskObservationDraft", + "ProfileBaselineSnapshot", + "ProfileBaselineUpdater", + "evaluate_financial_risk_graph", + "map_ontology_to_risk_graph", + "normalize_risk_signal", + "normalize_risk_signals", +] diff --git a/server/src/app/algorithem/risk_graph/anomaly_models.py b/server/src/app/algorithem/risk_graph/anomaly_models.py new file mode 100644 index 0000000..266d283 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/anomaly_models.py @@ -0,0 +1,175 @@ +"""Deterministic multi-model anomaly detection for risk graph features.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from statistics import median +from typing import Any + +ZERO = Decimal("0") + + +@dataclass(frozen=True, slots=True) +class AnomalyPoint: + key: str + amount: Decimal + occurred_at: datetime | None = None + segment: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class AnomalyModelSignal: + method: str + score: int + reason: str + related_keys: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "method": self.method, + "score": self.score, + "reason": self.reason, + "related_keys": list(self.related_keys), + } + + +class MultiModelAnomalyDetector: + def detect( + self, + points: list[AnomalyPoint], + *, + target_key: str, + ) -> list[AnomalyModelSignal]: + target = next((point for point in points if point.key == target_key), None) + if target is None: + return [] + peers = [ + point + for point in points + if point.key != target.key and (not target.segment or point.segment == target.segment) + ] + if len(peers) < 3: + return [] + + signals = [ + self._robust_statistical_signal(target, peers), + self._isolation_proxy_signal(target, peers), + self._local_outlier_signal(target, peers), + self._temporal_jump_signal(target, peers), + self._periodic_deviation_signal(target, peers), + ] + return [signal for signal in signals if signal is not None] + + def _robust_statistical_signal( + self, + target: AnomalyPoint, + peers: list[AnomalyPoint], + ) -> AnomalyModelSignal | None: + values = [point.amount for point in peers if point.amount >= ZERO] + if len(values) < 3: + return None + center = Decimal(str(median(values))) + deviations = [abs(value - center) for value in values] + mad = Decimal(str(median(deviations))) or Decimal("1") + modified_z = abs(target.amount - center) / mad + if modified_z < Decimal("3"): + return None + return AnomalyModelSignal( + method="robust_statistics", + score=min(100, int(modified_z * Decimal("18"))), + reason="Target amount deviates from peer median by robust MAD.", + related_keys=[point.key for point in peers], + ) + + def _isolation_proxy_signal( + self, + target: AnomalyPoint, + peers: list[AnomalyPoint], + ) -> AnomalyModelSignal | None: + values = sorted(point.amount for point in peers) + if target.amount <= values[-1] * Decimal("1.8"): + return None + return AnomalyModelSignal( + method="isolation_forest_proxy", + score=min(100, int((target.amount / max(values[-1], Decimal("1"))) * Decimal("45"))), + reason="Target amount is isolated beyond the peer maximum envelope.", + related_keys=[point.key for point in peers[-5:]], + ) + + def _local_outlier_signal( + self, + target: AnomalyPoint, + peers: list[AnomalyPoint], + ) -> AnomalyModelSignal | None: + distances = sorted((abs(target.amount - point.amount), point.key) for point in peers) + nearest = distances[: min(3, len(distances))] + peer_distances = [ + abs(left.amount - right.amount) + for index, left in enumerate(peers) + for right in peers[index + 1 :] + ] + local_scale = Decimal(str(median(peer_distances))) if peer_distances else Decimal("1") + local_scale = max(local_scale, Decimal("1")) + target_distance = sum((distance for distance, _ in nearest), ZERO) / Decimal(len(nearest)) + ratio = target_distance / local_scale + if ratio < Decimal("2.5"): + return None + return AnomalyModelSignal( + method="local_outlier_factor_proxy", + score=min(100, int(ratio * Decimal("24"))), + reason="Target is far away from its nearest peer neighborhood.", + related_keys=[key for _, key in nearest], + ) + + def _temporal_jump_signal( + self, + target: AnomalyPoint, + peers: list[AnomalyPoint], + ) -> AnomalyModelSignal | None: + if target.occurred_at is None: + return None + previous = [ + point + for point in peers + if point.occurred_at is not None and point.occurred_at < target.occurred_at + ] + previous = sorted(previous, key=lambda item: item.occurred_at or datetime.min)[-3:] + if len(previous) < 3: + return None + average = sum((point.amount for point in previous), ZERO) / Decimal(len(previous)) + if average <= ZERO or target.amount < average * Decimal("2.2"): + return None + return AnomalyModelSignal( + method="temporal_jump", + score=min(100, int((target.amount / average) * Decimal("32"))), + reason="Target amount jumps above the recent moving average.", + related_keys=[point.key for point in previous], + ) + + def _periodic_deviation_signal( + self, + target: AnomalyPoint, + peers: list[AnomalyPoint], + ) -> AnomalyModelSignal | None: + if target.occurred_at is None: + return None + same_period = [ + point + for point in peers + if point.occurred_at is not None + and point.occurred_at.weekday() == target.occurred_at.weekday() + ] + if len(same_period) < 2: + return None + average = sum((point.amount for point in same_period), ZERO) / Decimal(len(same_period)) + if average <= ZERO or target.amount < average * Decimal("2"): + return None + return AnomalyModelSignal( + method="periodic_deviation", + score=min(100, int((target.amount / average) * Decimal("30"))), + reason="Target deviates from same-weekday periodic peer behavior.", + related_keys=[point.key for point in same_period], + ) diff --git a/server/src/app/algorithem/risk_graph/consistency.py b/server/src/app/algorithem/risk_graph/consistency.py new file mode 100644 index 0000000..2ca53c3 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/consistency.py @@ -0,0 +1,183 @@ +"""Multi-evidence and spatiotemporal consistency checks.""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from .models import RiskEvidence, RiskGraphClaimSnapshot +from .signals import NormalizedRiskSignal, normalize_risk_signals + +ZERO = Decimal("0") + + +def evaluate_claim_consistency( + claim: RiskGraphClaimSnapshot, +) -> tuple[list[RiskEvidence], list[NormalizedRiskSignal]]: + evidence: list[RiskEvidence] = [] + signals: list[NormalizedRiskSignal] = [] + + if _has_location_mismatch(claim): + evidence.append( + RiskEvidence( + code="location_mismatch_graph", + title="Location mismatch graph", + detail="Claim location and item location are not aligned.", + source="spatiotemporal", + score=64, + ) + ) + signals.extend(normalize_risk_signals(["location_mismatch"], source="spatiotemporal")) + + amount_mismatch = _document_amount_mismatch(claim) + if amount_mismatch: + evidence.append( + RiskEvidence( + code="document_amount_mismatch", + title="Document amount mismatch", + detail="Claim amount and item amount sum are not aligned.", + source="multi_evidence", + score=72, + metadata=amount_mismatch, + ) + ) + signals.extend( + normalize_risk_signals( + [{"risk_signal": "document_expense_mismatch", "score": 72}], + source="multi_evidence", + ) + ) + + invoice_count_mismatch = _invoice_count_mismatch(claim) + if invoice_count_mismatch: + evidence.append( + RiskEvidence( + code="invoice_count_mismatch", + title="Invoice count mismatch", + detail="Declared invoice count and attached invoice count are not aligned.", + source="multi_evidence", + score=62, + metadata=invoice_count_mismatch, + ) + ) + signals.extend( + normalize_risk_signals( + [{"risk_signal": "document_expense_mismatch", "score": 62}], + source="multi_evidence", + ) + ) + + date_mismatch = _item_date_outside_claim_window(claim) + if date_mismatch: + evidence.append( + RiskEvidence( + code="date_outside_claim_window", + title="Date outside claim window", + detail="Item date is too far away from the claim occurrence date.", + source="spatiotemporal", + score=78, + metadata=date_mismatch, + ) + ) + signals.extend(normalize_risk_signals(["date_outside_trip"], source="spatiotemporal")) + + return evidence, signals + + +def _has_location_mismatch(claim: RiskGraphClaimSnapshot) -> bool: + claim_location = _canonical_key(claim.location) + if not claim_location or not claim.items: + return False + item_locations = { + _canonical_key(item.item_location) + for item in claim.items + if str(item.item_location or "").strip() + } + if not item_locations: + return False + return any(location and location != claim_location for location in item_locations) + + +def _document_amount_mismatch(claim: RiskGraphClaimSnapshot) -> dict[str, str] | None: + if not claim.items: + return None + claim_amount = _to_decimal(claim.amount) + item_amount_sum = sum((_to_decimal(item.item_amount) for item in claim.items), ZERO) + if claim_amount <= ZERO or item_amount_sum <= ZERO: + return None + difference = abs(claim_amount - item_amount_sum) + tolerance = max(Decimal("1"), claim_amount * Decimal("0.02")) + if difference <= tolerance: + return None + return { + "claim_amount": str(claim_amount), + "item_amount_sum": str(item_amount_sum), + "difference": str(difference), + "tolerance": str(tolerance), + } + + +def _invoice_count_mismatch(claim: RiskGraphClaimSnapshot) -> dict[str, Any] | None: + declared_count = int(claim.invoice_count or 0) + if declared_count <= 0: + return None + invoice_ids = sorted( + { + str(item.invoice_id or "").strip() + for item in claim.items + if str(item.invoice_id or "").strip() + } + ) + actual_count = len(invoice_ids) + if declared_count == actual_count: + return None + return { + "declared_invoice_count": declared_count, + "actual_invoice_count": actual_count, + "invoice_ids": invoice_ids, + } + + +def _item_date_outside_claim_window(claim: RiskGraphClaimSnapshot) -> dict[str, Any] | None: + occurred_date = _date_from_value(claim.occurred_at) + if occurred_date is None or not claim.items: + return None + mismatches: list[dict[str, Any]] = [] + for item in claim.items: + item_date = _date_from_value(item.item_date) + if item_date is None: + continue + distance_days = abs((item_date - occurred_date).days) + if distance_days <= 7: + continue + mismatches.append( + { + "item_id": item.item_id, + "item_date": item_date.isoformat(), + "occurred_at": occurred_date.isoformat(), + "distance_days": distance_days, + } + ) + return {"mismatches": mismatches} if mismatches else None + + +def _date_from_value(value: Any) -> date | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return None + + +def _canonical_key(value: Any) -> str: + return "_".join(str(value or "").strip().lower().split()) + + +def _to_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return ZERO diff --git a/server/src/app/algorithem/risk_graph/control_effect.py b/server/src/app/algorithem/risk_graph/control_effect.py new file mode 100644 index 0000000..81662ff --- /dev/null +++ b/server/src/app/algorithem/risk_graph/control_effect.py @@ -0,0 +1,77 @@ +"""Control effect analysis for risk rules, sampling, and digital employees.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +HIGH_LEVELS = {"high", "critical"} + + +@dataclass(slots=True) +class ControlEffectSummary: + before_count: int + after_count: int + risk_count_delta: int + average_score_delta: float + high_rate_delta: float + confirmation_rate_delta: float + false_positive_rate_delta: float + + def as_dict(self) -> dict[str, Any]: + return { + "before_count": self.before_count, + "after_count": self.after_count, + "risk_count_delta": self.risk_count_delta, + "average_score_delta": self.average_score_delta, + "high_rate_delta": self.high_rate_delta, + "confirmation_rate_delta": self.confirmation_rate_delta, + "false_positive_rate_delta": self.false_positive_rate_delta, + } + + +class ControlEffectAnalyzer: + def compare( + self, + before: list[dict[str, Any]], + after: list[dict[str, Any]], + ) -> ControlEffectSummary: + before_metrics = _metrics(before) + after_metrics = _metrics(after) + return ControlEffectSummary( + before_count=before_metrics["count"], + after_count=after_metrics["count"], + risk_count_delta=after_metrics["count"] - before_metrics["count"], + average_score_delta=round(after_metrics["average_score"] - before_metrics["average_score"], 4), + high_rate_delta=round(after_metrics["high_rate"] - before_metrics["high_rate"], 4), + confirmation_rate_delta=round( + after_metrics["confirmation_rate"] - before_metrics["confirmation_rate"], + 4, + ), + false_positive_rate_delta=round( + after_metrics["false_positive_rate"] - before_metrics["false_positive_rate"], + 4, + ), + ) + + +def _metrics(items: list[dict[str, Any]]) -> dict[str, Any]: + count = len(items) + if count == 0: + return { + "count": 0, + "average_score": 0.0, + "high_rate": 0.0, + "confirmation_rate": 0.0, + "false_positive_rate": 0.0, + } + confirmed = sum(1 for item in items if item.get("feedback_status") == "confirmed") + false_positive = sum(1 for item in items if item.get("feedback_status") == "false_positive") + reviewed = confirmed + false_positive + return { + "count": count, + "average_score": sum(int(item.get("risk_score") or 0) for item in items) / count, + "high_rate": sum(1 for item in items if item.get("risk_level") in HIGH_LEVELS) / count, + "confirmation_rate": confirmed / reviewed if reviewed else 0.0, + "false_positive_rate": false_positive / reviewed if reviewed else 0.0, + } diff --git a/server/src/app/algorithem/risk_graph/counterfactual.py b/server/src/app/algorithem/risk_graph/counterfactual.py new file mode 100644 index 0000000..eab33e4 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/counterfactual.py @@ -0,0 +1,82 @@ +"""Counterfactual recommendations for reducing financial risk scores.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(slots=True) +class CounterfactualRiskAction: + action_key: str + title: str + detail: str + related_feature: str + expected_score_delta: int + + def as_dict(self) -> dict[str, Any]: + return { + "action_key": self.action_key, + "title": self.title, + "detail": self.detail, + "related_feature": self.related_feature, + "expected_score_delta": self.expected_score_delta, + } + + +class CounterfactualRiskAdvisor: + def advise(self, observation: dict[str, Any]) -> list[CounterfactualRiskAction]: + scores = dict( + observation.get("contribution_scores") + or observation.get("decision_trace", {}).get("input_scores") + or {} + ) + evidence_codes = { + str(item.get("code") or "") + for item in observation.get("evidence", []) + if isinstance(item, dict) + } + trace = observation.get("decision_trace") or {} + actions: list[CounterfactualRiskAction] = [] + + if int(scores.get("S_rule") or 0) >= 70: + actions.append( + CounterfactualRiskAction( + action_key="complete_preapproval_or_required_attachment", + title="Complete required approval evidence", + detail="补齐事前申请、审批记录或制度要求的附件,可降低规则命中风险。", + related_feature="S_rule", + expected_score_delta=-20, + ) + ) + if int(scores.get("S_anomaly") or 0) >= 70: + actions.append( + CounterfactualRiskAction( + action_key="align_amount_with_peer_baseline", + title="Align amount with peer baseline", + detail="补充高金额原因或拆出不属于本次报销的费用,可降低基线偏离风险。", + related_feature="S_anomaly", + expected_score_delta=-18, + ) + ) + if int(scores.get("S_graph") or 0) >= 70 or "duplicate_invoice_graph" in evidence_codes: + actions.append( + CounterfactualRiskAction( + action_key="replace_duplicate_or_conflicting_invoice", + title="Replace conflicting invoice", + detail="替换重复票据、修正票据归属或说明跨单据复用原因,可降低图谱异常风险。", + related_feature="S_graph", + expected_score_delta=-25, + ) + ) + if trace.get("data_quality_gate") not in {"", "passed", None}: + actions.append( + CounterfactualRiskAction( + action_key="supplement_missing_risk_data", + title="Supplement missing risk data", + detail="补齐员工、金额、费用类型、票据明细等关键字段后再进入强风控判断。", + related_feature="data_quality", + expected_score_delta=-10, + ) + ) + return actions diff --git a/server/src/app/algorithem/risk_graph/decisioning.py b/server/src/app/algorithem/risk_graph/decisioning.py new file mode 100644 index 0000000..3ec77f7 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/decisioning.py @@ -0,0 +1,132 @@ +"""Decision trace and explanation helpers for risk graph observations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from .models import PeerBaseline, RiskEvidence + +RISK_SCORE_FORMULA = ( + "0.35*S_rule + 0.25*S_anomaly + " + "0.20*S_graph + 0.15*S_policy + 0.05*S_history" +) + + +@dataclass(slots=True) +class DecisionTrace: + formula: str + algorithm_version: str + input_scores: dict[str, int] + output_score: int + decision_row: str + feature_contributions_json: list[dict[str, Any]] + uncertainty_reasons_json: list[str] + explanation_template_key: str + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "formula": self.formula, + "algorithm_version": self.algorithm_version, + "input_scores": dict(self.input_scores), + "output_score": self.output_score, + "decision_row": self.decision_row, + "feature_contributions_json": list(self.feature_contributions_json), + "uncertainty_reasons_json": list(self.uncertainty_reasons_json), + "explanation_template_key": self.explanation_template_key, + **self.metadata, + } + + +class DecisionTraceBuilder: + def build( + self, + *, + algorithm_version: str, + risk_signal: str, + risk_level: str, + raw_risk_score: int, + risk_score: int, + contribution_scores: dict[str, int], + evidence: list[RiskEvidence], + baseline: PeerBaseline, + confidence: Decimal, + metadata: dict[str, Any], + ) -> DecisionTrace: + return DecisionTrace( + formula=RISK_SCORE_FORMULA, + algorithm_version=algorithm_version, + input_scores=contribution_scores, + output_score=risk_score, + decision_row=_decision_row(risk_score=risk_score, risk_level=risk_level), + feature_contributions_json=_feature_contributions(contribution_scores), + uncertainty_reasons_json=_uncertainty_reasons( + raw_risk_score=raw_risk_score, + risk_score=risk_score, + evidence=evidence, + baseline=baseline, + confidence=confidence, + metadata=metadata, + ), + explanation_template_key=f"risk.{risk_signal}.{risk_level}", + metadata=metadata, + ) + + +def _decision_row(*, risk_score: int, risk_level: str) -> str: + if risk_score >= 90: + return f"{risk_level}:score>=90" + if risk_score >= 70: + return f"{risk_level}:70<=score<90" + if risk_score >= 45: + return f"{risk_level}:45<=score<70" + return f"{risk_level}:score<45" + + +def _feature_contributions(scores: dict[str, int]) -> list[dict[str, Any]]: + weights = { + "S_rule": Decimal("0.35"), + "S_anomaly": Decimal("0.25"), + "S_graph": Decimal("0.20"), + "S_policy": Decimal("0.15"), + "S_history": Decimal("0.05"), + } + rows = [] + for key, score in scores.items(): + weighted_score = Decimal(int(score or 0)) * weights.get(key, Decimal("0")) + rows.append( + { + "feature": key, + "score": int(score or 0), + "weight": str(weights.get(key, Decimal("0"))), + "weighted_score": float(weighted_score), + } + ) + return sorted(rows, key=lambda item: item["weighted_score"], reverse=True) + + +def _uncertainty_reasons( + *, + raw_risk_score: int, + risk_score: int, + evidence: list[RiskEvidence], + baseline: PeerBaseline, + confidence: Decimal, + metadata: dict[str, Any], +) -> list[str]: + reasons: list[str] = [] + if risk_score < raw_risk_score: + reasons.append("score_capped_by_gate") + if baseline.scope == "insufficient_sample" or baseline.sample_size <= 0: + reasons.append("peer_baseline_insufficient") + if confidence < Decimal("0.55"): + reasons.append("low_confidence") + if len({item.source for item in evidence if item.source}) < 2: + reasons.append("single_evidence_source") + if metadata.get("ontology_gate") == "candidate_only": + reasons.append("ontology_candidate_only") + if metadata.get("data_quality_gate") not in {"", "passed", None}: + reasons.append("data_quality_gate_not_passed") + return list(dict.fromkeys(reasons)) diff --git a/server/src/app/algorithem/risk_graph/engine.py b/server/src/app/algorithem/risk_graph/engine.py new file mode 100644 index 0000000..302e482 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/engine.py @@ -0,0 +1,794 @@ +"""Financial behavior graph risk scoring engine.""" + +from __future__ import annotations + +from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal +from typing import Any + +from .consistency import evaluate_claim_consistency +from .decisioning import DecisionTraceBuilder +from .graph import build_claim_graph, claim_node_key, employee_node_key +from .models import ( + ALGORITHM_VERSION, + AUTOMATION_ASSIST, + AUTOMATION_AUTO_HOLD, + AUTOMATION_MANUAL_REVIEW, + AUTOMATION_SEMI_AUTO_REVIEW, + LEVEL_CRITICAL, + LEVEL_HIGH, + LEVEL_LOW, + LEVEL_MEDIUM, + PeerBaseline, + RiskEvidence, + RiskGraphClaimSnapshot, + RiskGraphEdge, + RiskGraphEvaluationContext, + RiskGraphEvaluationResult, + RiskGraphNode, + RiskHistoryStats, + RiskObservationDraft, +) +from .ontology import map_ontology_to_risk_graph +from .quality import RiskDataQualityGate +from .sampling import RiskSamplingPlanner +from .signals import ( + NormalizedRiskSignal, + normalize_risk_signals, + policy_refs_for_signal, +) + +ZERO = Decimal("0") +ONE = Decimal("1") +HUNDRED = Decimal("100") +DATA_QUALITY_GATE = RiskDataQualityGate() +SAMPLING_PLANNER = RiskSamplingPlanner() +DECISION_TRACE_BUILDER = DecisionTraceBuilder() + + +def evaluate_financial_risk_graph( + context: RiskGraphEvaluationContext, +) -> RiskGraphEvaluationResult: + nodes, edges = build_claim_graph(context.claims) + ontology_mapping = map_ontology_to_risk_graph( + context.ontology_parse, + ontology_parse_id=context.ontology_parse_id, + ontology_version=context.ontology_version, + ) + nodes = _merge_nodes(nodes, ontology_mapping.nodes) + edges = _merge_edges(edges, ontology_mapping.edges) + + target_ids = context.target_claim_ids or {claim.claim_id for claim in context.claims} + target_claims = [claim for claim in context.claims if claim.claim_id in target_ids] + observations: list[RiskObservationDraft] = [] + + for claim in target_claims: + baseline = _resolve_peer_baseline(claim, context.claims, context.min_peer_sample_size) + rule_score, rule_evidence, rule_signals = _score_rule_signals(claim) + anomaly_score, anomaly_evidence = _score_amount_anomaly(claim, baseline) + graph_score, graph_evidence, graph_signals = _score_graph_anomaly(claim, context) + policy_score, policy_evidence, policy_refs = _score_policy_relevance( + rule_signals + graph_signals + ontology_mapping.risk_signals, + ) + history_score, history_evidence, history = _score_history( + claim, + rule_signals + graph_signals + ontology_mapping.risk_signals, + context.history_stats, + ) + + contribution_scores = { + "S_rule": rule_score, + "S_anomaly": anomaly_score, + "S_graph": graph_score, + "S_policy": policy_score, + "S_history": history_score, + } + raw_risk_score = _weighted_risk_score(contribution_scores) + quality_result = DATA_QUALITY_GATE.evaluate_claim(claim) + evidence = [ + *rule_evidence, + *anomaly_evidence, + *graph_evidence, + *policy_evidence, + *history_evidence, + ] + risk_score, evidence_source_gate = _apply_evidence_source_gate( + raw_risk_score, + evidence, + ) + risk_score, data_quality_gate = DATA_QUALITY_GATE.apply_score_cap( + risk_score, + quality_result, + ) + if risk_score < context.observation_threshold and ontology_mapping.gate != "candidate_only": + continue + if risk_score < context.observation_threshold and not ontology_mapping.risk_signals: + continue + + evidence_source_count = _evidence_source_count(evidence) + primary_signal = _select_primary_signal( + rule_signals + graph_signals + ontology_mapping.risk_signals, + fallback_score=risk_score, + ) + confidence = _calculate_confidence( + evidence=evidence, + baseline=baseline, + ontology_confidence=ontology_mapping.confidence, + history=history, + data_quality_ok=quality_result.passed, + ) + automation_mode = _resolve_automation_mode( + risk_score=risk_score, + confidence=confidence, + evidence_count=len(evidence), + history=history, + ) + sampling_decision = SAMPLING_PLANNER.plan( + risk_score=risk_score, + confidence=confidence, + evidence_source_count=evidence_source_count, + data_quality_passed=quality_result.passed, + data_quality_gate=data_quality_gate, + history=history, + ) + risk_level = _level_from_score(risk_score) + decision_metadata = { + "raw_risk_score": raw_risk_score, + "evidence_source_count": evidence_source_count, + "evidence_source_gate": evidence_source_gate, + "data_quality_gate": data_quality_gate, + "data_quality": quality_result.as_dict(), + "sampling_strategy": sampling_decision.as_dict(), + "contribution_scores": contribution_scores, + "baseline_scope": baseline.scope, + "ontology_gate": ontology_mapping.gate, + } + decision_trace = DECISION_TRACE_BUILDER.build( + algorithm_version=ALGORITHM_VERSION, + risk_signal=primary_signal.code, + risk_level=risk_level, + raw_risk_score=raw_risk_score, + risk_score=risk_score, + contribution_scores=contribution_scores, + evidence=evidence, + baseline=baseline, + confidence=confidence, + metadata=decision_metadata, + ) + graph_node_keys = _claim_related_node_keys(claim, nodes) + graph_edge_keys = _claim_related_edge_keys(claim, edges) + similar_case_ids = _similar_case_ids(claim, context.claims) + + observations.append( + RiskObservationDraft( + observation_key=f"risk:{claim.claim_id}:{primary_signal.code}", + subject_type="expense_claim", + subject_key=f"claim:{claim.claim_id}", + subject_label=claim.claim_no or claim.claim_id, + claim_id=claim.claim_id, + claim_no=claim.claim_no, + risk_type=primary_signal.code, + risk_signal=primary_signal.code, + title=f"{primary_signal.label} risk", + description=_build_description(claim, primary_signal, risk_score, evidence), + risk_score=risk_score, + risk_level=risk_level, + confidence_score=confidence, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode=automation_mode, + source="financial_risk_graph", + algorithm_version=ALGORITHM_VERSION, + contribution_scores=contribution_scores, + baseline=baseline, + evidence=evidence, + graph_node_keys=graph_node_keys, + graph_edge_keys=graph_edge_keys, + policy_refs=policy_refs, + similar_case_claim_ids=similar_case_ids, + ontology_json=ontology_mapping.as_dict(), + decision_trace=decision_trace.as_dict(), + ) + ) + + return RiskGraphEvaluationResult( + observations=sorted(observations, key=lambda item: item.risk_score, reverse=True), + nodes=nodes, + edges=edges, + ) + + +def _score_rule_signals( + claim: RiskGraphClaimSnapshot, +) -> tuple[int, list[RiskEvidence], list[NormalizedRiskSignal]]: + signals = normalize_risk_signals(claim.risk_flags, source="rule") + if not signals: + return 0, [], [] + score = min(100, max(item.score for item in signals) + max(0, len(signals) - 1) * 5) + evidence = [ + RiskEvidence( + code="rule_signal", + title="Rule signal", + detail=f"{signal.label}: {signal.severity}", + source="rule", + score=signal.score, + metadata=signal.as_dict(), + ) + for signal in signals + ] + return score, evidence, signals + + +def _score_amount_anomaly( + claim: RiskGraphClaimSnapshot, + baseline: PeerBaseline, +) -> tuple[int, list[RiskEvidence]]: + amount = _to_decimal(claim.amount) + if baseline.sample_size <= 0 or baseline.p75_amount <= ZERO: + return 0, [ + RiskEvidence( + code="baseline_unavailable", + title="Baseline unavailable", + detail=baseline.fallback_reason or "No comparable peer sample.", + source="baseline", + ) + ] + + ratio = _safe_ratio(amount, baseline.p75_amount) + score = _score_ratio( + ratio, + [ + (Decimal("1.00"), 0), + (Decimal("1.25"), 30), + (Decimal("1.50"), 55), + (Decimal("2.00"), 75), + (Decimal("3.00"), 95), + ], + ) + if score <= 0: + return 0, [] + return score, [ + RiskEvidence( + code="peer_amount_deviation", + title="Peer amount deviation", + detail=( + f"Claim amount {amount} is {ratio.quantize(Decimal('0.0001'))} " + f"times peer p75 {baseline.p75_amount}." + ), + source="baseline", + score=score, + metadata={"ratio": str(ratio), "baseline": baseline.as_dict()}, + ) + ] + + +def _score_graph_anomaly( + claim: RiskGraphClaimSnapshot, + context: RiskGraphEvaluationContext, +) -> tuple[int, list[RiskEvidence], list[NormalizedRiskSignal]]: + evidence: list[RiskEvidence] = [] + signals: list[NormalizedRiskSignal] = [] + + duplicate_claims = _duplicate_invoice_claims(claim, context.claims) + if duplicate_claims: + evidence.append( + RiskEvidence( + code="duplicate_invoice_graph", + title="Duplicate invoice graph", + detail="Same invoice appears in multiple claims.", + source="graph", + score=95, + related_entity_keys=[f"claim:{item.claim_id}" for item in duplicate_claims], + ) + ) + signals.extend(normalize_risk_signals(["duplicate_invoice"], source="graph")) + + split_claims = _split_billing_claims(claim, context.claims, context.near_threshold_amount) + if len(split_claims) >= 3: + evidence.append( + RiskEvidence( + code="split_billing_graph", + title="Split billing graph", + detail="Same employee submitted several near-threshold claims in 7 days.", + source="graph", + score=78, + related_entity_keys=[f"claim:{item.claim_id}" for item in split_claims], + ) + ) + signals.extend(normalize_risk_signals(["split_billing"], source="graph")) + + frequency_claims = _employee_frequency_claims(claim, context.claims) + if len(frequency_claims) >= 4: + score = min(88, 52 + len(frequency_claims) * 6) + evidence.append( + RiskEvidence( + code="frequency_graph", + title="Frequency graph", + detail="Same employee has dense claims under the same expense type.", + source="graph", + score=score, + related_entity_keys=[f"claim:{item.claim_id}" for item in frequency_claims], + ) + ) + signals.extend(normalize_risk_signals(["frequency_anomaly"], source="graph")) + + consistency_evidence, consistency_signals = evaluate_claim_consistency(claim) + evidence.extend(consistency_evidence) + signals.extend(consistency_signals) + + cluster_claims = _cross_department_cluster_claims(claim, context.claims) + if len(cluster_claims) >= 3: + evidence.append( + RiskEvidence( + code="cross_department_cluster", + title="Cross-department cluster", + detail="Multiple departments produced similar high-value claims together.", + source="graph", + score=74, + related_entity_keys=[f"claim:{item.claim_id}" for item in cluster_claims], + ) + ) + signals.extend(normalize_risk_signals(["cross_department_cluster"], source="graph")) + + if not evidence: + return 0, [], [] + score = min(100, max(item.score for item in evidence) + max(0, len(evidence) - 1) * 6) + return score, evidence, _dedupe_signals(signals) + + +def _score_policy_relevance( + signals: list[NormalizedRiskSignal], +) -> tuple[int, list[RiskEvidence], list[str]]: + refs: list[str] = [] + for signal in signals: + for ref in policy_refs_for_signal(signal.code): + if ref not in refs: + refs.append(ref) + if not refs: + return 0, [], [] + score = min(88, 45 + len(refs) * 12) + return score, [ + RiskEvidence( + code="policy_relevance", + title="Policy relevance", + detail="Risk signal is bound to policy or control clause.", + source="policy", + score=score, + metadata={"policy_refs": refs}, + ) + ], refs + + +def _score_history( + claim: RiskGraphClaimSnapshot, + signals: list[NormalizedRiskSignal], + history_stats: list[RiskHistoryStats], +) -> tuple[int, list[RiskEvidence], RiskHistoryStats | None]: + signal_codes = {item.code for item in signals} + expense_type = _canonical_key(claim.expense_type) + matched = [ + item + for item in history_stats + if item.risk_signal in signal_codes + and (not item.expense_type or _canonical_key(item.expense_type) == expense_type) + ] + if not matched: + return 0, [], None + history = max(matched, key=lambda item: item.similar_case_count) + total = max(1, history.similar_case_count) + confirmed_rate = Decimal(history.confirmed_count) / Decimal(total) + returned_rate = Decimal(history.returned_count) / Decimal(total) + false_positive_rate = Decimal(history.false_positive_count) / Decimal(total) + score = _clamp_score( + HUNDRED * (confirmed_rate * Decimal("0.65") + returned_rate * Decimal("0.35")) + - HUNDRED * false_positive_rate * Decimal("0.50") + ) + if score <= 0: + return 0, [], history + return score, [ + RiskEvidence( + code="history_feedback", + title="History feedback", + detail="Similar historical cases contain confirmed or returned risks.", + source="feedback", + score=score, + metadata=history.as_dict(), + ) + ], history + + +def _resolve_peer_baseline( + target: RiskGraphClaimSnapshot, + claims: list[RiskGraphClaimSnapshot], + min_sample_size: int, +) -> PeerBaseline: + candidates = [claim for claim in claims if claim.claim_id != target.claim_id] + scopes = [ + ( + "department_grade_expense_type", + [ + claim + for claim in candidates + if _same(claim.department_name, target.department_name) + and _same(claim.employee_grade, target.employee_grade) + and _same(claim.expense_type, target.expense_type) + ], + ), + ( + "department_expense_type", + [ + claim + for claim in candidates + if _same(claim.department_name, target.department_name) + and _same(claim.expense_type, target.expense_type) + ], + ), + ( + "expense_type", + [claim for claim in candidates if _same(claim.expense_type, target.expense_type)], + ), + ("all_claims", candidates), + ] + for scope, scoped_claims in scopes: + amounts = [ + _to_decimal(claim.amount) + for claim in scoped_claims + if _to_decimal(claim.amount) > ZERO + ] + if len(amounts) >= min_sample_size: + return _build_baseline(scope, amounts) + return PeerBaseline( + scope="insufficient_sample", + sample_size=0, + fallback_reason="Peer sample is below minimum threshold.", + ) + + +def _build_baseline(scope: str, amounts: list[Decimal]) -> PeerBaseline: + return PeerBaseline( + scope=scope, + sample_size=len(amounts), + median_amount=_percentile(amounts, 50), + p75_amount=_percentile(amounts, 75), + p90_amount=_percentile(amounts, 90), + mean_amount=sum(amounts, ZERO) / Decimal(len(amounts)), + ) + + +def _weighted_risk_score(scores: dict[str, int]) -> int: + weighted = ( + Decimal(scores["S_rule"]) * Decimal("0.35") + + Decimal(scores["S_anomaly"]) * Decimal("0.25") + + Decimal(scores["S_graph"]) * Decimal("0.20") + + Decimal(scores["S_policy"]) * Decimal("0.15") + + Decimal(scores["S_history"]) * Decimal("0.05") + ) + return _clamp_score(weighted) + + +def _evidence_source_count(evidence: list[RiskEvidence]) -> int: + return len( + { + str(item.source or "").strip() + for item in evidence + if str(item.source or "").strip() + } + ) + + +def _apply_evidence_source_gate( + risk_score: int, + evidence: list[RiskEvidence], +) -> tuple[int, str]: + if risk_score >= 70 and _evidence_source_count(evidence) < 2: + return 69, "capped_high_risk_single_source" + return risk_score, "passed" + + +def _select_primary_signal( + signals: list[NormalizedRiskSignal], + *, + fallback_score: int, +) -> NormalizedRiskSignal: + deduped = _dedupe_signals(signals) + if deduped: + return max(deduped, key=lambda item: (item.score, item.confidence, item.code)) + fallback = normalize_risk_signals( + [{"risk_signal": "amount_limit_exceeded", "score": fallback_score}], + source="algorithm", + ) + return fallback[0] + + +def _calculate_confidence( + *, + evidence: list[RiskEvidence], + baseline: PeerBaseline, + ontology_confidence: Decimal, + history: RiskHistoryStats | None, + data_quality_ok: bool, +) -> Decimal: + source_count = len({item.source for item in evidence}) + confidence = Decimal("0.42") + min(Decimal("0.30"), Decimal(source_count) * Decimal("0.10")) + confidence += min(Decimal("0.16"), Decimal(baseline.sample_size) / Decimal("50")) + confidence += ontology_confidence * Decimal("0.08") + if history and history.similar_case_count: + false_positive_rate = Decimal(history.false_positive_count) / Decimal( + history.similar_case_count + ) + confidence -= min(Decimal("0.18"), false_positive_rate * Decimal("0.30")) + if not data_quality_ok: + confidence -= Decimal("0.20") + return max(Decimal("0.05"), min(Decimal("0.98"), confidence.quantize(Decimal("0.0001")))) + + +def _resolve_automation_mode( + *, + risk_score: int, + confidence: Decimal, + evidence_count: int, + history: RiskHistoryStats | None, +) -> str: + false_positive_rate = Decimal("0") + if history and history.similar_case_count: + false_positive_rate = Decimal(history.false_positive_count) / Decimal( + history.similar_case_count + ) + if ( + risk_score >= 90 + and confidence >= Decimal("0.90") + and evidence_count >= 3 + and false_positive_rate <= Decimal("0.10") + ): + return AUTOMATION_AUTO_HOLD + if risk_score >= 75 and confidence >= Decimal("0.72") and evidence_count >= 2: + return AUTOMATION_SEMI_AUTO_REVIEW + if risk_score >= 40: + return AUTOMATION_MANUAL_REVIEW + return AUTOMATION_ASSIST + + +def _duplicate_invoice_claims( + target: RiskGraphClaimSnapshot, + claims: list[RiskGraphClaimSnapshot], +) -> list[RiskGraphClaimSnapshot]: + invoice_ids = {item.invoice_id for item in target.items if item.invoice_id} + if not invoice_ids: + return [] + matched = [] + for claim in claims: + if claim.claim_id == target.claim_id: + continue + if any(item.invoice_id in invoice_ids for item in claim.items if item.invoice_id): + matched.append(claim) + return matched + + +def _split_billing_claims( + target: RiskGraphClaimSnapshot, + claims: list[RiskGraphClaimSnapshot], + near_threshold_amount: Decimal, +) -> list[RiskGraphClaimSnapshot]: + if target.occurred_at is None: + return [] + matched = [ + claim + for claim in claims + if _same_employee(claim, target) + and _same(claim.expense_type, target.expense_type) + and _same(claim.location, target.location) + and claim.occurred_at is not None + and abs((claim.occurred_at.date() - target.occurred_at.date()).days) <= 7 + and _to_decimal(claim.amount) <= near_threshold_amount + and _to_decimal(claim.amount) >= near_threshold_amount * Decimal("0.55") + ] + return matched + + +def _employee_frequency_claims( + target: RiskGraphClaimSnapshot, + claims: list[RiskGraphClaimSnapshot], +) -> list[RiskGraphClaimSnapshot]: + if target.occurred_at is None: + return [] + return [ + claim + for claim in claims + if _same_employee(claim, target) + and _same(claim.expense_type, target.expense_type) + and claim.occurred_at is not None + and abs((claim.occurred_at.date() - target.occurred_at.date()).days) <= 30 + ] + + +def _cross_department_cluster_claims( + target: RiskGraphClaimSnapshot, + claims: list[RiskGraphClaimSnapshot], +) -> list[RiskGraphClaimSnapshot]: + if target.occurred_at is None or not target.location: + return [] + matched = [ + claim + for claim in claims + if claim.occurred_at is not None + and claim.occurred_at.date() == target.occurred_at.date() + and _same(claim.location, target.location) + and _same(claim.expense_type, target.expense_type) + and _to_decimal(claim.amount) >= _to_decimal(target.amount) * Decimal("0.65") + ] + departments = { + _canonical_key(claim.department_name) + for claim in matched + if claim.department_name + } + return matched if len(departments) >= 2 else [] + + +def _similar_case_ids( + target: RiskGraphClaimSnapshot, + claims: list[RiskGraphClaimSnapshot], +) -> list[str]: + return [ + claim.claim_id + for claim in _employee_frequency_claims(target, claims) + if claim.claim_id != target.claim_id + ][:8] + + +def _claim_related_node_keys( + claim: RiskGraphClaimSnapshot, + nodes: list[RiskGraphNode], +) -> list[str]: + claim_key = claim_node_key(claim) + employee_key = employee_node_key(claim) + related = {claim_key} + if employee_key: + related.add(employee_key) + for node in nodes: + if str(node.key).startswith(("expense_type:", "department:", "location:")): + if str(node.label or "").strip() in { + claim.expense_type, + claim.department_name, + claim.location, + }: + related.add(node.key) + return sorted(related) + + +def _claim_related_edge_keys( + claim: RiskGraphClaimSnapshot, + edges: list[RiskGraphEdge], +) -> list[dict[str, str]]: + claim_key = claim_node_key(claim) + return [ + { + "source_key": edge.source_key, + "target_key": edge.target_key, + "edge_type": edge.edge_type, + } + for edge in edges + if edge.source_key == claim_key or edge.target_key == claim_key + ] + + +def _build_description( + claim: RiskGraphClaimSnapshot, + signal: NormalizedRiskSignal, + risk_score: int, + evidence: list[RiskEvidence], +) -> str: + top_evidence = max(evidence, key=lambda item: item.score, default=None) + if top_evidence is None: + return ( + f"{claim.claim_no or claim.claim_id} produced " + f"{signal.label} with score {risk_score}." + ) + return ( + f"{claim.claim_no or claim.claim_id} produced {signal.label} " + f"with score {risk_score}. Main evidence: {top_evidence.detail}" + ) + + +def _level_from_score(score: int) -> str: + if score >= 90: + return LEVEL_CRITICAL + if score >= 70: + return LEVEL_HIGH + if score >= 45: + return LEVEL_MEDIUM + return LEVEL_LOW + + +def _score_ratio(value: Decimal, bands: list[tuple[Decimal, int]]) -> int: + if not bands: + return 0 + points = sorted(bands, key=lambda item: item[0]) + if value <= points[0][0]: + return points[0][1] + for index in range(1, len(points)): + left_value, left_score = points[index - 1] + right_value, right_score = points[index] + if value > right_value: + continue + ratio = (value - left_value) / (right_value - left_value) + return _clamp_score(Decimal(left_score) + ratio * Decimal(right_score - left_score)) + return points[-1][1] + + +def _percentile(values: list[Decimal], percent: int) -> Decimal: + normalized = sorted(value for value in values if value >= ZERO) + if not normalized: + return ZERO + if len(normalized) == 1: + return normalized[0] + position = Decimal(len(normalized) - 1) * Decimal(percent) / HUNDRED + lower = int(position.to_integral_value(rounding=ROUND_FLOOR)) + upper = int(position.to_integral_value(rounding=ROUND_CEILING)) + if lower == upper: + return normalized[lower] + fraction = position - Decimal(lower) + return normalized[lower] + (normalized[upper] - normalized[lower]) * fraction + + +def _safe_ratio(numerator: Any, denominator: Any) -> Decimal: + denominator_value = _to_decimal(denominator) + if denominator_value <= ZERO: + return ZERO + return (_to_decimal(numerator) / denominator_value).quantize(Decimal("0.0001")) + + +def _to_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return ZERO + + +def _clamp_score(value: Any) -> int: + try: + numeric = Decimal(str(value)) + except Exception: + numeric = ZERO + return max(0, min(100, int(numeric.quantize(ONE, rounding=ROUND_HALF_UP)))) + + +def _same(left: Any, right: Any) -> bool: + return _canonical_key(left) == _canonical_key(right) + + +def _same_employee(left: RiskGraphClaimSnapshot, right: RiskGraphClaimSnapshot) -> bool: + left_key = left.employee_id or left.employee_name + right_key = right.employee_id or right.employee_name + return bool(left_key and _same(left_key, right_key)) + + +def _canonical_key(value: Any) -> str: + return "_".join(str(value or "").strip().lower().split()) + + +def _dedupe_signals(signals: list[NormalizedRiskSignal]) -> list[NormalizedRiskSignal]: + by_code: dict[str, NormalizedRiskSignal] = {} + for signal in signals: + current = by_code.get(signal.code) + if current is None or signal.score > current.score: + by_code[signal.code] = signal + return list(by_code.values()) + + +def _merge_nodes( + first: list[RiskGraphNode], + second: list[RiskGraphNode], +) -> list[RiskGraphNode]: + by_key = {node.key: node for node in first} + for node in second: + by_key.setdefault(node.key, node) + return list(by_key.values()) + + +def _merge_edges( + first: list[RiskGraphEdge], + second: list[RiskGraphEdge], +) -> list[RiskGraphEdge]: + by_key = {edge.edge_key(): edge for edge in first} + for edge in second: + by_key.setdefault(edge.edge_key(), edge) + return list(by_key.values()) diff --git a/server/src/app/algorithem/risk_graph/entity_resolution.py b/server/src/app/algorithem/risk_graph/entity_resolution.py new file mode 100644 index 0000000..990a836 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/entity_resolution.py @@ -0,0 +1,113 @@ +"""Canonical entity resolution for financial risk graph subjects.""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +ENTITY_TYPE_ALIASES = { + "supplier": "vendor", + "merchant": "vendor", + "hotel": "vendor", + "bank_account_name": "bank_account", + "employee_name": "employee", +} + + +@dataclass(slots=True) +class CanonicalEntity: + canonical_id: str + entity_type: str + canonical_key: str + label: str + aliases: list[str] = field(default_factory=list) + source: str = "" + confirmed_by: str = "" + confirmed_at: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "canonical_id": self.canonical_id, + "entity_type": self.entity_type, + "canonical_key": self.canonical_key, + "label": self.label, + "aliases": list(self.aliases), + "source": self.source, + "confirmed_by": self.confirmed_by, + "confirmed_at": self.confirmed_at, + "metadata": dict(self.metadata), + } + + +class FinancialEntityResolver: + def resolve( + self, + entity_type: str, + value: str, + *, + source: str = "", + metadata: dict[str, Any] | None = None, + ) -> CanonicalEntity | None: + canonical_type = ENTITY_TYPE_ALIASES.get(_canonical_token(entity_type), _canonical_token(entity_type)) + canonical_key = _canonical_value(value) + if not canonical_type or not canonical_key: + return None + canonical_id = _canonical_id(canonical_type, canonical_key) + return CanonicalEntity( + canonical_id=canonical_id, + entity_type=canonical_type, + canonical_key=canonical_key, + label=str(value or "").strip(), + aliases=[str(value or "").strip()], + source=source, + metadata=metadata or {}, + ) + + +class CanonicalEntityRegistry: + def __init__(self) -> None: + self._entities: dict[str, CanonicalEntity] = {} + + def upsert(self, entity: CanonicalEntity) -> CanonicalEntity: + current = self._entities.get(entity.canonical_id) + if current is None: + self._entities[entity.canonical_id] = entity + return entity + aliases = list(dict.fromkeys([*current.aliases, *entity.aliases])) + current.aliases = aliases + current.metadata.update(entity.metadata) + return current + + def confirm(self, canonical_id: str, *, actor: str) -> CanonicalEntity | None: + entity = self._entities.get(canonical_id) + if entity is None: + return None + entity.confirmed_by = str(actor or "").strip() + entity.confirmed_at = datetime.now(UTC).isoformat() + return entity + + def get(self, canonical_id: str) -> CanonicalEntity | None: + return self._entities.get(canonical_id) + + def all(self) -> list[CanonicalEntity]: + return list(self._entities.values()) + + +def _canonical_id(entity_type: str, canonical_key: str) -> str: + digest = hashlib.sha1(f"{entity_type}:{canonical_key}".encode("utf-8")).hexdigest()[:12] + return f"{entity_type}:{digest}" + + +def _canonical_token(value: str) -> str: + return "_".join(str(value or "").strip().lower().split()) + + +def _canonical_value(value: str) -> str: + normalized = str(value or "").strip().lower() + normalized = re.sub(r"[\s\-_/,,.。()()【】\[\]]+", "", normalized) + return normalized diff --git a/server/src/app/algorithem/risk_graph/evaluation_cases.py b/server/src/app/algorithem/risk_graph/evaluation_cases.py new file mode 100644 index 0000000..946b899 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/evaluation_cases.py @@ -0,0 +1,71 @@ +"""Replayable evaluation cases for the financial risk graph algorithm.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True, slots=True) +class RiskEvaluationCase: + case_id: str + category: str + expected_signal: str + expected_level: str + description: str + payload: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "case_id": self.case_id, + "category": self.category, + "expected_signal": self.expected_signal, + "expected_level": self.expected_level, + "description": self.description, + "payload": dict(self.payload), + } + + +def default_risk_evaluation_cases() -> list[RiskEvaluationCase]: + return [ + RiskEvaluationCase( + case_id="positive_duplicate_invoice_high", + category="positive", + expected_signal="duplicate_invoice", + expected_level="high", + description="重复发票叠加高金额偏离,应输出高风险观察。", + payload={"risk_flags": ["duplicate_invoice"], "invoice_reuse": True}, + ), + RiskEvaluationCase( + case_id="negative_clean_low_amount", + category="negative", + expected_signal="none", + expected_level="none", + description="低金额、无规则命中、无图谱异常,不应输出风险观察。", + payload={"amount": 300, "risk_flags": []}, + ), + RiskEvaluationCase( + case_id="counterfactual_invoice_corrected", + category="counterfactual", + expected_signal="none", + expected_level="none", + description="重复票据被替换为唯一票据后,风险应消失或降级。", + payload={"remove_duplicate_invoice": True}, + ), + RiskEvaluationCase( + case_id="noise_missing_employee", + category="noise", + expected_signal="preapproval_absent", + expected_level="medium", + description="缺失员工信息时允许候选观察,但不能输出强风控结论。", + payload={"missing_fields": ["employee"], "score_cap": 69}, + ), + RiskEvaluationCase( + case_id="historical_false_positive_calibration", + category="historical_false_positive", + expected_signal="duplicate_invoice", + expected_level="medium", + description="历史误报率较高时进入校准抽审,不直接强拦截。", + payload={"false_positive_rate": 0.35}, + ), + ] diff --git a/server/src/app/algorithem/risk_graph/features.py b/server/src/app/algorithem/risk_graph/features.py new file mode 100644 index 0000000..06872ce --- /dev/null +++ b/server/src/app/algorithem/risk_graph/features.py @@ -0,0 +1,144 @@ +"""Feature extraction for heterogeneous financial risk graphs.""" + +from __future__ import annotations + +from collections import Counter, defaultdict, deque +from dataclasses import dataclass, field +from typing import Any + +from .models import RiskGraphEdge, RiskGraphNode + + +@dataclass(slots=True) +class RiskGraphFeatureSet: + node_type_counts: dict[str, int] = field(default_factory=dict) + edge_type_counts: dict[str, int] = field(default_factory=dict) + meta_path_counts: dict[str, int] = field(default_factory=dict) + degree_centrality: dict[str, float] = field(default_factory=dict) + clusters: list[dict[str, Any]] = field(default_factory=list) + neighbor_risk_density: dict[str, float] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "node_type_counts": dict(self.node_type_counts), + "edge_type_counts": dict(self.edge_type_counts), + "meta_path_counts": dict(self.meta_path_counts), + "degree_centrality": dict(self.degree_centrality), + "clusters": list(self.clusters), + "neighbor_risk_density": dict(self.neighbor_risk_density), + } + + +class HeterogeneousRiskGraphFeatureBuilder: + def build( + self, + nodes: list[RiskGraphNode], + edges: list[RiskGraphEdge], + *, + risk_node_keys: set[str] | None = None, + ) -> RiskGraphFeatureSet: + node_by_key = {node.key: node for node in nodes} + adjacency = _build_adjacency(edges) + risk_keys = set(risk_node_keys or set()) + return RiskGraphFeatureSet( + node_type_counts=dict(Counter(node.node_type for node in nodes)), + edge_type_counts=dict(Counter(edge.edge_type for edge in edges)), + meta_path_counts=_meta_path_counts(node_by_key, adjacency), + degree_centrality=_degree_centrality(node_by_key, adjacency), + clusters=_clusters(node_by_key, adjacency), + neighbor_risk_density=_neighbor_risk_density(node_by_key, adjacency, risk_keys), + ) + + +def _build_adjacency(edges: list[RiskGraphEdge]) -> dict[str, list[tuple[str, str]]]: + adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list) + for edge in edges: + adjacency[edge.source_key].append((edge.target_key, edge.edge_type)) + adjacency[edge.target_key].append((edge.source_key, edge.edge_type)) + return adjacency + + +def _meta_path_counts( + node_by_key: dict[str, RiskGraphNode], + adjacency: dict[str, list[tuple[str, str]]], +) -> dict[str, int]: + counts: Counter[str] = Counter() + for source_key, first_hops in adjacency.items(): + source = node_by_key.get(source_key) + if source is None: + continue + for middle_key, first_edge_type in first_hops: + middle = node_by_key.get(middle_key) + if middle is None: + continue + for target_key, second_edge_type in adjacency.get(middle_key, []): + if target_key == source_key: + continue + target = node_by_key.get(target_key) + if target is None: + continue + key = ( + f"{source.node_type}->{first_edge_type}->{middle.node_type}" + f"->{second_edge_type}->{target.node_type}" + ) + counts[key] += 1 + return dict(counts) + + +def _degree_centrality( + node_by_key: dict[str, RiskGraphNode], + adjacency: dict[str, list[tuple[str, str]]], +) -> dict[str, float]: + denominator = max(1, len(node_by_key) - 1) + return { + node_key: round(len(adjacency.get(node_key, [])) / denominator, 4) + for node_key in node_by_key + } + + +def _clusters( + node_by_key: dict[str, RiskGraphNode], + adjacency: dict[str, list[tuple[str, str]]], +) -> list[dict[str, Any]]: + visited: set[str] = set() + clusters: list[dict[str, Any]] = [] + for start_key in node_by_key: + if start_key in visited: + continue + queue: deque[str] = deque([start_key]) + visited.add(start_key) + members: list[str] = [] + type_counts: Counter[str] = Counter() + while queue: + node_key = queue.popleft() + members.append(node_key) + type_counts[node_by_key[node_key].node_type] += 1 + for next_key, _ in adjacency.get(node_key, []): + if next_key in visited or next_key not in node_by_key: + continue + visited.add(next_key) + queue.append(next_key) + clusters.append( + { + "size": len(members), + "node_keys": sorted(members), + "node_type_counts": dict(type_counts), + } + ) + return sorted(clusters, key=lambda item: item["size"], reverse=True) + + +def _neighbor_risk_density( + node_by_key: dict[str, RiskGraphNode], + adjacency: dict[str, list[tuple[str, str]]], + risk_keys: set[str], +) -> dict[str, float]: + density: dict[str, float] = {} + for node_key in node_by_key: + neighbors = [target for target, _ in adjacency.get(node_key, [])] + if not neighbors: + density[node_key] = 0.0 + continue + risk_neighbor_count = sum(1 for target in neighbors if target in risk_keys) + density[node_key] = round(risk_neighbor_count / len(neighbors), 4) + return density diff --git a/server/src/app/algorithem/risk_graph/graph.py b/server/src/app/algorithem/risk_graph/graph.py new file mode 100644 index 0000000..03c4003 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/graph.py @@ -0,0 +1,307 @@ +"""Graph construction helpers for expense risk analysis.""" + +from __future__ import annotations + +from decimal import Decimal + +from .models import RiskGraphClaimSnapshot, RiskGraphEdge, RiskGraphNode + +ALLOWED_EDGE_TYPES = { + "department_has_employee", + "employee_submits_claim", + "claim_has_item", + "claim_expense_type", + "claim_location", + "claim_invoice", + "claim_has_risk_signal", + "claim_similar_to", + "claim_duplicate_invoice", + "ontology_extracts", + "ontology_constrains", + "ontology_signals", +} + + +def build_claim_graph( + claims: list[RiskGraphClaimSnapshot], +) -> tuple[list[RiskGraphNode], list[RiskGraphEdge]]: + nodes: dict[str, RiskGraphNode] = {} + edges: dict[tuple[str, str, str], RiskGraphEdge] = {} + + for claim in claims: + claim_key = claim_node_key(claim) + _add_node( + nodes, + RiskGraphNode( + key=claim_key, + node_type="claim", + label=claim.claim_no or claim.claim_id, + canonical_key=claim_key, + canonical_id=claim.claim_id or claim.claim_no, + metadata={ + "claim_id": claim.claim_id, + "amount": str(_to_decimal(claim.amount)), + "expense_type": claim.expense_type, + "status": claim.status, + }, + ), + ) + + employee_key = employee_node_key(claim) + if employee_key: + _add_node( + nodes, + RiskGraphNode( + key=employee_key, + node_type="employee", + label=claim.employee_name or claim.employee_id or "unknown", + canonical_key=employee_key, + canonical_id=claim.employee_id or claim.employee_name, + metadata={"employee_id": claim.employee_id, "grade": claim.employee_grade}, + ), + ) + _add_edge( + edges, + RiskGraphEdge( + source_key=employee_key, + target_key=claim_key, + edge_type="employee_submits_claim", + metadata={"amount": str(_to_decimal(claim.amount))}, + ), + ) + + department_key = department_node_key(claim) + if department_key: + _add_node( + nodes, + RiskGraphNode( + key=department_key, + node_type="department", + label=claim.department_name or claim.department_id or "unknown", + canonical_key=department_key, + canonical_id=claim.department_id or claim.department_name, + metadata={"department_id": claim.department_id}, + ), + ) + if employee_key: + _add_edge( + edges, + RiskGraphEdge( + source_key=department_key, + target_key=employee_key, + edge_type="department_has_employee", + ), + ) + + expense_key = expense_type_node_key(claim.expense_type) + if expense_key: + _add_node( + nodes, + RiskGraphNode( + key=expense_key, + node_type="expense_type", + label=claim.expense_type, + canonical_key=expense_key, + canonical_id=claim.expense_type, + ), + ) + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_key, + target_key=expense_key, + edge_type="claim_expense_type", + ), + ) + + location_key = location_node_key(claim.location) + if location_key: + _add_node( + nodes, + RiskGraphNode( + key=location_key, + node_type="location", + label=claim.location, + canonical_key=location_key, + canonical_id=claim.location, + ), + ) + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_key, + target_key=location_key, + edge_type="claim_location", + ), + ) + + for item in claim.items: + item_key = f"claim_item:{item.item_id}" if item.item_id else "" + if item_key: + _add_node( + nodes, + RiskGraphNode( + key=item_key, + node_type="claim_item", + label=item.item_type or item.item_id, + canonical_key=item_key, + canonical_id=item.item_id, + metadata={ + "amount": str(_to_decimal(item.item_amount)), + "location": item.item_location, + "invoice_id": item.invoice_id, + }, + ), + ) + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_key, + target_key=item_key, + edge_type="claim_has_item", + ), + ) + if item.invoice_id: + invoice_key = invoice_node_key(item.invoice_id) + _add_node( + nodes, + RiskGraphNode( + key=invoice_key, + node_type="invoice", + label=item.invoice_id, + canonical_key=invoice_key, + canonical_id=item.invoice_id, + ), + ) + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_key, + target_key=invoice_key, + edge_type="claim_invoice", + ), + ) + + _link_duplicate_invoices(claims, edges) + _link_similar_claims(claims, edges) + return list(nodes.values()), list(edges.values()) + + +def claim_node_key(claim: RiskGraphClaimSnapshot) -> str: + return f"claim:{claim.claim_id or claim.claim_no}" + + +def employee_node_key(claim: RiskGraphClaimSnapshot) -> str: + identifier = claim.employee_id or claim.employee_name + return f"employee:{_canonical_key(identifier)}" if identifier else "" + + +def department_node_key(claim: RiskGraphClaimSnapshot) -> str: + identifier = claim.department_id or claim.department_name + return f"department:{_canonical_key(identifier)}" if identifier else "" + + +def expense_type_node_key(expense_type: str) -> str: + return f"expense_type:{_canonical_key(expense_type)}" if str(expense_type or "").strip() else "" + + +def location_node_key(location: str) -> str: + return f"location:{_canonical_key(location)}" if str(location or "").strip() else "" + + +def invoice_node_key(invoice_id: str) -> str: + return f"invoice:{_canonical_key(invoice_id)}" + + +def _link_duplicate_invoices( + claims: list[RiskGraphClaimSnapshot], + edges: dict[tuple[str, str, str], RiskGraphEdge], +) -> None: + by_invoice: dict[str, list[RiskGraphClaimSnapshot]] = {} + for claim in claims: + for item in claim.items: + if item.invoice_id: + by_invoice.setdefault(item.invoice_id, []).append(claim) + + for invoice_id, invoice_claims in by_invoice.items(): + unique_claims = {claim.claim_id: claim for claim in invoice_claims} + if len(unique_claims) < 2: + continue + claim_list = list(unique_claims.values()) + for source in claim_list: + for target in claim_list: + if source.claim_id == target.claim_id: + continue + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_node_key(source), + target_key=claim_node_key(target), + edge_type="claim_duplicate_invoice", + weight=Decimal("2"), + evidence=f"invoice:{invoice_id}", + ), + ) + + +def _link_similar_claims( + claims: list[RiskGraphClaimSnapshot], + edges: dict[tuple[str, str, str], RiskGraphEdge], +) -> None: + for index, source in enumerate(claims): + for target in claims[index + 1 :]: + if not _is_similar_claim(source, target): + continue + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_node_key(source), + target_key=claim_node_key(target), + edge_type="claim_similar_to", + weight=Decimal("0.7"), + metadata={"reason": "same employee and expense type"}, + ), + ) + _add_edge( + edges, + RiskGraphEdge( + source_key=claim_node_key(target), + target_key=claim_node_key(source), + edge_type="claim_similar_to", + weight=Decimal("0.7"), + metadata={"reason": "same employee and expense type"}, + ), + ) + + +def _is_similar_claim(source: RiskGraphClaimSnapshot, target: RiskGraphClaimSnapshot) -> bool: + source_employee = source.employee_id or source.employee_name + target_employee = target.employee_id or target.employee_name + if not source_employee or _canonical_key(source_employee) != _canonical_key(target_employee): + return False + if _canonical_key(source.expense_type) != _canonical_key(target.expense_type): + return False + if source.occurred_at is None or target.occurred_at is None: + return True + return abs((source.occurred_at.date() - target.occurred_at.date()).days) <= 30 + + +def _add_node(nodes: dict[str, RiskGraphNode], node: RiskGraphNode) -> None: + nodes.setdefault(node.key, node) + + +def _add_edge(edges: dict[tuple[str, str, str], RiskGraphEdge], edge: RiskGraphEdge) -> None: + if edge.edge_type not in ALLOWED_EDGE_TYPES: + return + edges.setdefault(edge.edge_key(), edge) + + +def _canonical_key(value: str | None) -> str: + return "_".join(str(value or "").strip().lower().split()) + + +def _to_decimal(value: object) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return Decimal("0") diff --git a/server/src/app/algorithem/risk_graph/lineage.py b/server/src/app/algorithem/risk_graph/lineage.py new file mode 100644 index 0000000..c011373 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/lineage.py @@ -0,0 +1,103 @@ +"""Data lineage contracts for risk graph observations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class RiskDataLineage: + observation_key: str + data_tables: list[str] = field(default_factory=list) + document_ids: list[str] = field(default_factory=list) + ocr_job_ids: list[str] = field(default_factory=list) + agent_run_ids: list[str] = field(default_factory=list) + tool_call_ids: list[str] = field(default_factory=list) + rule_versions: list[str] = field(default_factory=list) + ontology_version: str = "" + algorithm_version: str = "" + source_event_ids: list[str] = field(default_factory=list) + quality_gates: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "observation_key": self.observation_key, + "data_tables": list(self.data_tables), + "document_ids": list(self.document_ids), + "ocr_job_ids": list(self.ocr_job_ids), + "agent_run_ids": list(self.agent_run_ids), + "tool_call_ids": list(self.tool_call_ids), + "rule_versions": list(self.rule_versions), + "ontology_version": self.ontology_version, + "algorithm_version": self.algorithm_version, + "source_event_ids": list(self.source_event_ids), + "quality_gates": list(self.quality_gates), + } + + +class RiskDataLineageBuilder: + def build_from_observation( + self, + observation: dict[str, Any], + *, + source_event_ids: list[str] | None = None, + ) -> RiskDataLineage: + evidence = [item for item in observation.get("evidence", []) if isinstance(item, dict)] + ontology_json = observation.get("ontology_json") or {} + decision_trace = observation.get("decision_trace") or {} + data_tables = ["risk_observations"] + if observation.get("claim_id"): + data_tables.extend(["expense_claims", "expense_claim_items"]) + if evidence: + data_tables.append("risk_observation_evidence") + + return RiskDataLineage( + observation_key=str(observation.get("observation_key") or ""), + data_tables=_unique(data_tables), + document_ids=_evidence_values(evidence, ["document_id", "doc_id", "file_id"]), + ocr_job_ids=_evidence_values(evidence, ["ocr_job_id", "ocr_run_id"]), + agent_run_ids=_unique( + [ + str(observation.get("run_id") or "").strip(), + str(decision_trace.get("agent_run_id") or "").strip(), + ] + ), + tool_call_ids=_evidence_values(evidence, ["tool_call_id"]), + rule_versions=_unique( + [ + *_evidence_values(evidence, ["rule_version"]), + str(decision_trace.get("rule_version") or "").strip(), + ] + ), + ontology_version=str(ontology_json.get("ontology_version") or "").strip(), + algorithm_version=str(observation.get("algorithm_version") or "").strip(), + source_event_ids=_unique(source_event_ids or []), + quality_gates=_quality_gates(decision_trace), + ) + + +def _evidence_values(evidence: list[dict[str, Any]], keys: list[str]) -> list[str]: + values: list[str] = [] + for item in evidence: + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + for key in keys: + value = str(item.get(key) or metadata.get(key) or "").strip() + if value: + values.append(value) + return _unique(values) + + +def _quality_gates(decision_trace: dict[str, Any]) -> list[str]: + gates = [ + str(decision_trace.get("evidence_source_gate") or "").strip(), + str(decision_trace.get("data_quality_gate") or "").strip(), + ] + sampling = decision_trace.get("sampling_strategy") + if isinstance(sampling, dict): + gates.append(str(sampling.get("strategy") or "").strip()) + return _unique([item for item in gates if item and item != "passed"]) + + +def _unique(values: list[str]) -> list[str]: + return list(dict.fromkeys(str(item).strip() for item in values if str(item).strip())) diff --git a/server/src/app/algorithem/risk_graph/models.py b/server/src/app/algorithem/risk_graph/models.py new file mode 100644 index 0000000..9d79f74 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/models.py @@ -0,0 +1,365 @@ +"""Data contracts for the financial risk graph algorithm.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +ALGORITHM_VERSION = "financial_risk_graph.v1" + +LEVEL_LOW = "low" +LEVEL_MEDIUM = "medium" +LEVEL_HIGH = "high" +LEVEL_CRITICAL = "critical" + +AUTOMATION_ASSIST = "assist" +AUTOMATION_MANUAL_REVIEW = "manual_review" +AUTOMATION_SEMI_AUTO_REVIEW = "semi_auto_review" +AUTOMATION_AUTO_HOLD = "auto_hold" + + +@dataclass(slots=True) +class RiskGraphClaimItemSnapshot: + item_id: str = "" + item_type: str = "" + item_amount: Any = Decimal("0") + item_location: str = "" + item_date: date | None = None + invoice_id: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_orm(cls, item: Any) -> "RiskGraphClaimItemSnapshot": + return cls( + item_id=str(getattr(item, "id", "") or ""), + item_type=str(getattr(item, "item_type", "") or ""), + item_amount=getattr(item, "item_amount", Decimal("0")) or Decimal("0"), + item_location=str(getattr(item, "item_location", "") or ""), + item_date=getattr(item, "item_date", None), + invoice_id=( + str(getattr(item, "invoice_id", "") or "").strip() + or None + ), + metadata=_metadata_from_object(item), + ) + + +@dataclass(slots=True) +class RiskGraphClaimSnapshot: + claim_id: str + claim_no: str = "" + employee_id: str | None = None + employee_name: str = "" + department_id: str | None = None + department_name: str = "" + employee_grade: str | None = None + expense_type: str = "" + amount: Any = Decimal("0") + currency: str = "CNY" + invoice_count: int = 0 + occurred_at: datetime | None = None + submitted_at: datetime | None = None + status: str = "" + reason: str = "" + location: str = "" + risk_flags: list[Any] = field(default_factory=list) + items: list[RiskGraphClaimItemSnapshot] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_orm(cls, claim: Any) -> "RiskGraphClaimSnapshot": + items = [ + RiskGraphClaimItemSnapshot.from_orm(item) + for item in list(getattr(claim, "items", None) or []) + ] + return cls( + claim_id=str(getattr(claim, "id", "") or ""), + claim_no=str(getattr(claim, "claim_no", "") or ""), + employee_id=( + str(getattr(claim, "employee_id", "") or "").strip() + or None + ), + employee_name=str(getattr(claim, "employee_name", "") or ""), + department_id=( + str(getattr(claim, "department_id", "") or "").strip() + or None + ), + department_name=str(getattr(claim, "department_name", "") or ""), + employee_grade=( + str(getattr(claim, "employee_grade", "") or "").strip() + or None + ), + expense_type=str(getattr(claim, "expense_type", "") or ""), + amount=getattr(claim, "amount", Decimal("0")) or Decimal("0"), + currency=str(getattr(claim, "currency", "CNY") or "CNY"), + invoice_count=int(getattr(claim, "invoice_count", 0) or 0), + occurred_at=getattr(claim, "occurred_at", None), + submitted_at=getattr(claim, "submitted_at", None), + status=str(getattr(claim, "status", "") or ""), + reason=str(getattr(claim, "reason", "") or ""), + location=str(getattr(claim, "location", "") or ""), + risk_flags=list(getattr(claim, "risk_flags_json", None) or []), + items=items, + metadata=_metadata_from_object(claim), + ) + + +@dataclass(slots=True) +class RiskGraphNode: + key: str + node_type: str + label: str + canonical_key: str = "" + canonical_id: str = "" + ontology_type: str = "" + ontology_parse_id: str = "" + ontology_version: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "node_type": self.node_type, + "label": self.label, + "canonical_key": self.canonical_key or self.key, + "canonical_id": self.canonical_id or self.canonical_key or self.key, + "ontology_type": self.ontology_type or self.node_type, + "ontology_parse_id": self.ontology_parse_id, + "ontology_version": self.ontology_version, + "metadata": _json_safe(self.metadata), + } + + +@dataclass(slots=True) +class RiskGraphEdge: + source_key: str + target_key: str + edge_type: str + weight: Decimal = Decimal("1") + source: str = "algorithm" + evidence: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + def edge_key(self) -> tuple[str, str, str]: + return (self.source_key, self.target_key, self.edge_type) + + def as_dict(self) -> dict[str, Any]: + return { + "source_key": self.source_key, + "target_key": self.target_key, + "edge_type": self.edge_type, + "weight": _format_decimal(self.weight), + "source": self.source, + "evidence": self.evidence, + "metadata": _json_safe(self.metadata), + } + + +@dataclass(slots=True) +class PeerBaseline: + scope: str + sample_size: int + median_amount: Decimal = Decimal("0") + p75_amount: Decimal = Decimal("0") + p90_amount: Decimal = Decimal("0") + mean_amount: Decimal = Decimal("0") + fallback_reason: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "scope": self.scope, + "sample_size": self.sample_size, + "median_amount": _format_decimal(self.median_amount), + "p75_amount": _format_decimal(self.p75_amount), + "p90_amount": _format_decimal(self.p90_amount), + "mean_amount": _format_decimal(self.mean_amount), + "fallback_reason": self.fallback_reason, + } + + +@dataclass(slots=True) +class RiskEvidence: + code: str + title: str + detail: str + source: str + score: int = 0 + related_entity_keys: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "title": self.title, + "detail": self.detail, + "source": self.source, + "score": int(self.score), + "related_entity_keys": list(self.related_entity_keys), + "metadata": _json_safe(self.metadata), + } + + +@dataclass(slots=True) +class RiskHistoryStats: + risk_signal: str + expense_type: str = "" + similar_case_count: int = 0 + confirmed_count: int = 0 + false_positive_count: int = 0 + returned_count: int = 0 + + def as_dict(self) -> dict[str, Any]: + return { + "risk_signal": self.risk_signal, + "expense_type": self.expense_type, + "similar_case_count": self.similar_case_count, + "confirmed_count": self.confirmed_count, + "false_positive_count": self.false_positive_count, + "returned_count": self.returned_count, + } + + +@dataclass(slots=True) +class RiskGraphEvaluationContext: + claims: list[RiskGraphClaimSnapshot] + target_claim_ids: set[str] | None = None + ontology_parse: Any | None = None + ontology_parse_id: str = "" + ontology_version: str = "ontology.v1" + history_stats: list[RiskHistoryStats] = field(default_factory=list) + min_peer_sample_size: int = 3 + observation_threshold: int = 31 + near_threshold_amount: Decimal = Decimal("5000") + + +@dataclass(slots=True) +class RiskObservationDraft: + observation_key: str + subject_type: str + subject_key: str + subject_label: str + claim_id: str + claim_no: str + risk_type: str + risk_signal: str + title: str + description: str + risk_score: int + risk_level: str + confidence_score: Decimal + control_stage: str + control_mode: str + automation_mode: str + source: str + algorithm_version: str + contribution_scores: dict[str, int] + baseline: PeerBaseline + evidence: list[RiskEvidence] = field(default_factory=list) + graph_node_keys: list[str] = field(default_factory=list) + graph_edge_keys: list[dict[str, str]] = field(default_factory=list) + policy_refs: list[str] = field(default_factory=list) + similar_case_claim_ids: list[str] = field(default_factory=list) + ontology_json: dict[str, Any] = field(default_factory=dict) + decision_trace: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "observation_key": self.observation_key, + "subject_type": self.subject_type, + "subject_key": self.subject_key, + "subject_label": self.subject_label, + "claim_id": self.claim_id, + "claim_no": self.claim_no, + "risk_type": self.risk_type, + "risk_signal": self.risk_signal, + "title": self.title, + "description": self.description, + "risk_score": self.risk_score, + "risk_level": self.risk_level, + "confidence_score": _format_decimal(self.confidence_score), + "control_stage": self.control_stage, + "control_mode": self.control_mode, + "automation_mode": self.automation_mode, + "source": self.source, + "algorithm_version": self.algorithm_version, + "contribution_scores": dict(self.contribution_scores), + "baseline": self.baseline.as_dict(), + "evidence": [item.as_dict() for item in self.evidence], + "graph_node_keys": list(self.graph_node_keys), + "graph_edge_keys": list(self.graph_edge_keys), + "policy_refs": list(self.policy_refs), + "similar_case_claim_ids": list(self.similar_case_claim_ids), + "ontology_json": _json_safe(self.ontology_json), + "decision_trace": _json_safe(self.decision_trace), + } + + +@dataclass(slots=True) +class RiskGraphEvaluationResult: + observations: list[RiskObservationDraft] + nodes: list[RiskGraphNode] + edges: list[RiskGraphEdge] + algorithm_version: str = ALGORITHM_VERSION + + def as_dict(self) -> dict[str, Any]: + return { + "algorithm_version": self.algorithm_version, + "observations": [item.as_dict() for item in self.observations], + "nodes": [item.as_dict() for item in self.nodes], + "edges": [item.as_dict() for item in self.edges], + "summary": { + "observation_count": len(self.observations), + "node_count": len(self.nodes), + "edge_count": len(self.edges), + "high_or_above_count": sum( + 1 + for item in self.observations + if item.risk_level in {LEVEL_HIGH, LEVEL_CRITICAL} + ), + }, + } + + +def _format_decimal(value: Any, places: str = "0.0000") -> str: + if value is None: + return "0" + if not isinstance(value, Decimal): + value = Decimal(str(value or "0")) + return format(value.quantize(Decimal(places)), "f").rstrip("0").rstrip(".") or "0" + + +def _json_safe(value: Any) -> Any: + if isinstance(value, Decimal): + return _format_decimal(value) + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + return value + + +def _metadata_from_object(source: Any) -> dict[str, Any]: + metadata: dict[str, Any] = {} + for attr in ( + "metadata", + "metadata_json", + "extra_json", + "supplier_id", + "supplier_name", + "vendor_id", + "vendor_name", + "merchant_id", + "merchant_name", + ): + value = getattr(source, attr, None) + if isinstance(value, dict): + metadata.update(value) + elif attr != "metadata" and value not in (None, ""): + metadata[attr] = value + return metadata diff --git a/server/src/app/algorithem/risk_graph/ontology.py b/server/src/app/algorithem/risk_graph/ontology.py new file mode 100644 index 0000000..1101378 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/ontology.py @@ -0,0 +1,270 @@ +"""Ontology-to-risk-graph mapping utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from .models import RiskGraphEdge, RiskGraphNode +from .signals import NormalizedRiskSignal, normalize_risk_signals + +ONTOLOGY_NODE_TYPE_MAP = { + "expense_type": "expense_type", + "document_type": "document", + "employee": "employee", + "department": "department", + "vendor": "vendor", + "supplier": "vendor", + "merchant": "vendor", + "customer": "customer", + "risk_signal": "risk_signal", + "invoice": "invoice", + "claim": "claim", +} + +ALLOWED_ONTOLOGY_EDGE_TYPES = { + "ontology_extracts", + "ontology_constrains", + "ontology_signals", +} + + +@dataclass(slots=True) +class OntologyRiskGraphMapping: + ontology_parse_id: str + ontology_version: str + domain: str + scenario: str + intent: str + confidence: Decimal + gate: str + nodes: list[RiskGraphNode] = field(default_factory=list) + edges: list[RiskGraphEdge] = field(default_factory=list) + risk_signals: list[NormalizedRiskSignal] = field(default_factory=list) + canonical_subject_key: str = "" + raw_payload: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "ontology_parse_id": self.ontology_parse_id, + "ontology_version": self.ontology_version, + "domain": self.domain, + "scenario": self.scenario, + "intent": self.intent, + "confidence": str(self.confidence), + "gate": self.gate, + "canonical_subject_key": self.canonical_subject_key, + "risk_signals": [item.as_dict() for item in self.risk_signals], + } + + +def map_ontology_to_risk_graph( + ontology: Any, + *, + ontology_parse_id: str = "", + ontology_version: str = "ontology.v1", +) -> OntologyRiskGraphMapping: + payload = _model_to_dict(ontology) + if not payload: + return OntologyRiskGraphMapping( + ontology_parse_id=ontology_parse_id, + ontology_version=ontology_version, + domain="unknown", + scenario="unknown", + intent="query", + confidence=Decimal("0"), + gate="candidate_only", + ) + + parse_id = str( + ontology_parse_id + or payload.get("ontology_parse_id") + or payload.get("parse_id") + or payload.get("run_id") + or "ontology_parse" + ) + scenario = str(payload.get("scenario") or "unknown") + intent = str(payload.get("intent") or "query") + domain = str(payload.get("domain") or scenario) + confidence = _to_decimal(payload.get("confidence")) + gate = _gate_from_confidence(confidence) + + nodes: list[RiskGraphNode] = [ + RiskGraphNode( + key=f"ontology:{parse_id}", + node_type="ontology_parse", + label=parse_id, + canonical_key=f"ontology:{parse_id}", + canonical_id=parse_id, + ontology_type="ontology_parse", + ontology_parse_id=parse_id, + ontology_version=ontology_version, + metadata={ + "scenario": scenario, + "intent": intent, + "domain": domain, + "confidence": str(confidence), + }, + ) + ] + edges: list[RiskGraphEdge] = [] + canonical_subject_key = "" + + for entity in list(payload.get("entities") or []): + entity_payload = _model_to_dict(entity) + raw_type = str(entity_payload.get("type") or "").strip().lower() + node_type = ONTOLOGY_NODE_TYPE_MAP.get(raw_type, raw_type or "entity") + value = str( + entity_payload.get("normalized_value") + or entity_payload.get("value") + or "" + ).strip() + if not value: + continue + key = f"{node_type}:{_canonical_key(value)}" + nodes.append( + RiskGraphNode( + key=key, + node_type=node_type, + label=value, + canonical_key=key, + canonical_id=_canonical_key(value), + ontology_type=raw_type or node_type, + ontology_parse_id=parse_id, + ontology_version=ontology_version, + metadata={ + "role": entity_payload.get("role") or "target", + "confidence": entity_payload.get("confidence") or 0, + }, + ) + ) + edges.append( + RiskGraphEdge( + source_key=f"ontology:{parse_id}", + target_key=key, + edge_type="ontology_extracts", + source="ontology", + metadata={"raw_type": raw_type}, + ) + ) + if not canonical_subject_key and node_type in {"employee", "claim", "vendor"}: + canonical_subject_key = key + + for constraint in list(payload.get("constraints") or []): + constraint_payload = _model_to_dict(constraint) + field = str(constraint_payload.get("field") or "").strip() + operator = str(constraint_payload.get("operator") or "").strip() + value = str(constraint_payload.get("value") or "").strip() + if not field or not value: + continue + key = f"constraint:{_canonical_key(field)}:{_canonical_key(value)}" + nodes.append( + RiskGraphNode( + key=key, + node_type="constraint", + label=f"{field} {operator} {value}".strip(), + canonical_key=key, + canonical_id=key, + ontology_type="constraint", + ontology_parse_id=parse_id, + ontology_version=ontology_version, + metadata=constraint_payload, + ) + ) + edges.append( + RiskGraphEdge( + source_key=f"ontology:{parse_id}", + target_key=key, + edge_type="ontology_constrains", + source="ontology", + ) + ) + + risk_signals = normalize_risk_signals(list(payload.get("risk_flags") or []), source="ontology") + for signal in risk_signals: + key = f"risk_signal:{signal.code}" + nodes.append( + RiskGraphNode( + key=key, + node_type="risk_signal", + label=signal.label, + canonical_key=key, + canonical_id=signal.code, + ontology_type="risk_signal", + ontology_parse_id=parse_id, + ontology_version=ontology_version, + metadata={"severity": signal.severity, "score": signal.score}, + ) + ) + edges.append( + RiskGraphEdge( + source_key=f"ontology:{parse_id}", + target_key=key, + edge_type="ontology_signals", + source="ontology", + metadata={"gate": gate}, + ) + ) + + return OntologyRiskGraphMapping( + ontology_parse_id=parse_id, + ontology_version=ontology_version, + domain=domain, + scenario=scenario, + intent=intent, + confidence=confidence, + gate=gate, + nodes=_dedupe_nodes(nodes), + edges=_dedupe_edges(edges), + risk_signals=risk_signals, + canonical_subject_key=canonical_subject_key, + raw_payload=payload, + ) + + +def _model_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if hasattr(value, "model_dump"): + return dict(value.model_dump(mode="json")) + if hasattr(value, "dict"): + return dict(value.dict()) + return {} + + +def _gate_from_confidence(confidence: Decimal) -> str: + if confidence >= Decimal("0.78"): + return "automatic" + if confidence >= Decimal("0.55"): + return "review" + return "candidate_only" + + +def _canonical_key(value: str) -> str: + return "_".join(str(value or "").strip().lower().split()) + + +def _to_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return Decimal("0") + + +def _dedupe_nodes(nodes: list[RiskGraphNode]) -> list[RiskGraphNode]: + by_key: dict[str, RiskGraphNode] = {} + for node in nodes: + by_key.setdefault(node.key, node) + return list(by_key.values()) + + +def _dedupe_edges(edges: list[RiskGraphEdge]) -> list[RiskGraphEdge]: + by_key: dict[tuple[str, str, str], RiskGraphEdge] = {} + for edge in edges: + if edge.edge_type not in ALLOWED_ONTOLOGY_EDGE_TYPES: + continue + by_key.setdefault(edge.edge_key(), edge) + return list(by_key.values()) diff --git a/server/src/app/algorithem/risk_graph/policy_knowledge_contract.py b/server/src/app/algorithem/risk_graph/policy_knowledge_contract.py new file mode 100644 index 0000000..e2958fe --- /dev/null +++ b/server/src/app/algorithem/risk_graph/policy_knowledge_contract.py @@ -0,0 +1,86 @@ +"""Output contract for finance policy knowledge organizing tasks.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True, slots=True) +class PolicySourceRef: + source_id: str + title: str + location: str = "" + page: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "source_id": self.source_id, + "title": self.title, + "location": self.location, + "page": self.page, + } + + +@dataclass(frozen=True, slots=True) +class PolicyKnowledgeItem: + policy_ref: str + title: str + summary: str + expense_type: str = "" + control_stage: str = "" + trigger_conditions: list[str] = field(default_factory=list) + source_refs: list[PolicySourceRef] = field(default_factory=list) + review_status: str = "pending_review" + + def as_dict(self) -> dict[str, Any]: + return { + "policy_ref": self.policy_ref, + "title": self.title, + "summary": self.summary, + "expense_type": self.expense_type, + "control_stage": self.control_stage, + "trigger_conditions": list(self.trigger_conditions), + "source_refs": [item.as_dict() for item in self.source_refs], + "review_status": self.review_status, + } + + +@dataclass(slots=True) +class PolicyKnowledgeOrganizingReport: + summary: str + categories: list[str] = field(default_factory=list) + knowledge_items: list[PolicyKnowledgeItem] = field(default_factory=list) + source_refs: list[PolicySourceRef] = field(default_factory=list) + open_questions: list[str] = field(default_factory=list) + next_actions: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "summary": self.summary, + "categories": list(self.categories), + "knowledge_items": [item.as_dict() for item in self.knowledge_items], + "source_refs": [item.as_dict() for item in self.source_refs], + "open_questions": list(self.open_questions), + "next_actions": list(self.next_actions), + "risk_policy_refs": self.risk_policy_refs(), + } + + def risk_policy_refs(self) -> list[str]: + return list( + dict.fromkeys( + item.policy_ref + for item in self.knowledge_items + if item.policy_ref and item.review_status in {"pending_review", "confirmed"} + ) + ) + + +def build_policy_ref(expense_type: str, signal: str, *, prefix: str = "policy") -> str: + expense = _token(expense_type) or "general" + risk_signal = _token(signal) or "control" + return f"{prefix}.{expense}.{risk_signal}" + + +def _token(value: str) -> str: + return "_".join(str(value or "").strip().lower().split()) diff --git a/server/src/app/algorithem/risk_graph/process_mining.py b/server/src/app/algorithem/risk_graph/process_mining.py new file mode 100644 index 0000000..4dcc436 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/process_mining.py @@ -0,0 +1,325 @@ +"""Object-centric process mining for financial risk events.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from .models import RiskGraphClaimSnapshot + +APPROVAL_EVENTS = {"approval_approved", "finance_approved", "claim_approved"} +PAYMENT_EVENTS = {"payment_requested", "payment_completed"} +RETURN_EVENTS = {"claim_returned", "approval_returned", "supplement_required"} +SUBMIT_EVENTS = {"claim_submitted", "application_submitted"} + + +@dataclass(slots=True) +class ObjectCentricEvent: + event_id: str + event_type: str + occurred_at: datetime + object_refs: dict[str, list[str]] + actor: str = "" + source: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "event_id": self.event_id, + "event_type": self.event_type, + "occurred_at": self.occurred_at.isoformat(), + "object_refs": {key: list(value) for key, value in self.object_refs.items()}, + "actor": self.actor, + "source": self.source, + "metadata": dict(self.metadata), + } + + +@dataclass(slots=True) +class ConformanceRisk: + risk_code: str + title: str + detail: str + severity: str + related_event_ids: list[str] = field(default_factory=list) + object_refs: dict[str, list[str]] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "risk_code": self.risk_code, + "title": self.title, + "detail": self.detail, + "severity": self.severity, + "related_event_ids": list(self.related_event_ids), + "object_refs": {key: list(value) for key, value in self.object_refs.items()}, + } + + +class ObjectCentricProcessMiner: + def build_from_claims( + self, + claims: list[RiskGraphClaimSnapshot], + ) -> list[ObjectCentricEvent]: + events: list[ObjectCentricEvent] = [] + for claim in claims: + events.extend(self._claim_events(claim)) + return sorted(events, key=lambda item: (item.occurred_at, item.event_id)) + + def build_from_dicts(self, rows: list[dict[str, Any]]) -> list[ObjectCentricEvent]: + events: list[ObjectCentricEvent] = [] + for index, row in enumerate(rows): + occurred_at = _datetime_from_value(row.get("occurred_at")) + if occurred_at is None: + continue + event_type = str(row.get("event_type") or "").strip() + if not event_type: + continue + events.append( + ObjectCentricEvent( + event_id=str(row.get("event_id") or f"event:{index}:{event_type}"), + event_type=event_type, + occurred_at=occurred_at, + object_refs=_normalize_object_refs(row.get("object_refs")), + actor=str(row.get("actor") or "").strip(), + source=str(row.get("source") or "").strip(), + metadata=dict(row.get("metadata") or {}), + ) + ) + return sorted(events, key=lambda item: (item.occurred_at, item.event_id)) + + def _claim_events(self, claim: RiskGraphClaimSnapshot) -> list[ObjectCentricEvent]: + object_refs = _claim_object_refs(claim) + events: list[ObjectCentricEvent] = [] + occurred_at = claim.occurred_at or claim.submitted_at + if occurred_at: + events.append( + ObjectCentricEvent( + event_id=f"{claim.claim_id}:expense_occurred", + event_type="expense_occurred", + occurred_at=occurred_at, + object_refs=object_refs, + actor=claim.employee_id or claim.employee_name, + source="expense_claim", + metadata={"amount": str(claim.amount), "expense_type": claim.expense_type}, + ) + ) + if claim.submitted_at: + events.append( + ObjectCentricEvent( + event_id=f"{claim.claim_id}:claim_submitted", + event_type="claim_submitted", + occurred_at=claim.submitted_at, + object_refs=object_refs, + actor=claim.employee_id or claim.employee_name, + source="expense_claim", + metadata={"status": claim.status}, + ) + ) + for item in claim.items: + item_time = _datetime_from_value(item.item_date) or occurred_at or datetime.now(UTC) + item_refs = _merge_object_refs( + object_refs, + { + "claim_item": [item.item_id] if item.item_id else [], + "invoice": [item.invoice_id] if item.invoice_id else [], + }, + ) + events.append( + ObjectCentricEvent( + event_id=f"{claim.claim_id}:item:{item.item_id or len(events)}", + event_type="expense_item_recorded", + occurred_at=item_time, + object_refs=item_refs, + actor=claim.employee_id or claim.employee_name, + source="expense_item", + metadata={ + "amount": str(item.item_amount), + "item_type": item.item_type, + "item_location": item.item_location, + }, + ) + ) + if item.invoice_id: + events.append( + ObjectCentricEvent( + event_id=f"{claim.claim_id}:invoice:{item.invoice_id}", + event_type="invoice_attached", + occurred_at=item_time, + object_refs=item_refs, + actor=claim.employee_id or claim.employee_name, + source="invoice", + ) + ) + for index, flag in enumerate(claim.risk_flags): + signal = _risk_signal_from_flag(flag) + if not signal: + continue + events.append( + ObjectCentricEvent( + event_id=f"{claim.claim_id}:risk_flag:{index}:{signal}", + event_type="risk_flagged", + occurred_at=claim.submitted_at or occurred_at or datetime.now(UTC), + object_refs=object_refs, + source="risk_rule", + metadata={"risk_signal": signal, "raw": flag}, + ) + ) + return events + + +class ConformanceRiskDetector: + def detect(self, events: list[ObjectCentricEvent]) -> list[ConformanceRisk]: + risks: list[ConformanceRisk] = [] + for claim_key, claim_events in _events_by_object(events, "claim").items(): + ordered = sorted(claim_events, key=lambda item: (item.occurred_at, item.event_id)) + risks.extend(self._detect_claim_risks(claim_key, ordered)) + return risks + + def _detect_claim_risks( + self, + claim_key: str, + events: list[ObjectCentricEvent], + ) -> list[ConformanceRisk]: + risks: list[ConformanceRisk] = [] + event_types = [event.event_type for event in events] + first_submit = _first_event(events, SUBMIT_EVENTS) + first_approval = _first_event(events, APPROVAL_EVENTS) + first_payment = _first_event(events, PAYMENT_EVENTS) + + if first_payment and (not first_approval or first_payment.occurred_at < first_approval.occurred_at): + related = [first_payment.event_id] + if first_approval: + related.append(first_approval.event_id) + risks.append( + ConformanceRisk( + risk_code="payment_before_approval", + title="Payment before approval", + detail="Payment event appears before an approval event.", + severity="critical", + related_event_ids=related, + object_refs={"claim": [claim_key]}, + ) + ) + if first_approval and (not first_submit or first_approval.occurred_at < first_submit.occurred_at): + related = [first_approval.event_id] + if first_submit: + related.append(first_submit.event_id) + risks.append( + ConformanceRisk( + risk_code="approval_bypass", + title="Approval bypass", + detail="Approval appears before submission or without submission.", + severity="high", + related_event_ids=related, + object_refs={"claim": [claim_key]}, + ) + ) + return_count = sum(1 for event_type in event_types if event_type in RETURN_EVENTS) + submit_count = sum(1 for event_type in event_types if event_type in SUBMIT_EVENTS) + if return_count >= 2 or (return_count >= 1 and submit_count >= 2): + risks.append( + ConformanceRisk( + risk_code="rework_loop", + title="Rework loop", + detail="Claim has repeated return and resubmission events.", + severity="medium", + related_event_ids=[ + event.event_id + for event in events + if event.event_type in RETURN_EVENTS | SUBMIT_EVENTS + ], + object_refs={"claim": [claim_key]}, + ) + ) + if "invoice_attached" in event_types and not first_submit: + risks.append( + ConformanceRisk( + risk_code="process_bypass", + title="Process bypass", + detail="Invoice exists without a claim submission event.", + severity="medium", + related_event_ids=[ + event.event_id for event in events if event.event_type == "invoice_attached" + ], + object_refs={"claim": [claim_key]}, + ) + ) + return risks + + +def _claim_object_refs(claim: RiskGraphClaimSnapshot) -> dict[str, list[str]]: + return { + "claim": [claim.claim_id] if claim.claim_id else [], + "employee": [claim.employee_id or claim.employee_name] + if claim.employee_id or claim.employee_name + else [], + "department": [claim.department_id or claim.department_name] + if claim.department_id or claim.department_name + else [], + "expense_type": [claim.expense_type] if claim.expense_type else [], + } + + +def _normalize_object_refs(value: Any) -> dict[str, list[str]]: + if not isinstance(value, dict): + return {} + normalized: dict[str, list[str]] = {} + for key, raw_items in value.items(): + if isinstance(raw_items, list): + items = [str(item).strip() for item in raw_items if str(item).strip()] + else: + items = [str(raw_items).strip()] if str(raw_items or "").strip() else [] + normalized[str(key).strip()] = list(dict.fromkeys(items)) + return normalized + + +def _merge_object_refs(*refs: dict[str, list[str]]) -> dict[str, list[str]]: + merged: dict[str, list[str]] = {} + for ref in refs: + for key, values in ref.items(): + bucket = merged.setdefault(key, []) + bucket.extend(str(value).strip() for value in values if str(value).strip()) + return {key: list(dict.fromkeys(values)) for key, values in merged.items()} + + +def _events_by_object( + events: list[ObjectCentricEvent], + object_type: str, +) -> dict[str, list[ObjectCentricEvent]]: + grouped: dict[str, list[ObjectCentricEvent]] = {} + for event in events: + for object_key in event.object_refs.get(object_type, []): + grouped.setdefault(object_key, []).append(event) + return grouped + + +def _first_event( + events: list[ObjectCentricEvent], + event_types: set[str], +) -> ObjectCentricEvent | None: + for event in events: + if event.event_type in event_types: + return event + return None + + +def _risk_signal_from_flag(flag: Any) -> str: + if isinstance(flag, dict): + raw = flag.get("risk_signal") or flag.get("signal") or flag.get("rule_code") or flag.get("code") + else: + raw = flag + return "_".join(str(raw or "").strip().lower().split()) + + +def _datetime_from_value(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + if hasattr(value, "year") and hasattr(value, "month") and hasattr(value, "day"): + return datetime(value.year, value.month, value.day, tzinfo=UTC) + if isinstance(value, str) and value.strip(): + try: + return datetime.fromisoformat(value) + except ValueError: + return None + return None diff --git a/server/src/app/algorithem/risk_graph/profile_baselines.py b/server/src/app/algorithem/risk_graph/profile_baselines.py new file mode 100644 index 0000000..5f97797 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/profile_baselines.py @@ -0,0 +1,259 @@ +"""Profile baseline contracts for digital employee scans.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal +from typing import Any + +from .models import ALGORITHM_VERSION, RiskGraphClaimSnapshot + +ZERO = Decimal("0") +HUNDRED = Decimal("100") + +BASELINE_ALGORITHM_VERSION = f"{ALGORITHM_VERSION}.profile_baselines.v1" +BASELINE_DIMENSIONS = ("employee", "department", "supplier", "expense_type") +SUPPLIER_ID_KEYS = ("supplier_id", "vendor_id", "merchant_id", "supplier_code") +SUPPLIER_NAME_KEYS = ("supplier_name", "vendor_name", "merchant_name", "supplier", "vendor", "merchant") + + +@dataclass(frozen=True, slots=True) +class ProfileBaselineBucket: + dimension: str + key: str + label: str + sample_size: int + claim_count: int + total_amount: Decimal + average_amount: Decimal + median_amount: Decimal + p75_amount: Decimal + p90_amount: Decimal + claim_ids: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "dimension": self.dimension, + "key": self.key, + "label": self.label, + "sample_size": self.sample_size, + "claim_count": self.claim_count, + "total_amount": _format_decimal(self.total_amount), + "average_amount": _format_decimal(self.average_amount), + "median_amount": _format_decimal(self.median_amount), + "p75_amount": _format_decimal(self.p75_amount), + "p90_amount": _format_decimal(self.p90_amount), + "claim_ids": list(self.claim_ids), + } + + +@dataclass(frozen=True, slots=True) +class ProfileBaselineSnapshot: + algorithm_version: str + buckets: list[ProfileBaselineBucket] = field(default_factory=list) + + @property + def dimension_counts(self) -> dict[str, int]: + counts = {dimension: 0 for dimension in BASELINE_DIMENSIONS} + for bucket in self.buckets: + counts[bucket.dimension] = counts.get(bucket.dimension, 0) + 1 + return counts + + def buckets_for(self, dimension: str) -> list[ProfileBaselineBucket]: + return [bucket for bucket in self.buckets if bucket.dimension == dimension] + + def as_dict(self) -> dict[str, Any]: + return { + "algorithm_version": self.algorithm_version, + "dimension_counts": self.dimension_counts, + "bucket_count": len(self.buckets), + "buckets": [bucket.as_dict() for bucket in self.buckets], + } + + +class ProfileBaselineUpdater: + def build_from_claims( + self, + claims: list[RiskGraphClaimSnapshot], + ) -> ProfileBaselineSnapshot: + grouped: dict[tuple[str, str], list[tuple[Decimal, str]]] = defaultdict(list) + labels: dict[tuple[str, str], str] = {} + + for claim in claims: + self._add_claim_rows(grouped, labels, claim) + + buckets = [ + _build_bucket(dimension, key, labels[(dimension, key)], rows) + for (dimension, key), rows in grouped.items() + ] + buckets.sort(key=lambda item: (item.dimension, -item.total_amount, item.key)) + return ProfileBaselineSnapshot( + algorithm_version=BASELINE_ALGORITHM_VERSION, + buckets=buckets, + ) + + def _add_claim_rows( + self, + grouped: dict[tuple[str, str], list[tuple[Decimal, str]]], + labels: dict[tuple[str, str], str], + claim: RiskGraphClaimSnapshot, + ) -> None: + amount = _to_decimal(claim.amount) + claim_id = claim.claim_id or claim.claim_no + _add_row( + grouped, + labels, + "employee", + claim.employee_id or claim.employee_name, + claim.employee_name or claim.employee_id, + amount, + claim_id, + ) + _add_row( + grouped, + labels, + "department", + claim.department_id or claim.department_name, + claim.department_name or claim.department_id, + amount, + claim_id, + ) + _add_row( + grouped, + labels, + "expense_type", + claim.expense_type, + claim.expense_type, + amount, + claim_id, + ) + for supplier_key, supplier_label, supplier_amount in _supplier_rows(claim): + _add_row( + grouped, + labels, + "supplier", + supplier_key, + supplier_label, + supplier_amount, + claim_id, + ) + + +def _build_bucket( + dimension: str, + key: str, + label: str, + rows: list[tuple[Decimal, str]], +) -> ProfileBaselineBucket: + amounts = [amount for amount, _claim_id in rows] + total = sum(amounts, ZERO) + sample_size = len(amounts) + claim_ids = sorted({claim_id for _amount, claim_id in rows if claim_id}) + average = total / Decimal(sample_size) if sample_size else ZERO + return ProfileBaselineBucket( + dimension=dimension, + key=key, + label=label, + sample_size=sample_size, + claim_count=len(claim_ids), + total_amount=total, + average_amount=average, + median_amount=_percentile(amounts, 50), + p75_amount=_percentile(amounts, 75), + p90_amount=_percentile(amounts, 90), + claim_ids=claim_ids, + ) + + +def _add_row( + grouped: dict[tuple[str, str], list[tuple[Decimal, str]]], + labels: dict[tuple[str, str], str], + dimension: str, + key_source: Any, + label_source: Any, + amount: Decimal, + claim_id: str, +) -> None: + key = _canonical_key(key_source) + if not key: + return + group_key = (dimension, key) + labels.setdefault(group_key, str(label_source or key_source or key).strip() or key) + grouped[group_key].append((amount, claim_id)) + + +def _supplier_rows(claim: RiskGraphClaimSnapshot) -> list[tuple[str, str, Decimal]]: + item_rows: list[tuple[str, str, Decimal]] = [] + for item in claim.items: + supplier = _extract_supplier(item.metadata) + if supplier is not None: + item_rows.append((*supplier, _to_decimal(item.item_amount))) + if item_rows: + return item_rows + + supplier = _extract_supplier(claim.metadata) or _extract_supplier_from_flags(claim.risk_flags) + if supplier is None: + return [] + return [(*supplier, _to_decimal(claim.amount))] + + +def _extract_supplier(metadata: Any) -> tuple[str, str] | None: + if not isinstance(metadata, dict): + return None + supplier_id = _first_text(metadata, SUPPLIER_ID_KEYS) + supplier_name = _first_text(metadata, SUPPLIER_NAME_KEYS) + key = supplier_id or supplier_name + if not key: + return None + return key, supplier_name or supplier_id or key + + +def _extract_supplier_from_flags(flags: list[Any]) -> tuple[str, str] | None: + for flag in flags or []: + if not isinstance(flag, dict): + continue + supplier = _extract_supplier(flag) or _extract_supplier(flag.get("metadata")) + if supplier is not None: + return supplier + return None + + +def _first_text(source: dict[str, Any], keys: tuple[str, ...]) -> str: + for key in keys: + value = str(source.get(key) or "").strip() + if value: + return value + return "" + + +def _percentile(values: list[Decimal], percent: int) -> Decimal: + normalized = sorted(value for value in values if value >= ZERO) + if not normalized: + return ZERO + if len(normalized) == 1: + return normalized[0] + position = Decimal(len(normalized) - 1) * Decimal(percent) / HUNDRED + lower = int(position.to_integral_value(rounding=ROUND_FLOOR)) + upper = int(position.to_integral_value(rounding=ROUND_CEILING)) + if lower == upper: + return normalized[lower] + fraction = position - Decimal(lower) + return normalized[lower] + (normalized[upper] - normalized[lower]) * fraction + + +def _to_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return ZERO + + +def _format_decimal(value: Any) -> str: + if not isinstance(value, Decimal): + value = _to_decimal(value) + return format(value.quantize(Decimal("0.0001")), "f").rstrip("0").rstrip(".") or "0" + + +def _canonical_key(value: Any) -> str: + return "_".join(str(value or "").strip().lower().split()) diff --git a/server/src/app/algorithem/risk_graph/quality.py b/server/src/app/algorithem/risk_graph/quality.py new file mode 100644 index 0000000..37bc365 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/quality.py @@ -0,0 +1,84 @@ +"""Data quality gates for strong financial risk conclusions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from .models import RiskGraphClaimSnapshot + + +@dataclass(slots=True) +class RiskDataQualityResult: + passed: bool + gate: str + max_risk_score: int + missing_fields: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "passed": self.passed, + "gate": self.gate, + "max_risk_score": self.max_risk_score, + "missing_fields": list(self.missing_fields), + "warnings": list(self.warnings), + } + + +class RiskDataQualityGate: + """Prevent weak source data from becoming strong automated conclusions.""" + + def evaluate_claim(self, claim: RiskGraphClaimSnapshot) -> RiskDataQualityResult: + missing_fields: list[str] = [] + warnings: list[str] = [] + + if not str(claim.claim_id or "").strip(): + missing_fields.append("claim_id") + if not (str(claim.employee_id or "").strip() or str(claim.employee_name or "").strip()): + missing_fields.append("employee") + if _to_decimal(claim.amount) <= Decimal("0"): + missing_fields.append("amount") + if not str(claim.expense_type or "").strip(): + warnings.append("expense_type") + if claim.invoice_count > 0 and not claim.items: + warnings.append("invoice_items") + + if missing_fields: + return RiskDataQualityResult( + passed=False, + gate="capped_missing_required_fields", + max_risk_score=69, + missing_fields=missing_fields, + warnings=warnings, + ) + if len(warnings) >= 2: + return RiskDataQualityResult( + passed=False, + gate="capped_low_context_quality", + max_risk_score=69, + warnings=warnings, + ) + return RiskDataQualityResult( + passed=True, + gate="passed", + max_risk_score=100, + warnings=warnings, + ) + + def apply_score_cap( + self, + risk_score: int, + result: RiskDataQualityResult, + ) -> tuple[int, str]: + if risk_score > result.max_risk_score: + return result.max_risk_score, result.gate + return risk_score, result.gate + + +def _to_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return Decimal("0") diff --git a/server/src/app/algorithem/risk_graph/replay.py b/server/src/app/algorithem/risk_graph/replay.py new file mode 100644 index 0000000..810f292 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/replay.py @@ -0,0 +1,93 @@ +"""Replay-set contracts for risk graph algorithm evaluation.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True, slots=True) +class AlgorithmReplayCase: + replay_case_id: str + claim_id: str + ontology_version: str + rule_version: str + algorithm_version: str + feedback_label: str + payload: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "replay_case_id": self.replay_case_id, + "claim_id": self.claim_id, + "ontology_version": self.ontology_version, + "rule_version": self.rule_version, + "algorithm_version": self.algorithm_version, + "feedback_label": self.feedback_label, + "payload": dict(self.payload), + } + + +@dataclass(slots=True) +class AlgorithmReplaySet: + replay_set_id: str + created_at: datetime + cases: list[AlgorithmReplayCase] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "replay_set_id": self.replay_set_id, + "created_at": self.created_at.isoformat(), + "case_count": len(self.cases), + "cases": [item.as_dict() for item in self.cases], + } + + +class AlgorithmReplaySetBuilder: + def build_from_observations( + self, + replay_set_id: str, + observations: list[dict[str, Any]], + *, + created_at: datetime, + ) -> AlgorithmReplaySet: + cases = [ + self._case_from_observation(index, observation) + for index, observation in enumerate(observations, start=1) + ] + return AlgorithmReplaySet( + replay_set_id=replay_set_id, + created_at=created_at, + cases=cases, + ) + + def _case_from_observation( + self, + index: int, + observation: dict[str, Any], + ) -> AlgorithmReplayCase: + ontology = observation.get("ontology_json") or {} + trace = observation.get("decision_trace") or {} + return AlgorithmReplayCase( + replay_case_id=str( + observation.get("evaluation_case_id") + or trace.get("evaluation_case_id") + or f"replay:{index}:{observation.get('observation_key') or 'observation'}" + ), + claim_id=str(observation.get("claim_id") or ""), + ontology_version=str(ontology.get("ontology_version") or ""), + rule_version=str(trace.get("rule_version") or ""), + algorithm_version=str(observation.get("algorithm_version") or ""), + feedback_label=str( + observation.get("feedback_status") + or observation.get("status") + or "unreviewed" + ), + payload={ + "risk_signal": observation.get("risk_signal"), + "risk_score": observation.get("risk_score"), + "risk_level": observation.get("risk_level"), + "decision_trace": trace, + }, + ) diff --git a/server/src/app/algorithem/risk_graph/rule_discovery.py b/server/src/app/algorithem/risk_graph/rule_discovery.py new file mode 100644 index 0000000..2e0ba35 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/rule_discovery.py @@ -0,0 +1,106 @@ +"""Candidate risk rule discovery from reviewed risk observations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True, slots=True) +class CandidateRiskRule: + candidate_id: str + rule_code: str + title: str + risk_signal: str + evidence: list[dict[str, Any]] + source: str + confidence_score: float + status: str = "candidate_review" + + def as_dict(self) -> dict[str, Any]: + return { + "candidate_id": self.candidate_id, + "rule_code": self.rule_code, + "title": self.title, + "risk_signal": self.risk_signal, + "evidence": list(self.evidence), + "source": self.source, + "confidence_score": self.confidence_score, + "status": self.status, + } + + +class CandidateRiskRuleDiscovery: + def discover_from_feedback( + self, + observations: list[dict[str, Any]], + feedback_items: list[dict[str, Any]], + ) -> list[CandidateRiskRule]: + observation_by_key = { + str(item.get("observation_key") or item.get("id") or ""): item + for item in observations + } + candidates: list[CandidateRiskRule] = [] + for feedback in feedback_items: + source = str(feedback.get("candidate_rule_source") or "").strip() + decision = str(feedback.get("decision") or feedback.get("feedback_type") or "").strip() + if source != "risk_observation_feedback" and "candidate" not in decision: + continue + observation_key = str(feedback.get("observation_key") or "").strip() + observation = observation_by_key.get(observation_key, {}) + risk_signal = str( + feedback.get("risk_signal") or observation.get("risk_signal") or "" + ).strip() + if not risk_signal: + continue + confidence = _confidence(feedback, observation) + candidates.append( + CandidateRiskRule( + candidate_id=f"candidate:{observation_key or risk_signal}:{risk_signal}", + rule_code=f"candidate.risk.{risk_signal}", + title=f"{risk_signal} candidate rule", + risk_signal=risk_signal, + evidence=_candidate_evidence(observation, feedback), + source=source or "risk_observation_feedback", + confidence_score=confidence, + ) + ) + return _dedupe_candidates(candidates) + + +def _confidence(feedback: dict[str, Any], observation: dict[str, Any]) -> float: + raw = feedback.get("confidence_score") + if raw in (None, ""): + raw = observation.get("confidence_score") + try: + return max(0.0, min(1.0, float(raw or 0.55))) + except (TypeError, ValueError): + return 0.55 + + +def _candidate_evidence( + observation: dict[str, Any], + feedback: dict[str, Any], +) -> list[dict[str, Any]]: + evidence: list[dict[str, Any]] = [] + for item in observation.get("evidence", []) or []: + if isinstance(item, dict): + evidence.append({"source": item.get("source") or "observation", **item}) + evidence.append( + { + "source": feedback.get("candidate_rule_source") or "risk_observation_feedback", + "feedback_type": feedback.get("feedback_type"), + "action": feedback.get("action"), + "comment": feedback.get("comment"), + } + ) + return evidence + + +def _dedupe_candidates(candidates: list[CandidateRiskRule]) -> list[CandidateRiskRule]: + by_code: dict[str, CandidateRiskRule] = {} + for candidate in candidates: + current = by_code.get(candidate.rule_code) + if current is None or candidate.confidence_score > current.confidence_score: + by_code[candidate.rule_code] = candidate + return list(by_code.values()) diff --git a/server/src/app/algorithem/risk_graph/sampling.py b/server/src/app/algorithem/risk_graph/sampling.py new file mode 100644 index 0000000..7c6c18e --- /dev/null +++ b/server/src/app/algorithem/risk_graph/sampling.py @@ -0,0 +1,94 @@ +"""Risk-based sampling strategy for audit review and replay.""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from .models import RiskHistoryStats + + +@dataclass(slots=True) +class RiskSamplingDecision: + strategy: str + threshold: int + replay_bucket: str + audit_required: bool + reason: str + + def as_dict(self) -> dict[str, Any]: + return { + "strategy": self.strategy, + "threshold": self.threshold, + "replay_bucket": self.replay_bucket, + "audit_required": self.audit_required, + "reason": self.reason, + } + + +class RiskSamplingPlanner: + def plan( + self, + *, + risk_score: int, + confidence: Decimal, + evidence_source_count: int, + data_quality_passed: bool = True, + data_quality_gate: str = "", + history: RiskHistoryStats | None = None, + ) -> RiskSamplingDecision: + false_positive_rate = _false_positive_rate(history) + if not data_quality_passed: + return RiskSamplingDecision( + strategy="uncertainty_sample", + threshold=45, + replay_bucket="data_quality_gate", + audit_required=True, + reason=data_quality_gate or "data_quality_gate_not_passed", + ) + if risk_score >= 90: + return RiskSamplingDecision( + strategy="mandatory_review", + threshold=90, + replay_bucket="critical_high_risk", + audit_required=True, + reason="risk_score_above_critical_threshold", + ) + if risk_score >= 70: + return RiskSamplingDecision( + strategy="focused_review", + threshold=70, + replay_bucket="high_risk", + audit_required=True, + reason="risk_score_above_high_threshold", + ) + if false_positive_rate >= Decimal("0.30"): + return RiskSamplingDecision( + strategy="calibration_sample", + threshold=45, + replay_bucket="false_positive_calibration", + audit_required=True, + reason="historical_false_positive_rate_high", + ) + if confidence < Decimal("0.55") or evidence_source_count < 2: + return RiskSamplingDecision( + strategy="uncertainty_sample", + threshold=45, + replay_bucket="low_confidence", + audit_required=True, + reason="confidence_or_evidence_source_insufficient", + ) + return RiskSamplingDecision( + strategy="monitor", + threshold=31, + replay_bucket="routine_monitoring", + audit_required=False, + reason="below_review_threshold", + ) + + +def _false_positive_rate(history: RiskHistoryStats | None) -> Decimal: + if history is None or history.similar_case_count <= 0: + return Decimal("0") + return Decimal(history.false_positive_count) / Decimal(history.similar_case_count) diff --git a/server/src/app/algorithem/risk_graph/signals.py b/server/src/app/algorithem/risk_graph/signals.py new file mode 100644 index 0000000..f696a8b --- /dev/null +++ b/server/src/app/algorithem/risk_graph/signals.py @@ -0,0 +1,230 @@ +"""Risk signal normalization shared by rules, ontology, and graph scoring.""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +SEVERITY_SCORE = { + "info": 12, + "low": 32, + "medium": 58, + "high": 82, + "critical": 100, +} + +SIGNAL_ALIASES: dict[str, str] = { + "amount_over_limit": "amount_limit_exceeded", + "over_budget": "budget_overrun", + "budget_exceeded": "budget_overrun", + "duplicate_expense": "duplicate_invoice", + "duplicate_ticket": "duplicate_invoice", + "risk.invoice.duplicate_invoice": "duplicate_invoice", + "location_mismatch": "location_mismatch", + "city_mismatch": "location_mismatch", + "hotel_itinerary_mismatch": "hotel_itinerary_mismatch", + "date_outside_trip": "date_outside_trip", + "preapproval_absent": "preapproval_absent", + "application_fields_missing": "application_fields_missing", + "attachment_ocr_missing": "attachment_missing", + "missing_attachment": "attachment_missing", + "reason_too_brief": "reason_too_brief", + "vague_ticket_content": "vague_goods_description", + "personal_purpose": "personal_purpose", + "split_billing": "split_billing", + "frequency_anomaly": "frequency_anomaly", + "collusion": "cross_department_cluster", + "cross_department_cluster": "cross_department_cluster", + "buyer_name_mismatch": "buyer_name_mismatch", + "document_expense_mismatch": "document_expense_mismatch", + "void_or_red_invoice": "void_or_red_invoice", + "cross_year_invoice": "cross_year_invoice", + "entertainment_missing_detail": "entertainment_missing_detail", +} + +SIGNAL_LABELS: dict[str, str] = { + "amount_limit_exceeded": "Amount limit exceeded", + "budget_overrun": "Budget overrun", + "duplicate_invoice": "Duplicate invoice", + "location_mismatch": "Location mismatch", + "hotel_itinerary_mismatch": "Hotel and itinerary mismatch", + "date_outside_trip": "Date outside approved trip", + "preapproval_absent": "Pre-approval missing", + "application_fields_missing": "Application fields missing", + "attachment_missing": "Attachment missing", + "reason_too_brief": "Reason too brief", + "vague_goods_description": "Vague goods description", + "personal_purpose": "Possible personal purpose", + "split_billing": "Split billing pattern", + "frequency_anomaly": "Frequency anomaly", + "cross_department_cluster": "Cross-department spending cluster", + "buyer_name_mismatch": "Buyer name mismatch", + "document_expense_mismatch": "Document and expense mismatch", + "void_or_red_invoice": "Void or red invoice", + "cross_year_invoice": "Cross-year invoice", + "entertainment_missing_detail": "Entertainment detail missing", +} + +SIGNAL_DEFAULT_SEVERITY: dict[str, str] = { + "duplicate_invoice": "critical", + "personal_purpose": "high", + "preapproval_absent": "high", + "date_outside_trip": "high", + "amount_limit_exceeded": "high", + "budget_overrun": "high", + "split_billing": "high", + "cross_department_cluster": "high", + "location_mismatch": "medium", + "hotel_itinerary_mismatch": "medium", + "frequency_anomaly": "medium", + "buyer_name_mismatch": "medium", + "document_expense_mismatch": "medium", + "void_or_red_invoice": "high", + "cross_year_invoice": "medium", + "entertainment_missing_detail": "medium", + "application_fields_missing": "low", + "attachment_missing": "low", + "reason_too_brief": "low", + "vague_goods_description": "low", +} + +POLICY_BOUND_SIGNALS = { + "amount_limit_exceeded", + "budget_overrun", + "preapproval_absent", + "date_outside_trip", + "hotel_itinerary_mismatch", + "location_mismatch", + "document_expense_mismatch", + "buyer_name_mismatch", + "entertainment_missing_detail", + "application_fields_missing", + "attachment_missing", +} + + +@dataclass(slots=True) +class NormalizedRiskSignal: + code: str + raw_code: str + label: str + severity: str + score: int + confidence: Decimal = Decimal("1") + source: str = "rule" + metadata: dict[str, Any] | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "raw_code": self.raw_code, + "label": self.label, + "severity": self.severity, + "score": self.score, + "confidence": str(self.confidence), + "source": self.source, + "metadata": self.metadata or {}, + } + + +def normalize_risk_signal(value: Any, *, source: str = "rule") -> NormalizedRiskSignal | None: + if isinstance(value, dict): + raw_code = _first_present( + value, + "risk_signal", + "signal", + "code", + "risk_type", + "rule_code", + "type", + ) + severity = str(value.get("severity") or value.get("risk_level") or "").strip().lower() + confidence = _to_decimal(value.get("confidence") or value.get("score_confidence") or 1) + explicit_score = value.get("risk_score") or value.get("score") + metadata = dict(value) + else: + raw_code = str(value or "").strip() + severity = "" + confidence = Decimal("1") + explicit_score = None + metadata = {} + + if not raw_code: + return None + + canonical = SIGNAL_ALIASES.get(raw_code.strip().lower(), raw_code.strip().lower()) + canonical = canonical.replace(" ", "_") + severity = severity or SIGNAL_DEFAULT_SEVERITY.get(canonical, "medium") + score = _score_from_value(explicit_score, severity=severity) + return NormalizedRiskSignal( + code=canonical, + raw_code=raw_code, + label=SIGNAL_LABELS.get(canonical, canonical.replace("_", " ").title()), + severity=severity, + score=score, + confidence=max(Decimal("0"), min(Decimal("1"), confidence)), + source=source, + metadata=metadata, + ) + + +def normalize_risk_signals( + values: list[Any], + *, + source: str = "rule", +) -> list[NormalizedRiskSignal]: + by_code: dict[str, NormalizedRiskSignal] = {} + for value in values: + signal = normalize_risk_signal(value, source=source) + if signal is None: + continue + current = by_code.get(signal.code) + if current is None or signal.score > current.score: + by_code[signal.code] = signal + return sorted(by_code.values(), key=lambda item: (item.score, item.code), reverse=True) + + +def policy_refs_for_signal(signal_code: str) -> list[str]: + signal_code = SIGNAL_ALIASES.get(str(signal_code or "").strip().lower(), signal_code) + if signal_code not in POLICY_BOUND_SIGNALS: + return [] + return [f"policy.{signal_code}"] + + +def severity_from_score(score: int) -> str: + normalized = max(0, min(100, int(score or 0))) + if normalized >= 90: + return "critical" + if normalized >= 70: + return "high" + if normalized >= 45: + return "medium" + return "low" + + +def _first_present(value: dict[str, Any], *keys: str) -> str: + for key in keys: + candidate = str(value.get(key) or "").strip() + if candidate: + return candidate + return "" + + +def _score_from_value(value: Any, *, severity: str) -> int: + if value is None or value == "": + return SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"]) + try: + numeric = Decimal(str(value)) + except Exception: + return SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"]) + if numeric <= Decimal("1"): + numeric *= Decimal("100") + return max(0, min(100, int(numeric.to_integral_value()))) + + +def _to_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value)) + except Exception: + return Decimal("0") diff --git a/server/src/app/algorithem/risk_graph/temporal.py b/server/src/app/algorithem/risk_graph/temporal.py new file mode 100644 index 0000000..6f76391 --- /dev/null +++ b/server/src/app/algorithem/risk_graph/temporal.py @@ -0,0 +1,162 @@ +"""Temporal monitoring for risk graph relationship changes.""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from typing import Any + +from .models import RiskGraphEdge + + +@dataclass(slots=True) +class TemporalRiskGraphChange: + change_type: str + source_key: str + target_key: str + edge_type: str + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "change_type": self.change_type, + "source_key": self.source_key, + "target_key": self.target_key, + "edge_type": self.edge_type, + "metadata": dict(self.metadata), + } + + +@dataclass(slots=True) +class TemporalRiskGraphSnapshotDiff: + changes: list[TemporalRiskGraphChange] + edge_type_delta: dict[str, int] + + def as_dict(self) -> dict[str, Any]: + return { + "changes": [item.as_dict() for item in self.changes], + "edge_type_delta": dict(self.edge_type_delta), + } + + +class TemporalRiskGraphMonitor: + def monitor( + self, + previous_edges: list[RiskGraphEdge], + current_edges: list[RiskGraphEdge], + *, + risk_node_keys: set[str] | None = None, + ) -> TemporalRiskGraphSnapshotDiff: + previous = {edge.edge_key(): edge for edge in previous_edges} + current = {edge.edge_key(): edge for edge in current_edges} + risk_keys = set(risk_node_keys or set()) + + changes: list[TemporalRiskGraphChange] = [] + for key, edge in current.items(): + if key not in previous: + changes.append(_change("relationship_added", edge)) + if edge.source_key in risk_keys or edge.target_key in risk_keys: + changes.append(_change("risk_propagation", edge)) + for key, edge in previous.items(): + if key not in current: + changes.append(_change("relationship_removed", edge)) + + changes.extend(_relationship_volume_changes(previous_edges, current_edges)) + changes.extend(_target_migrations(previous_edges, current_edges)) + return TemporalRiskGraphSnapshotDiff( + changes=changes, + edge_type_delta=_edge_type_delta(previous_edges, current_edges), + ) + + +def _change(change_type: str, edge: RiskGraphEdge, **metadata: Any) -> TemporalRiskGraphChange: + return TemporalRiskGraphChange( + change_type=change_type, + source_key=edge.source_key, + target_key=edge.target_key, + edge_type=edge.edge_type, + metadata=metadata, + ) + + +def _edge_type_delta( + previous_edges: list[RiskGraphEdge], + current_edges: list[RiskGraphEdge], +) -> dict[str, int]: + previous_counts = Counter(edge.edge_type for edge in previous_edges) + current_counts = Counter(edge.edge_type for edge in current_edges) + edge_types = set(previous_counts) | set(current_counts) + return { + edge_type: current_counts.get(edge_type, 0) - previous_counts.get(edge_type, 0) + for edge_type in sorted(edge_types) + } + + +def _relationship_volume_changes( + previous_edges: list[RiskGraphEdge], + current_edges: list[RiskGraphEdge], +) -> list[TemporalRiskGraphChange]: + changes: list[TemporalRiskGraphChange] = [] + previous_counts = Counter(edge.edge_type for edge in previous_edges) + current_by_type: dict[str, list[RiskGraphEdge]] = defaultdict(list) + for edge in current_edges: + current_by_type[edge.edge_type].append(edge) + for edge_type, current_group in current_by_type.items(): + previous_count = previous_counts.get(edge_type, 0) + current_count = len(current_group) + if current_count >= 3 and current_count >= max(1, previous_count) * 2: + changes.append( + _change( + "relationship_surge", + current_group[0], + previous_count=previous_count, + current_count=current_count, + ) + ) + previous_by_type: dict[str, list[RiskGraphEdge]] = defaultdict(list) + for edge in previous_edges: + previous_by_type[edge.edge_type].append(edge) + current_counts = Counter(edge.edge_type for edge in current_edges) + for edge_type, previous_group in previous_by_type.items(): + if len(previous_group) >= 3 and current_counts.get(edge_type, 0) == 0: + changes.append( + _change( + "relationship_disappeared", + previous_group[0], + previous_count=len(previous_group), + current_count=0, + ) + ) + return changes + + +def _target_migrations( + previous_edges: list[RiskGraphEdge], + current_edges: list[RiskGraphEdge], +) -> list[TemporalRiskGraphChange]: + previous_targets: dict[tuple[str, str], set[str]] = defaultdict(set) + current_targets: dict[tuple[str, str], set[str]] = defaultdict(set) + for edge in previous_edges: + previous_targets[(edge.source_key, edge.edge_type)].add(edge.target_key) + for edge in current_edges: + current_targets[(edge.source_key, edge.edge_type)].add(edge.target_key) + + changes: list[TemporalRiskGraphChange] = [] + for key, current_target_set in current_targets.items(): + previous_target_set = previous_targets.get(key, set()) + if previous_target_set and current_target_set != previous_target_set: + source_key, edge_type = key + target_key = sorted(current_target_set - previous_target_set or current_target_set)[0] + changes.append( + TemporalRiskGraphChange( + change_type="target_migration", + source_key=source_key, + target_key=target_key, + edge_type=edge_type, + metadata={ + "previous_targets": sorted(previous_target_set), + "current_targets": sorted(current_target_set), + }, + ) + ) + return changes diff --git a/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py b/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py new file mode 100644 index 0000000..86f9312 --- /dev/null +++ b/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Annotated, NoReturn + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import ( + CurrentUserContext, + get_db, + require_rule_editor_user, +) +from app.schemas.agent_asset import ( + AgentAssetRead, + AgentAssetRiskRuleDraftUpdate, + AgentAssetRiskRuleRevisionCreate, +) +from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService +from app.services.agent_assets import AgentAssetService + +router = APIRouter(prefix="/agent-assets") +DbSession = Annotated[Session, Depends(get_db)] +ActorHeader = Annotated[ + str | None, + Header(description="审计操作人。未传时使用当前登录用户名称。"), +] +RequestIdHeader = Annotated[ + str | None, + Header(description="外部请求 ID,用于串联审计日志和上游调用链。"), +] +RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)] + + +def _handle_asset_error(exc: Exception) -> NoReturn: + if isinstance(exc, (LookupError, FileNotFoundError)): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + if isinstance(exc, (PermissionError, ValueError)): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + raise exc + + +def _actor_name(current_user: CurrentUserContext, x_actor: str | None) -> str: + return (x_actor or current_user.name or current_user.username or "system").strip() or "system" + + +def _read_asset(db: Session, asset_id: str) -> AgentAssetRead: + asset = AgentAssetService(db).get_asset(asset_id) + if asset is None: + raise LookupError("Asset not found") + return asset + + +@router.patch( + "/{asset_id}/risk-rules/draft", + response_model=AgentAssetRead, + summary="编辑未上线风险规则草稿", + description="仅允许编辑从未上线的自然语言风险规则草稿或生成失败规则,不直接覆盖已上线版本。", +) +def update_risk_rule_draft( + asset_id: str, + payload: AgentAssetRiskRuleDraftUpdate, + current_user: RuleEditorUser, + db: DbSession, + x_actor: ActorHeader = None, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRead: + try: + AgentAssetRiskRuleRevisionService(db).update_unpublished_draft( + asset_id, + payload, + actor=_actor_name(current_user, x_actor), + request_id=x_request_id, + ) + return _read_asset(db, asset_id) + except Exception as exc: + _handle_asset_error(exc) + + +@router.post( + "/{asset_id}/risk-rules/revisions", + response_model=AgentAssetRead, + status_code=status.HTTP_201_CREATED, + summary="创建已上线风险规则修订草稿", + description="为已上线或已下线的自然语言风险规则创建修订草稿,保留当前生效版本不变。", +) +def create_risk_rule_revision( + asset_id: str, + payload: AgentAssetRiskRuleRevisionCreate, + current_user: RuleEditorUser, + db: DbSession, + x_actor: ActorHeader = None, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRead: + try: + AgentAssetRiskRuleRevisionService(db).create_revision_draft( + asset_id, + payload, + actor=_actor_name(current_user, x_actor), + request_id=x_request_id, + ) + return _read_asset(db, asset_id) + except Exception as exc: + _handle_asset_error(exc) diff --git a/server/src/app/api/v1/endpoints/agent_feedback.py b/server/src/app/api/v1/endpoints/agent_feedback.py new file mode 100644 index 0000000..554fa70 --- /dev/null +++ b/server/src/app/api/v1/endpoints/agent_feedback.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.schemas.agent_feedback import ( + AgentFeedbackCreate, + AgentFeedbackRead, + AgentFeedbackSummaryRead, +) +from app.services.agent_feedback import AgentFeedbackService + +router = APIRouter(prefix="/agent-feedback") +DbSession = Annotated[Session, Depends(get_db)] + + +@router.post( + "", + response_model=AgentFeedbackRead, + status_code=status.HTTP_201_CREATED, + summary="记录 Agent 操作评价", + description="记录用户对一次智能体处理结果的 1-5 星评价和低分原因。", +) +def create_agent_feedback(payload: AgentFeedbackCreate, db: DbSession) -> AgentFeedbackRead: + return AgentFeedbackService(db).create_feedback(payload) + + +@router.get( + "/summary", + response_model=AgentFeedbackSummaryRead, + summary="查询 Agent 操作评价统计", + description="按最近反馈记录汇总评分分布、低分数量和低分原因。", +) +def summarize_agent_feedback( + db: DbSession, + agent: Annotated[str | None, Query(description="Agent 名称筛选。")] = None, + session_type: Annotated[str | None, Query(description="会话类型筛选。")] = None, + limit: Annotated[int, Query(ge=1, le=500, description="统计最近记录数。")] = 200, +) -> AgentFeedbackSummaryRead: + return AgentFeedbackService(db).summarize_feedback( + agent=agent, + session_type=session_type, + limit=limit, + ) diff --git a/server/src/app/api/v1/endpoints/agent_runs.py b/server/src/app/api/v1/endpoints/agent_runs.py index a58b4be..1e25bbe 100644 --- a/server/src/app/api/v1/endpoints/agent_runs.py +++ b/server/src/app/api/v1/endpoints/agent_runs.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.api.deps import get_db -from app.schemas.agent_run import AgentRunRead +from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead from app.schemas.common import ErrorResponse from app.services.agent_runs import AgentRunService @@ -44,6 +44,39 @@ def list_agent_runs( ) +@router.get( + "/summary", + response_model=AgentRunStatsRead, + summary="查询 Agent 运行统计", + description="按最近运行记录实时汇总 Agent、工具调用、模型调用和错误统计。", +) +def summarize_agent_runs( + db: DbSession, + agent: Annotated[ + str | None, + Query(description="Agent 名称筛选。"), + ] = None, + status_value: Annotated[ + str | None, + Query(alias="status", description="运行状态筛选。"), + ] = None, + source: Annotated[ + str | None, + Query(description="运行来源筛选。"), + ] = None, + limit: Annotated[ + int, + Query(ge=1, le=500, description="统计最近记录数。"), + ] = 200, +) -> AgentRunStatsRead: + return AgentRunService(db).summarize_runs( + agent=agent, + status=status_value, + source=source, + limit=limit, + ) + + @router.get( "/{run_id}", response_model=AgentRunRead, diff --git a/server/src/app/api/v1/endpoints/analytics.py b/server/src/app/api/v1/endpoints/analytics.py new file mode 100644 index 0000000..293b9cf --- /dev/null +++ b/server/src/app/api/v1/endpoints/analytics.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.schemas.finance_dashboard import FinanceDashboardRead +from app.schemas.system_dashboard import SystemDashboardRead +from app.services.finance_dashboard import FinanceDashboardService +from app.services.system_dashboard import SystemDashboardService + +router = APIRouter(prefix="/analytics") +DbSession = Annotated[Session, Depends(get_db)] + + +@router.get( + "/system-dashboard", + response_model=SystemDashboardRead, + summary="查询系统看板真实指标", + description="基于 Agent 运行、工具调用、用户会话和反馈数据聚合系统看板指标。", +) +def get_system_dashboard( + db: DbSession, + days: Annotated[ + int, + Query(ge=1, le=30, description="统计窗口天数。"), + ] = 7, +) -> SystemDashboardRead: + return SystemDashboardService(db).build_dashboard(days=days) + + +@router.get( + "/finance-dashboard", + response_model=FinanceDashboardRead, + summary="查询财务看板真实指标", + description="基于报销单据、风险观察和预算池数据聚合财务看板指标。", +) +def get_finance_dashboard( + db: DbSession, + range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日", + start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None, + end_date: Annotated[date | None, Query(description="自定义结束日期。")] = None, + trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = "近12天", + department_range: Annotated[str, Query(max_length=30, description="部门排行时间范围。")] = "本月", +) -> FinanceDashboardRead: + return FinanceDashboardService(db).build_dashboard( + range_key=range_key, + start_date=start_date, + end_date=end_date, + trend_range=trend_range, + department_range=department_range, + ) diff --git a/server/src/app/api/v1/endpoints/auth.py b/server/src/app/api/v1/endpoints/auth.py index 8b7cb3b..24279df 100644 --- a/server/src/app/api/v1/endpoints/auth.py +++ b/server/src/app/api/v1/endpoints/auth.py @@ -6,9 +6,15 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.api.deps import get_db -from app.schemas.auth import LoginRequest, LoginResponse +from app.schemas.auth import ( + LoginRequest, + LoginResponse, + SessionFinishRequest, + SessionFinishResponse, +) from app.schemas.common import ErrorResponse from app.services.auth import AuthService +from app.services.user_session_metrics import UserSessionMetricService router = APIRouter(prefix="/auth") DbSession = Annotated[Session, Depends(get_db)] @@ -31,3 +37,32 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse: return AuthService(db).login(payload) except ValueError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + +@router.post( + "/sessions/{session_id}/finish", + response_model=SessionFinishResponse, + summary="结算用户在线会话", +) +def finish_session( + session_id: str, + payload: SessionFinishRequest, + db: DbSession, +) -> SessionFinishResponse: + session = UserSessionMetricService(db).finish_session( + session_id=session_id, + reason=payload.reason, + last_activity_at=payload.lastActivityAt, + activity_event_count=payload.activityEventCount, + event={"page_path": payload.pagePath}, + ) + if session is None: + return SessionFinishResponse( + detail="会话不存在或已被清理。", + sessionId=session_id, + durationMs=0, + ) + return SessionFinishResponse( + sessionId=session.session_id, + durationMs=int(session.duration_ms or 0), + ) diff --git a/server/src/app/api/v1/endpoints/employee_profiles.py b/server/src/app/api/v1/endpoints/employee_profiles.py index 42c46f5..5709420 100644 --- a/server/src/app/api/v1/endpoints/employee_profiles.py +++ b/server/src/app/api/v1/endpoints/employee_profiles.py @@ -124,7 +124,7 @@ def _missing_usage_duration_metric(latest: EmployeeProfileLatestRead) -> bool: for profile in latest.profiles: if profile.profile_type == "ai_usage": - return "ai_run_duration_ms" not in profile.metrics + return "usage_duration_ms" not in profile.metrics return False diff --git a/server/src/app/api/v1/endpoints/risk_observations.py b/server/src/app/api/v1/endpoints/risk_observations.py new file mode 100644 index 0000000..dd9a766 --- /dev/null +++ b/server/src/app/api/v1/endpoints/risk_observations.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.schemas.common import ErrorResponse +from app.schemas.risk_observation import ( + RiskObservationDashboardRead, + RiskObservationFeedbackCreate, + RiskObservationFeedbackRead, + RiskObservationListRead, + RiskObservationRead, +) +from app.services.risk_observations import RiskObservationService + +router = APIRouter(prefix="/risk-observations") +DbSession = Annotated[Session, Depends(get_db)] + + +@router.get( + "", + response_model=RiskObservationListRead, + summary="查询风险观察列表", + description="按单据、风险等级、风险信号、状态和来源筛选统一风险观察池。", +) +def list_risk_observations( + db: DbSession, + claim_id: Annotated[str | None, Query(max_length=80)] = None, + run_id: Annotated[str | None, Query(max_length=80)] = None, + execution_log_id: Annotated[str | None, Query(max_length=80)] = None, + risk_level: Annotated[str | None, Query(max_length=20)] = None, + risk_signal: Annotated[str | None, Query(max_length=100)] = None, + status_value: Annotated[ + str | None, + Query(alias="status", max_length=30), + ] = None, + source: Annotated[str | None, Query(max_length=60)] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, +) -> RiskObservationListRead: + items, total = RiskObservationService(db).list_observations( + claim_id=claim_id, + run_id=run_id, + execution_log_id=execution_log_id, + risk_level=risk_level, + risk_signal=risk_signal, + status=status_value, + source=source, + limit=limit, + offset=offset, + ) + return RiskObservationListRead(items=items, total=total, limit=limit, offset=offset) + + +@router.get( + "/dashboard", + response_model=RiskObservationDashboardRead, + summary="查询风险看板聚合", + description="返回风险观察池的总量、分布、算法效果和近期高风险记录。", +) +def summarize_risk_observations( + db: DbSession, + window_days: Annotated[int, Query(ge=1, le=365)] = 30, + limit: Annotated[int, Query(ge=1, le=2000)] = 500, +) -> RiskObservationDashboardRead: + return RiskObservationService(db).summarize_dashboard( + window_days=window_days, + limit=limit, + ) + + +@router.get( + "/claim/{claim_id}", + response_model=list[RiskObservationRead], + summary="查询单据风险观察", + description="按报销单 ID 返回该单据关联的风险观察,供单据详情证据链使用。", +) +def list_claim_risk_observations(claim_id: str, db: DbSession) -> list[RiskObservationRead]: + return RiskObservationService(db).list_claim_observations(claim_id) + + +@router.get( + "/execution-log/{execution_log_id}", + response_model=list[RiskObservationRead], + summary="查询数字员工工作记录风险观察", + description="按数字员工执行日志 ID 返回本次任务生成的风险观察。", +) +def list_execution_log_risk_observations( + execution_log_id: str, + db: DbSession, +) -> list[RiskObservationRead]: + return RiskObservationService(db).list_execution_log_observations(execution_log_id) + + +@router.get( + "/{observation_key_or_id}", + response_model=RiskObservationRead, + summary="读取风险观察详情", + description="按观察 key 或 ID 返回风险评分、证据链、图谱节点、制度引用和决策追踪。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "风险观察不存在。", + } + }, +) +def get_risk_observation( + observation_key_or_id: str, + db: DbSession, +) -> RiskObservationRead: + observation = RiskObservationService(db).get_observation(observation_key_or_id) + if observation is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Risk observation not found", + ) + return observation + + +@router.post( + "/{observation_key_or_id}/feedback", + response_model=RiskObservationFeedbackRead, + summary="写入风险观察反馈", + description="记录人工确认、误报、忽略、已处理或备注反馈,并同步更新观察状态。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "风险观察不存在。", + } + }, +) +def create_risk_observation_feedback( + observation_key_or_id: str, + payload: RiskObservationFeedbackCreate, + db: DbSession, +) -> RiskObservationFeedbackRead: + try: + return RiskObservationService(db).create_feedback(observation_key_or_id, payload) + except LookupError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Risk observation not found", + ) from None diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index b5ce6c2..3fff4c9 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -1,7 +1,10 @@ from fastapi import APIRouter +from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router from app.api.v1.endpoints.agent_assets import router as agent_assets_router +from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router from app.api.v1.endpoints.agent_runs import router as agent_runs_router +from app.api.v1.endpoints.analytics import router as analytics_router from app.api.v1.endpoints.audit_logs import router as audit_logs_router from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router @@ -15,6 +18,7 @@ from app.api.v1.endpoints.ontology import router as ontology_router from app.api.v1.endpoints.orchestrator import router as orchestrator_router from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router from app.api.v1.endpoints.reimbursements import router as reimbursements_router +from app.api.v1.endpoints.risk_observations import router as risk_observations_router from app.api.v1.endpoints.settings import router as settings_router from app.api.v1.endpoints.system_logs import router as system_logs_router @@ -24,7 +28,10 @@ router.include_router(bootstrap_router, tags=["bootstrap"]) router.include_router(auth_router, tags=["auth"]) router.include_router(budgets_router, tags=["budgets"]) router.include_router(agent_assets_router, tags=["agent-assets"]) +router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"]) +router.include_router(agent_feedback_router, tags=["agent-feedback"]) router.include_router(agent_runs_router, tags=["agent-runs"]) +router.include_router(analytics_router, tags=["analytics"]) router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(ocr_router, tags=["ocr"]) @@ -34,5 +41,6 @@ router.include_router(receipt_folder_router, tags=["receipt-folder"]) router.include_router(employees_router, prefix="/employees", tags=["employees"]) router.include_router(employee_profiles_router, tags=["employee-profiles"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) +router.include_router(risk_observations_router, tags=["risk-observations"]) router.include_router(settings_router, tags=["settings"]) router.include_router(system_logs_router, tags=["system-logs"]) diff --git a/server/src/app/core/openapi.py b/server/src/app/core/openapi.py index d23b35b..bffafcb 100644 --- a/server/src/app/core/openapi.py +++ b/server/src/app/core/openapi.py @@ -34,6 +34,7 @@ X-Financial 后端 OpenAPI 文档。 - Orchestrator 统一调度 - 系统设置与模型连通性 - Agent 资产、运行日志、审计日志 +- 系统分析看板指标聚合 """.strip() @@ -90,6 +91,14 @@ OPENAPI_TAGS = [ "name": "agent-runs", "description": "Agent 运行日志查询,包括工具调用和语义解析结果。", }, + { + "name": "agent-feedback", + "description": "Agent 处理结果用户评价与统计接口。", + }, + { + "name": "analytics", + "description": "分析看板聚合接口,包括系统工具调用、Token、在线时长和反馈指标。", + }, { "name": "audit-logs", "description": "系统审计日志查询接口,用于追踪资产和任务写操作。", diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 6296953..d210d68 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -1,6 +1,7 @@ from app.db.base_class import Base from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun, AgentAssetVersion +from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog @@ -18,10 +19,12 @@ from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_report import HermesRiskReport from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest +from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.role import Role from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret +from app.models.user_session_metric import UserSessionMetric __all__ = [ "Base", @@ -33,6 +36,7 @@ __all__ = [ "AgentAssetReview", "AgentAssetTestRun", "AgentAssetVersion", + "AgentOperationFeedback", "AgentRun", "AgentToolCall", "ApprovalRecord", @@ -50,9 +54,12 @@ __all__ = [ "HermesRiskReport", "OrganizationUnit", "ReimbursementRequest", + "RiskObservation", + "RiskObservationFeedback", "Role", "SemanticParseLog", "SystemModelSetting", "SystemSetting", "SystemSettingSecret", + "UserSessionMetric", ] diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index b3c7b8c..f6e8eda 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion +from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog @@ -17,10 +18,12 @@ from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_report import HermesRiskReport from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest +from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.role import Role from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret +from app.models.user_session_metric import UserSessionMetric __all__ = [ "AccountsPayableRecord", @@ -30,6 +33,7 @@ __all__ = [ "AgentAsset", "AgentAssetReview", "AgentAssetVersion", + "AgentOperationFeedback", "AgentRun", "AgentToolCall", "ApprovalRecord", @@ -47,9 +51,12 @@ __all__ = [ "HermesRiskReport", "OrganizationUnit", "ReimbursementRequest", + "RiskObservation", + "RiskObservationFeedback", "Role", "SemanticParseLog", "SystemModelSetting", "SystemSetting", "SystemSettingSecret", + "UserSessionMetric", ] diff --git a/server/src/app/models/agent_feedback.py b/server/src/app/models/agent_feedback.py new file mode 100644 index 0000000..9841788 --- /dev/null +++ b/server/src/app/models/agent_feedback.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Index, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class AgentOperationFeedback(Base): + __tablename__ = "agent_operation_feedback" + __table_args__ = ( + Index("ix_agent_operation_feedback_user_created", "user_id", "created_at"), + Index("ix_agent_operation_feedback_run_rating", "run_id", "rating"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + feedback_id: Mapped[str] = mapped_column( + String(50), + unique=True, + index=True, + default=lambda: f"fb_{uuid.uuid4().hex[:16]}", + ) + run_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + conversation_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + user_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + agent: Mapped[str] = mapped_column(String(30), default="", index=True) + source: Mapped[str] = mapped_column(String(30), default="", index=True) + session_type: Mapped[str] = mapped_column(String(30), default="", index=True) + operation_type: Mapped[str] = mapped_column(String(50), default="assistant_round", index=True) + operation_status: Mapped[str] = mapped_column(String(20), default="", index=True) + rating: Mapped[int] = mapped_column(Integer, index=True) + reason: Mapped[str | None] = mapped_column(Text(), nullable=True) + context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/server/src/app/models/risk_observation.py b/server/src/app/models/risk_observation.py new file mode 100644 index 0000000..334807a --- /dev/null +++ b/server/src/app/models/risk_observation.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class RiskObservation(Base): + __tablename__ = "risk_observations" + __table_args__ = ( + Index("ix_risk_observations_subject", "subject_type", "subject_key"), + Index("ix_risk_observations_signal_level", "risk_signal", "risk_level"), + Index("ix_risk_observations_status_created", "status", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + observation_key: Mapped[str] = mapped_column(String(160), unique=True, index=True) + subject_type: Mapped[str] = mapped_column(String(50), index=True) + subject_key: Mapped[str] = mapped_column(String(160), index=True) + subject_label: Mapped[str] = mapped_column(String(160), default="") + claim_id: Mapped[str | None] = mapped_column( + ForeignKey("expense_claims.id"), + nullable=True, + index=True, + ) + claim_no: Mapped[str] = mapped_column(String(80), default="", index=True) + run_id: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + execution_log_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + + risk_type: Mapped[str] = mapped_column(String(80), index=True) + risk_signal: Mapped[str] = mapped_column(String(100), index=True) + title: Mapped[str] = mapped_column(String(200), default="") + description: Mapped[str] = mapped_column(Text(), default="") + risk_score: Mapped[int] = mapped_column(Integer, default=0, index=True) + risk_level: Mapped[str] = mapped_column(String(20), index=True) + confidence_score: Mapped[float] = mapped_column(Float, default=0.0) + + control_stage: Mapped[str] = mapped_column(String(50), default="") + control_mode: Mapped[str] = mapped_column(String(50), default="") + automation_mode: Mapped[str] = mapped_column(String(50), default="") + source: Mapped[str] = mapped_column(String(60), default="", index=True) + algorithm_version: Mapped[str] = mapped_column(String(80), default="", index=True) + status: Mapped[str] = mapped_column(String(30), default="pending_review", index=True) + feedback_status: Mapped[str] = mapped_column(String(30), default="unreviewed", index=True) + + contribution_scores_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + baseline_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + evidence_json: Mapped[list[Any]] = mapped_column(JSON, default=list) + graph_node_keys_json: Mapped[list[Any]] = mapped_column(JSON, default=list) + graph_edge_keys_json: Mapped[list[Any]] = mapped_column(JSON, default=list) + policy_refs_json: Mapped[list[Any]] = mapped_column(JSON, default=list) + similar_case_claim_ids_json: Mapped[list[Any]] = mapped_column(JSON, default=list) + ontology_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + decision_trace_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + claim = relationship("ExpenseClaim", foreign_keys=[claim_id]) + feedback_items = relationship( + "RiskObservationFeedback", + back_populates="observation", + cascade="all, delete-orphan", + order_by="desc(RiskObservationFeedback.created_at)", + ) + + @property + def sampling_strategy(self) -> dict[str, Any]: + value = (self.decision_trace_json or {}).get("sampling_strategy") + return dict(value) if isinstance(value, dict) else {} + + @property + def evaluation_case_id(self) -> str: + return _json_text((self.decision_trace_json or {}).get("evaluation_case_id")) + + @property + def ontology_parse_id(self) -> str: + return _json_text((self.ontology_json or {}).get("ontology_parse_id")) + + @property + def ontology_version(self) -> str: + return _json_text((self.ontology_json or {}).get("ontology_version")) + + @property + def domain(self) -> str: + return _json_text((self.ontology_json or {}).get("domain")) + + @property + def scenario(self) -> str: + return _json_text((self.ontology_json or {}).get("scenario")) + + @property + def intent(self) -> str: + return _json_text((self.ontology_json or {}).get("intent")) + + @property + def ontology_entities_json(self) -> list[Any]: + value = (self.ontology_json or {}).get("ontology_entities_json") + if value is None: + value = (self.ontology_json or {}).get("entities") + return list(value) if isinstance(value, list) else [] + + @property + def risk_signals_json(self) -> list[Any]: + value = (self.ontology_json or {}).get("risk_signals_json") + if value is None: + value = (self.ontology_json or {}).get("risk_signals") + return list(value) if isinstance(value, list) else [] + + @property + def canonical_subject_key(self) -> str: + return _json_text((self.ontology_json or {}).get("canonical_subject_key")) + + +class RiskObservationFeedback(Base): + __tablename__ = "risk_observation_feedback" + __table_args__ = ( + Index("ix_risk_observation_feedback_type_created", "feedback_type", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + observation_id: Mapped[str] = mapped_column( + ForeignKey("risk_observations.id"), + index=True, + ) + feedback_type: Mapped[str] = mapped_column(String(30), index=True) + action: Mapped[str] = mapped_column(String(50), default="") + actor: Mapped[str] = mapped_column(String(100), default="") + comment: Mapped[str | None] = mapped_column(Text(), nullable=True) + payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + observation = relationship("RiskObservation", back_populates="feedback_items") + + @property + def decision(self) -> str: + return _json_text((self.payload_json or {}).get("decision")) or self.feedback_type + + @property + def candidate_rule_source(self) -> str: + return _json_text((self.payload_json or {}).get("candidate_rule_source")) + + @property + def confidence_score(self) -> float: + try: + return float((self.payload_json or {}).get("confidence_score") or 0) + except (TypeError, ValueError): + return 0.0 + + @property + def escalation_target(self) -> str: + return _json_text((self.payload_json or {}).get("escalation_target")) + + @property + def supplement_required(self) -> bool: + return bool((self.payload_json or {}).get("supplement_required")) + + +def _json_text(value: Any) -> str: + return str(value or "").strip() diff --git a/server/src/app/models/user_session_metric.py b/server/src/app/models/user_session_metric.py new file mode 100644 index 0000000..e6b01a9 --- /dev/null +++ b/server/src/app/models/user_session_metric.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class UserSessionMetric(Base): + __tablename__ = "user_session_metrics" + __table_args__ = ( + Index("ix_user_session_metrics_identity_window", "username", "employee_no", "login_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + username: Mapped[str] = mapped_column(String(255), index=True) + display_name: Mapped[str] = mapped_column(String(100), default="", index=True) + employee_no: Mapped[str] = mapped_column(String(80), default="", index=True) + email: Mapped[str] = mapped_column(String(255), default="", index=True) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + logout_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + last_activity_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + duration_ms: Mapped[int] = mapped_column(Integer, default=0) + activity_event_count: Mapped[int] = mapped_column(Integer, default=0) + logout_reason: Mapped[str] = mapped_column(String(40), default="") + status: Mapped[str] = mapped_column(String(20), default="active", index=True) + event_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index c3dc754..dcf2255 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -124,6 +124,28 @@ class AgentAssetRiskRuleGenerateRequest(BaseModel): requires_attachment: bool = False +class AgentAssetRiskRuleDraftUpdate(BaseModel): + rule_title: str | None = Field(default=None, min_length=2, max_length=80) + expense_category: str | None = Field(default=None, max_length=40) + natural_language: str | None = Field(default=None, min_length=8, max_length=2000) + requires_attachment: bool | None = None + + +class AgentAssetRiskRuleRevisionCreate(BaseModel): + rule_title: str | None = Field(default=None, min_length=2, max_length=80) + expense_category: str | None = Field(default=None, max_length=40) + natural_language: str | None = Field(default=None, min_length=8, max_length=2000) + requires_attachment: bool | None = None + change_reason: str = Field(min_length=1, max_length=1000) + + +class AgentAssetRiskRuleRegenerateRequest(BaseModel): + rule_title: str | None = Field(default=None, min_length=2, max_length=80) + expense_category: str | None = Field(default=None, max_length=40) + natural_language: str | None = Field(default=None, min_length=8, max_length=2000) + requires_attachment: bool | None = None + + class AgentAssetRiskRuleSampleCase(BaseModel): case_id: str | None = Field(default=None, max_length=60) name: str = Field(default="测试样例", min_length=1, max_length=80) @@ -184,7 +206,9 @@ class AgentAssetRiskRuleSimulationRead(BaseModel): blocking_reason: str = "" message: str = "" field_values: dict[str, Any] = Field(default_factory=dict) + normalized_fields: dict[str, Any] = Field(default_factory=dict) evidence: dict[str, Any] = Field(default_factory=dict) + trace: dict[str, Any] = Field(default_factory=dict) attachments: list[dict[str, Any]] = Field(default_factory=list) recognized_fields: list[dict[str, Any]] = Field(default_factory=list) missing_fields: list[dict[str, Any]] = Field(default_factory=list) diff --git a/server/src/app/schemas/agent_feedback.py b/server/src/app/schemas/agent_feedback.py new file mode 100644 index 0000000..957b810 --- /dev/null +++ b/server/src/app/schemas/agent_feedback.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _blank_to_none(value: Any) -> Any: + if value is None: + return None + if isinstance(value, str): + normalized = value.strip() + return normalized or None + return value + + +class AgentFeedbackCreate(BaseModel): + run_id: str | None = Field(default=None, max_length=50) + conversation_id: str | None = Field(default=None, max_length=50) + user_id: str | None = Field(default=None, max_length=100) + agent: str | None = Field(default=None, max_length=30) + source: str | None = Field(default=None, max_length=30) + session_type: str | None = Field(default=None, max_length=30) + operation_type: str | None = Field(default="assistant_round", max_length=50) + operation_status: str | None = Field(default=None, max_length=20) + rating: int = Field(ge=1, le=5) + reason: str | None = Field(default=None, max_length=1000) + context_json: dict[str, Any] = Field(default_factory=dict) + + @field_validator( + "run_id", + "conversation_id", + "user_id", + "agent", + "source", + "session_type", + "operation_type", + "operation_status", + "reason", + mode="before", + ) + @classmethod + def normalize_optional_text(cls, value: Any) -> Any: + return _blank_to_none(value) + + +class AgentFeedbackRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + feedback_id: str + run_id: str | None + conversation_id: str | None + user_id: str | None + agent: str + source: str + session_type: str + operation_type: str + operation_status: str + rating: int + reason: str | None + context_json: dict[str, Any] + created_at: datetime + + +class AgentFeedbackSummaryRead(BaseModel): + window_limit: int + total_feedback: int + average_rating: float + low_rating_count: int + rating_distribution: dict[str, int] = Field(default_factory=dict) + agents: dict[str, int] = Field(default_factory=dict) + session_types: dict[str, int] = Field(default_factory=dict) + recent_low_feedback: list[dict[str, Any]] = Field(default_factory=list) diff --git a/server/src/app/schemas/agent_run.py b/server/src/app/schemas/agent_run.py index 840ad7c..7949cd1 100644 --- a/server/src/app/schemas/agent_run.py +++ b/server/src/app/schemas/agent_run.py @@ -59,3 +59,21 @@ class AgentRunRead(BaseModel): finished_at: datetime | None tool_calls: list[AgentToolCallRead] = Field(default_factory=list) semantic_parse: SemanticParseRead | None = None + + +class AgentRunStatsRead(BaseModel): + window_limit: int + total_runs: int + succeeded_runs: int + blocked_runs: int + failed_runs: int + tool_call_count: int + failed_tool_call_count: int + llm_call_count: int + failed_llm_call_count: int + model_fallback_count: int + model_guardrail_count: int + agents: dict[str, int] = Field(default_factory=dict) + statuses: dict[str, int] = Field(default_factory=dict) + tool_statuses: dict[str, int] = Field(default_factory=dict) + recent_errors: list[dict[str, Any]] = Field(default_factory=list) diff --git a/server/src/app/schemas/auth.py b/server/src/app/schemas/auth.py index c744476..fa6fa04 100644 --- a/server/src/app/schemas/auth.py +++ b/server/src/app/schemas/auth.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import Any from pydantic import BaseModel, EmailStr, Field @@ -34,3 +35,18 @@ class LoginResponse(BaseModel): ok: bool = True detail: str = "登录成功。" user: AuthUserRead + sessionId: str = "" + + +class SessionFinishRequest(BaseModel): + reason: str = Field(default="manual", max_length=40) + lastActivityAt: datetime | None = None + activityEventCount: int = Field(default=0, ge=0) + pagePath: str = Field(default="", max_length=512) + + +class SessionFinishResponse(BaseModel): + ok: bool = True + detail: str = "会话已结算。" + sessionId: str = "" + durationMs: int = 0 diff --git a/server/src/app/schemas/finance_dashboard.py b/server/src/app/schemas/finance_dashboard.py new file mode 100644 index 0000000..77bc810 --- /dev/null +++ b/server/src/app/schemas/finance_dashboard.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class FinanceDashboardRead(BaseModel): + range_key: str + start_date: str + end_date: str + generated_at: str + has_real_data: bool + totals: dict[str, Any] = Field(default_factory=dict) + metric_meta: dict[str, Any] = Field(default_factory=dict) + trend: dict[str, Any] = Field(default_factory=dict) + spend_by_category: list[dict[str, Any]] = Field(default_factory=list) + exception_mix: list[dict[str, Any]] = Field(default_factory=list) + department_ranking: list[dict[str, Any]] = Field(default_factory=list) + bottlenecks: list[dict[str, Any]] = Field(default_factory=list) + budget_summary: dict[str, Any] = Field(default_factory=dict) diff --git a/server/src/app/schemas/risk_observation.py b/server/src/app/schemas/risk_observation.py new file mode 100644 index 0000000..7238636 --- /dev/null +++ b/server/src/app/schemas/risk_observation.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +RiskObservationStatus = Literal[ + "pending_review", + "confirmed", + "false_positive", + "ignored", + "resolved", +] + +RiskObservationFeedbackType = Literal[ + "confirm", + "false_positive", + "ignore", + "resolve", + "comment", +] + + +class RiskObservationFeedbackRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + observation_id: str + feedback_type: str + action: str + actor: str + comment: str | None + payload_json: dict[str, Any] + decision: str = "" + candidate_rule_source: str = "" + confidence_score: float = 0.0 + escalation_target: str = "" + supplement_required: bool = False + created_at: datetime + + +class RiskObservationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + observation_key: str + subject_type: str + subject_key: str + subject_label: str + claim_id: str | None + claim_no: str + run_id: str | None + execution_log_id: str | None + risk_type: str + risk_signal: str + title: str + description: str + risk_score: int + risk_level: str + confidence_score: float + control_stage: str + control_mode: str + automation_mode: str + source: str + algorithm_version: str + status: str + feedback_status: str + contribution_scores_json: dict[str, Any] + baseline_json: dict[str, Any] + evidence_json: list[Any] + graph_node_keys_json: list[Any] + graph_edge_keys_json: list[Any] + policy_refs_json: list[Any] + similar_case_claim_ids_json: list[Any] + ontology_json: dict[str, Any] + decision_trace_json: dict[str, Any] + sampling_strategy: dict[str, Any] = Field(default_factory=dict) + evaluation_case_id: str = "" + ontology_parse_id: str = "" + ontology_version: str = "" + domain: str = "" + scenario: str = "" + intent: str = "" + ontology_entities_json: list[Any] = Field(default_factory=list) + risk_signals_json: list[Any] = Field(default_factory=list) + canonical_subject_key: str = "" + created_at: datetime + updated_at: datetime + feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list) + + +class RiskObservationListRead(BaseModel): + items: list[RiskObservationRead] + total: int + limit: int + offset: int + + +class RiskObservationFeedbackCreate(BaseModel): + feedback_type: RiskObservationFeedbackType + action: str | None = Field(default=None, max_length=50) + actor: str | None = Field(default=None, max_length=100) + comment: str | None = Field(default=None, max_length=1000) + payload_json: dict[str, Any] = Field(default_factory=dict) + + @field_validator("action", "actor", "comment", mode="before") + @classmethod + def normalize_text(cls, value: Any) -> Any: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + +class RiskObservationDashboardRead(BaseModel): + window_days: int + total_observations: int + pending_count: int + high_or_above_count: int + confirmed_count: int + false_positive_count: int + total_amount: float = 0.0 + average_score: float + level_distribution: dict[str, int] = Field(default_factory=dict) + status_distribution: dict[str, int] = Field(default_factory=dict) + signal_distribution: dict[str, int] = Field(default_factory=dict) + source_distribution: dict[str, int] = Field(default_factory=dict) + automation_distribution: dict[str, int] = Field(default_factory=dict) + department_distribution: dict[str, int] = Field(default_factory=dict) + expense_type_distribution: dict[str, int] = Field(default_factory=dict) + risk_type_distribution: dict[str, int] = Field(default_factory=dict) + supplier_distribution: dict[str, int] = Field(default_factory=dict) + employee_grade_distribution: dict[str, int] = Field(default_factory=dict) + daily_trend: list[dict[str, Any]] = Field(default_factory=list) + top_risk_signals: list[dict[str, Any]] = Field(default_factory=list) + top_departments: list[dict[str, Any]] = Field(default_factory=list) + top_employees: list[dict[str, Any]] = Field(default_factory=list) + top_suppliers: list[dict[str, Any]] = Field(default_factory=list) + top_expense_types: list[dict[str, Any]] = Field(default_factory=list) + top_rules: list[dict[str, Any]] = Field(default_factory=list) + candidate_rule_count: int = 0 + confirmation_rate: float + false_positive_rate: float + recent_high_observations: list[RiskObservationRead] = Field(default_factory=list) diff --git a/server/src/app/schemas/system_dashboard.py b/server/src/app/schemas/system_dashboard.py new file mode 100644 index 0000000..d63b668 --- /dev/null +++ b/server/src/app/schemas/system_dashboard.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class SystemDashboardRead(BaseModel): + window_days: int + generated_at: str + has_real_data: bool + totals: dict[str, Any] = Field(default_factory=dict) + agent_daily_ratio: dict[str, Any] = Field(default_factory=dict) + login_wave: dict[str, Any] = Field(default_factory=dict) + token_daily_wave: dict[str, Any] = Field(default_factory=dict) + user_token_usage: list[dict[str, Any]] = Field(default_factory=list) + accuracy_comparison: dict[str, Any] = Field(default_factory=dict) + usage_duration_summary: dict[str, Any] = Field(default_factory=dict) + feedback_summary: list[dict[str, Any]] = Field(default_factory=list) + tool_detail_rows: list[dict[str, Any]] = Field(default_factory=list) diff --git a/server/src/app/services/account_behavior_profile.py b/server/src/app/services/account_behavior_profile.py index 3f7a145..2f5552e 100644 --- a/server/src/app/services/account_behavior_profile.py +++ b/server/src/app/services/account_behavior_profile.py @@ -44,8 +44,10 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): empty_reason="当前账号未匹配员工目录,无法形成审批场景员工画像。", ) - runs = self._fetch_account_runs(identifiers, datetime.now(UTC) - timedelta(days=window_days)) - if not runs: + cutoff = datetime.now(UTC) - timedelta(days=window_days) + runs = self._fetch_account_runs(identifiers, cutoff) + usage_duration_metrics = self._resolve_usage_duration_metrics(identifiers, cutoff, runs) + if not runs and not usage_duration_metrics["online_duration_ms"]: return EmployeeProfileLatestRead( employee_id=account_id, employee_name=account_name, @@ -57,6 +59,7 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): result = self._calculate_account_ai_usage_profile( runs=runs, + usage_duration_metrics=usage_duration_metrics, window_days=window_days, expense_type_scope=expense_type_scope, ) @@ -100,6 +103,7 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): self, *, runs: list[AgentRun], + usage_duration_metrics: dict[str, Any], window_days: int, expense_type_scope: str, ): @@ -108,7 +112,6 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"} ] estimated_tokens = self._estimate_tokens(runs) - duration_ms = self._sum_agent_run_duration_ms(runs) token_mode = "estimated_token_count" if estimated_tokens else "unavailable" return evaluate_weighted_profile( @@ -159,8 +162,7 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): "token_count_mode": token_mode, "estimated_token_count": estimated_tokens, "exact_token_count": None, - "ai_run_duration_ms": duration_ms, - "ai_run_duration_mode": "elapsed_or_tool_call_fallback", + **usage_duration_metrics, }, ) diff --git a/server/src/app/services/agent_asset_risk_rule_revision.py b/server/src/app/services/agent_asset_risk_rule_revision.py new file mode 100644 index 0000000..3cc7386 --- /dev/null +++ b/server/src/app/services/agent_asset_risk_rule_revision.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.orm import Session + +from app.core.agent_enums import AgentAssetStatus, AgentAssetType +from app.models.agent_asset import AgentAsset, AgentAssetVersion +from app.repositories.agent_asset import AgentAssetRepository +from app.schemas.agent_asset import ( + AgentAssetRiskRuleDraftUpdate, + AgentAssetRiskRuleRevisionCreate, +) +from app.services.audit import AuditLogService +from app.services.risk_rule_generation_ontology import EXPENSE_RISK_CATEGORY_LABELS + + +class AgentAssetRiskRuleRevisionService: + """风险规则草稿编辑与已发布规则修订草稿服务。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.repository = AgentAssetRepository(db) + self.audit_service = AuditLogService(db) + + def update_unpublished_draft( + self, + asset_id: str, + body: AgentAssetRiskRuleDraftUpdate, + *, + actor: str, + request_id: str | None = None, + ) -> AgentAsset: + asset = self._resolve_json_risk_asset(asset_id) + if str(asset.published_version or "").strip() or asset.status not in { + AgentAssetStatus.DRAFT.value, + AgentAssetStatus.FAILED.value, + }: + raise PermissionError("只有未上线草稿或生成失败的风险规则可以直接编辑。") + + before = self._snapshot(asset) + config = dict(asset.config_json or {}) + request = self._merged_generation_request(config, body.model_dump(exclude_unset=True)) + self._apply_edit_payload(asset, config, request, actor=actor, action="update_draft") + self.db.add(asset) + self.db.flush() + self.audit_service.log_action( + actor=actor, + action="update_risk_rule_draft", + resource_type=AgentAssetType.RULE.value, + resource_id=asset.id, + before_json=before, + after_json=self._snapshot(asset), + request_id=request_id, + ) + return asset + + def create_revision_draft( + self, + asset_id: str, + body: AgentAssetRiskRuleRevisionCreate, + *, + actor: str, + request_id: str | None = None, + ) -> AgentAsset: + asset = self._resolve_json_risk_asset(asset_id) + if not str(asset.published_version or "").strip(): + raise ValueError("未上线规则不需要创建修订版本,请直接编辑草稿。") + if asset.status not in {AgentAssetStatus.ACTIVE.value, AgentAssetStatus.DISABLED.value}: + raise ValueError("只有已上线或已下线规则可以创建修订版本。") + + before = self._snapshot(asset) + config = dict(asset.config_json or {}) + request = self._merged_generation_request(config, body.model_dump(exclude_unset=True)) + revision_version = self._next_revision_version(asset) + now = datetime.now(UTC).isoformat() + config["revision_draft"] = { + "version": revision_version, + "base_version": asset.published_version, + "status": "draft", + "change_reason": body.change_reason, + "generation_request": request, + "created_by": actor, + "created_at": now, + } + config["last_operation"] = { + "action": "create_revision", + "actor": actor, + "at": now, + "target_version": revision_version, + } + asset.working_version = revision_version + asset.config_json = config + self.db.add(asset) + self.db.add( + AgentAssetVersion( + asset_id=asset.id, + version=revision_version, + content=self._build_revision_content(asset, config), + content_type="markdown", + change_note=body.change_reason, + created_by=actor, + ) + ) + self.db.flush() + self.audit_service.log_action( + actor=actor, + action="create_risk_rule_revision", + resource_type=AgentAssetType.RULE.value, + resource_id=asset.id, + before_json=before, + after_json=self._snapshot(asset), + request_id=request_id, + ) + return asset + + def _resolve_json_risk_asset(self, asset_id: str) -> AgentAsset: + asset = self.repository.get(asset_id) + if asset is None: + raise FileNotFoundError("风险规则不存在。") + config = asset.config_json or {} + if asset.asset_type != AgentAssetType.RULE.value or config.get("detail_mode") != "json_risk": + raise ValueError("当前资产不是自然语言风险规则。") + return asset + + def _apply_edit_payload( + self, + asset: AgentAsset, + config: dict[str, Any], + request: dict[str, Any], + *, + actor: str, + action: str, + ) -> None: + now = datetime.now(UTC).isoformat() + rule_title = str(request.get("rule_title") or asset.name or "").strip() + natural_language = str(request.get("natural_language") or asset.description or "").strip() + expense_category = str(request.get("expense_category") or config.get("expense_category") or "").strip() + category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category, config.get("risk_category") or "") + asset.name = rule_title or asset.name + asset.description = natural_language or asset.description + if category_label: + asset.scenario_json = [category_label] + config.update( + { + "expense_category": expense_category or None, + "expense_category_label": category_label, + "risk_category": category_label or config.get("risk_category"), + "requires_attachment": bool(request.get("requires_attachment")), + "generation_request": request, + "generation_status": "draft_updated", + "last_operation": {"action": action, "actor": actor, "at": now}, + } + ) + asset.config_json = config + + @staticmethod + def _merged_generation_request(config: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]: + base = config.get("generation_request") if isinstance(config.get("generation_request"), dict) else {} + merged = dict(base) + for key, value in updates.items(): + if key == "change_reason": + continue + merged[key] = value + merged.setdefault("business_domain", "expense") + merged.setdefault("business_stage", config.get("business_stage") or "reimbursement") + merged.setdefault("expense_category", config.get("expense_category")) + merged.setdefault("rule_title", config.get("rule_title") or "") + merged.setdefault("natural_language", "") + merged.setdefault("requires_attachment", bool(config.get("requires_attachment"))) + return merged + + def _next_revision_version(self, asset: AgentAsset) -> str: + base = str(asset.working_version or asset.current_version or asset.published_version or "v0.1.0") + major, minor, patch = self._parse_version(base) + existing = {version.version for version in self.repository.list_versions(asset.id)} + while True: + patch += 1 + candidate = f"v{major}.{minor}.{patch}" + if candidate not in existing: + return candidate + + @staticmethod + def _parse_version(value: str) -> tuple[int, int, int]: + parts = str(value or "").strip().removeprefix("v").split(".") + numbers = [int(part) if part.isdigit() else 0 for part in parts[:3]] + padded = (numbers + [0, 0, 0])[:3] + return padded[0], padded[1], padded[2] + + @staticmethod + def _build_revision_content(asset: AgentAsset, config: dict[str, Any]) -> str: + revision = config.get("revision_draft") if isinstance(config.get("revision_draft"), dict) else {} + request = revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {} + return "\n".join( + [ + f"# {asset.name} 修订草稿", + "", + f"- 基线版本:{revision.get('base_version') or ''}", + f"- 修订版本:{revision.get('version') or ''}", + f"- 修订原因:{revision.get('change_reason') or ''}", + f"- 规则描述:{request.get('natural_language') or asset.description}", + ] + ) + + @staticmethod + def _snapshot(asset: AgentAsset) -> dict[str, Any]: + return { + "id": asset.id, + "name": asset.name, + "description": asset.description, + "status": asset.status, + "current_version": asset.current_version, + "published_version": asset.published_version, + "working_version": asset.working_version, + "config_json": asset.config_json or {}, + } diff --git a/server/src/app/services/agent_asset_risk_rule_simulation.py b/server/src/app/services/agent_asset_risk_rule_simulation.py index da237ec..d12c0b6 100644 --- a/server/src/app/services/agent_asset_risk_rule_simulation.py +++ b/server/src/app/services/agent_asset_risk_rule_simulation.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from datetime import UTC, datetime from typing import Any @@ -63,6 +64,7 @@ class AgentAssetRiskRuleSimulationMixin: summary=block["summary"], blocking_reason=block["reason"], field_values=field_values, + normalized_fields=field_values, attachments=attachments, recognized_fields=recognized_fields, missing_fields=missing_fields, @@ -71,7 +73,12 @@ class AgentAssetRiskRuleSimulationMixin: ) claim, contexts = self._build_synthetic_claim(field_values, manifest) - result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts) + execution = RiskRuleTemplateExecutor().evaluate_with_trace( + manifest, + claim=claim, + contexts=contexts, + ) + result = execution["result"] hit = result is not None severity = ( str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "medium") @@ -96,7 +103,9 @@ class AgentAssetRiskRuleSimulationMixin: summary=summary, message=message, field_values=field_values, + normalized_fields=field_values, evidence=evidence if isinstance(evidence, dict) else {}, + trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {}, attachments=attachments, recognized_fields=recognized_fields, missing_fields=[], @@ -184,7 +193,11 @@ class AgentAssetRiskRuleSimulationMixin: ) -> Any: key_text = f"{field_key} {label}".lower() if field_key.endswith("route_cities"): - return city_mentions or [] + return city_mentions if self._looks_like_route_text(corpus) else [] + if field_key == "item.item_location": + return self._extract_labeled_city(corpus, city_mentions, ("明细地点", "发生地点")) + if field_key == "employee.location": + return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地", "出发地")) if "city" in field_key or "location" in field_key: if any( token in key_text @@ -221,6 +234,19 @@ class AgentAssetRiskRuleSimulationMixin: return corpus or "仿真测试报销事由" return None + @staticmethod + def _looks_like_route_text(text: str) -> bool: + return any(token in str(text or "") for token in ("交通票", "车票", "机票", "火车", "高铁", "行程", "路线", "从", "到", "至")) + + @staticmethod + def _extract_labeled_city(text: str, city_mentions: list[str], labels: tuple[str, ...]) -> str: + corpus = str(text or "") + for label in labels: + for city in city_mentions: + if re.search(rf"{re.escape(label)}[^,。;;、\n]{{0,10}}{re.escape(city)}", corpus): + return city + return "" + def _apply_compare_city_hints( self, manifest: dict[str, Any], diff --git a/server/src/app/services/agent_asset_risk_rule_testing.py b/server/src/app/services/agent_asset_risk_rule_testing.py index 31219de..5dd58aa 100644 --- a/server/src/app/services/agent_asset_risk_rule_testing.py +++ b/server/src/app/services/agent_asset_risk_rule_testing.py @@ -432,7 +432,8 @@ class AgentAssetRiskRuleTestingMixin: case: AgentAssetRiskRuleSampleCase, ) -> dict[str, Any]: claim, contexts = self._build_synthetic_claim(case.values, manifest) - result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts) + execution = RiskRuleTemplateExecutor().evaluate_with_trace(manifest, claim=claim, contexts=contexts) + result = execution["result"] actual_hit = result is not None actual_severity = ( str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "").strip() @@ -455,11 +456,13 @@ class AgentAssetRiskRuleTestingMixin: "passed": passed, "message": str(result.get("message") or "") if isinstance(result, dict) else "", "evidence": result.get("evidence") if isinstance(result, dict) else {}, + "trace": execution["trace"] if isinstance(execution.get("trace"), dict) else {}, } def _run_claim_scenario(self, manifest: dict[str, Any], claim: ExpenseClaim) -> dict[str, Any]: contexts = ExpenseClaimService(self.db)._build_claim_attachment_contexts(claim) - result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts) + execution = RiskRuleTemplateExecutor().evaluate_with_trace(manifest, claim=claim, contexts=contexts) + result = execution["result"] hit = result is not None return { "claim_id": claim.id, @@ -476,6 +479,7 @@ class AgentAssetRiskRuleTestingMixin: else "none", "message": str(result.get("message") or "") if isinstance(result, dict) else "", "evidence": result.get("evidence") if isinstance(result, dict) else {}, + "trace": execution["trace"] if isinstance(execution.get("trace"), dict) else {}, } def _build_synthetic_claim( @@ -617,6 +621,9 @@ class AgentAssetRiskRuleTestingMixin: template_key = str(manifest.get("template_key") or "").strip() params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} if template_key == "field_compare_v1": + if str(params.get("semantic_type") or "").strip() in {"travel_city_consistency", "travel_route_city_consistency"}: + values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京", "employee.location": "北京"}) + return values condition = next( (item for item in params.get("conditions", []) if isinstance(item, dict)), {}, diff --git a/server/src/app/services/agent_feedback.py b/server/src/app/services/agent_feedback.py new file mode 100644 index 0000000..23b313b --- /dev/null +++ b/server/src/app/services/agent_feedback.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.agent_feedback import AgentOperationFeedback +from app.schemas.agent_feedback import ( + AgentFeedbackCreate, + AgentFeedbackRead, + AgentFeedbackSummaryRead, +) + +LOW_RATING_MAX = 3 + + +class AgentFeedbackService: + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_storage_ready(self) -> None: + Base.metadata.create_all(bind=self.db.get_bind(), tables=[AgentOperationFeedback.__table__]) + + def create_feedback(self, payload: AgentFeedbackCreate) -> AgentFeedbackRead: + self.ensure_storage_ready() + feedback = AgentOperationFeedback( + run_id=payload.run_id, + conversation_id=payload.conversation_id, + user_id=payload.user_id, + agent=payload.agent or "", + source=payload.source or "", + session_type=payload.session_type or "", + operation_type=payload.operation_type or "assistant_round", + operation_status=payload.operation_status or "", + rating=int(payload.rating), + reason=self._normalize_reason(payload.reason), + context_json=self._normalize_context(payload.context_json), + ) + self.db.add(feedback) + self.db.commit() + self.db.refresh(feedback) + return AgentFeedbackRead.model_validate(feedback) + + def summarize_feedback( + self, + *, + agent: str | None = None, + session_type: str | None = None, + limit: int = 200, + ) -> AgentFeedbackSummaryRead: + self.ensure_storage_ready() + stmt = select(AgentOperationFeedback).order_by(AgentOperationFeedback.created_at.desc()).limit(limit) + if agent: + stmt = stmt.where(AgentOperationFeedback.agent == agent) + if session_type: + stmt = stmt.where(AgentOperationFeedback.session_type == session_type) + + feedback_items = list(self.db.scalars(stmt).all()) + rating_distribution = {str(score): 0 for score in range(1, 6)} + agents: dict[str, int] = {} + session_types: dict[str, int] = {} + low_feedback: list[dict[str, Any]] = [] + total_rating = 0 + + for item in feedback_items: + rating = max(1, min(int(item.rating or 0), 5)) + total_rating += rating + rating_distribution[str(rating)] = rating_distribution.get(str(rating), 0) + 1 + if item.agent: + agents[item.agent] = agents.get(item.agent, 0) + 1 + if item.session_type: + session_types[item.session_type] = session_types.get(item.session_type, 0) + 1 + if rating <= LOW_RATING_MAX: + low_feedback.append( + { + "feedback_id": item.feedback_id, + "run_id": item.run_id, + "conversation_id": item.conversation_id, + "user_id": item.user_id, + "agent": item.agent, + "session_type": item.session_type, + "rating": rating, + "reason": item.reason, + "created_at": item.created_at, + } + ) + + total_feedback = len(feedback_items) + average_rating = round(total_rating / total_feedback, 2) if total_feedback else 0.0 + return AgentFeedbackSummaryRead( + window_limit=limit, + total_feedback=total_feedback, + average_rating=average_rating, + low_rating_count=len(low_feedback), + rating_distribution=rating_distribution, + agents=agents, + session_types=session_types, + recent_low_feedback=low_feedback[:10], + ) + + @staticmethod + def _normalize_reason(value: str | None) -> str | None: + normalized = str(value or "").strip() + return normalized[:1000] if normalized else None + + @staticmethod + def _normalize_context(value: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + return value diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index fadbe2f..7356abf 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -27,6 +27,9 @@ from app.services.agent_foundation_constants import ( PLATFORM_DESTINATION_LOCATION_RULE_CODE, PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) +from app.services.agent_foundation_digital_employee_tasks import ( + AgentFoundationDigitalEmployeeTaskMixin, +) from app.services.agent_foundation_financial_seed import AgentFoundationFinancialSeedMixin from app.services.agent_foundation_markdown import AgentFoundationMarkdownMixin from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin @@ -51,6 +54,7 @@ def prepare_agent_foundation() -> None: class AgentFoundationService( AgentFoundationAssetSeedMixin, AgentFoundationFinancialSeedMixin, + AgentFoundationDigitalEmployeeTaskMixin, AgentFoundationAssetTopUpMixin, AgentFoundationSpreadsheetMixin, AgentFoundationAssetHelperMixin, diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index 4650489..b82e912 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -29,6 +29,9 @@ from app.services.agent_foundation_constants import ( COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, DIGITAL_EMPLOYEE_SKILL_CATEGORIES, DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP, ) @@ -48,19 +51,27 @@ class AgentFoundationAssetSeedMixin: "skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES), } - def _finance_policy_knowledge_skill_markdown(self) -> str: + def _read_domain_skill_markdown( + self, + skill_name: str, + fallback_lines: list[str], + ) -> str: skill_path = ( SERVER_DIR / "src" / "app" / "skills" / "domain" - / "finance-policy-knowledge-organizer" + / skill_name / "SKILL.md" ) if skill_path.exists(): return skill_path.read_text(encoding="utf-8").strip() - return "\n".join( + return "\n".join(fallback_lines) + + def _finance_policy_knowledge_skill_markdown(self) -> str: + return self._read_domain_skill_markdown( + "finance-policy-knowledge-organizer", [ "---", "name: finance-policy-knowledge-organizer", @@ -72,7 +83,58 @@ class AgentFoundationAssetSeedMixin: "## 功能说明", "", "整理公司财务制度、报销口径、审批要求和知识库资料,输出可复核的结构化知识。", - ] + ], + ) + + def _financial_risk_graph_scan_skill_markdown(self) -> str: + return self._read_domain_skill_markdown( + "financial-risk-graph-scanner", + [ + "---", + "name: financial-risk-graph-scanner", + "description: 用于巡检财务风险图谱,生成风险观察和可复核证据链。", + "---", + "", + "# 财务风险图谱巡检", + "", + "## 功能说明", + "", + "扫描新增报销单、票据、审批链、员工画像和规则命中结果,输出统一风险观察。", + ], + ) + + def _employee_behavior_profile_scan_skill_markdown(self) -> str: + return self._read_domain_skill_markdown( + "employee-behavior-profile-scanner", + [ + "---", + "name: employee-behavior-profile-scanner", + "description: 用于更新员工行为画像,沉淀费用、流程质量和协作治理基线。", + "---", + "", + "# 员工行为画像巡检", + "", + "## 功能说明", + "", + "汇总员工费用、审批、材料完整性和智能协作数据,生成可解释的画像快照。", + ], + ) + + def _risk_rule_discovery_skill_markdown(self) -> str: + return self._read_domain_skill_markdown( + "risk-rule-discovery", + [ + "---", + "name: risk-rule-discovery", + "description: 用于根据风险观察反馈生成候选规则,不直接上线。", + "---", + "", + "# 风险规则候选发现", + "", + "## 功能说明", + "", + "从风险观察、人工反馈和误报复盘中生成带证据、来源和置信度的候选规则。", + ], ) def _digital_employee_task_content( @@ -311,6 +373,67 @@ class AgentFoundationAssetSeedMixin: }, ) + risk_graph_scan_task = AgentAsset( + asset_type=AgentAssetType.TASK.value, + code=DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, + name="财务风险图谱巡检", + description="按计划扫描报销单、票据、审批链、员工画像和规则命中结果,生成风险观察与可复核证据链。", + domain=AgentAssetDomain.SYSTEM.value, + scenario_json=["schedule", "expense", "risk_graph", "risk_observation"], + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", + config_json={ + **self._digital_employee_task_config( + DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, + "0 9 * * *", + ), + "skill_name": "financial-risk-graph-scanner", + "scan_scope": [ + "expense_claims", + "invoices", + "approval_chain", + "employee_profiles", + "risk_rules", + ], + "output_format": "risk_observation_report", + "writes_risk_observations": True, + }, + ) + + employee_profile_scan_task = AgentAsset( + asset_type=AgentAssetType.TASK.value, + code=DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + name="员工行为画像巡检", + description="按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,为风险图谱提供画像基线。", + domain=AgentAssetDomain.SYSTEM.value, + scenario_json=["schedule", "employee_profile", "baseline", "risk_graph"], + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", + config_json={ + **self._digital_employee_task_config( + DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + "30 8 * * 1", + ), + "skill_name": "employee-behavior-profile-scanner", + "profile_dimensions": [ + "expense_intensity", + "material_completeness", + "approval_efficiency", + "ai_collaboration", + ], + "output_format": "employee_behavior_profile_snapshot", + "writes_profile_snapshots": True, + }, + ) + self.db.add_all( [ attachment_rule, @@ -324,6 +447,8 @@ class AgentFoundationAssetSeedMixin: invoice_mcp_asset, ledger_mcp_asset, finance_policy_knowledge_task, + risk_graph_scan_task, + employee_profile_scan_task, ] ) @@ -490,6 +615,22 @@ class AgentFoundationAssetSeedMixin: change_note="初始化整理公司财务知识制度能力。", created_by="系统初始化", ), + AgentAssetVersion( + asset=risk_graph_scan_task, + version="v1.0.0", + content=self._financial_risk_graph_scan_skill_markdown(), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化财务风险图谱巡检能力。", + created_by="系统初始化", + ), + AgentAssetVersion( + asset=employee_profile_scan_task, + version="v1.0.0", + content=self._employee_behavior_profile_scan_skill_markdown(), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化员工行为画像巡检能力。", + created_by="系统初始化", + ), ] ) diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index 8ad10c0..1752bf5 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -600,6 +600,8 @@ class AgentFoundationAssetTopUpMixin: created_by="系统初始化", ) + self._upsert_runtime_digital_employee_tasks(existing_codes) + finance_policy_cron = "0 3 * * *" finance_policy_config = { **self._digital_employee_task_config( diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index 88d6314..8e4b770 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -90,6 +90,12 @@ DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估") DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize" +DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE = "task.hermes.global_risk_scan" + +DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile_scan" + +DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE = "task.hermes.risk_rule_discovery" + DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = ( "task.hermes.daily_risk_scan", "task.hermes.weekly_ar_summary", @@ -100,6 +106,9 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = ( DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = { DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理", + DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级", } ATTACHMENT_RULE_RUNTIME_CONFIG = { diff --git a/server/src/app/services/agent_foundation_digital_employee_tasks.py b/server/src/app/services/agent_foundation_digital_employee_tasks.py new file mode 100644 index 0000000..229c9c7 --- /dev/null +++ b/server/src/app/services/agent_foundation_digital_employee_tasks.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from sqlalchemy import select + +from app.core.agent_enums import ( + AgentAssetContentType, + AgentAssetDomain, + AgentAssetStatus, + AgentAssetType, + AgentName, +) +from app.models.agent_asset import AgentAsset +from app.services.agent_foundation_constants import ( + DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, + DIGITAL_EMPLOYEE_SKILL_CATEGORIES, +) + + +class AgentFoundationDigitalEmployeeTaskMixin: + def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]: + return ( + { + "code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, + "name": "财务风险图谱巡检", + "description": "按计划扫描报销单、票据、审批链、员工画像和规则命中结果,生成风险观察与可复核证据链。", + "scenario_json": ["schedule", "expense", "risk_graph", "risk_observation"], + "owner": "风控与审计部", + "reviewer": "顾承宇", + "cron": "0 9 * * *", + "skill_category": "评估", + "markdown": self._financial_risk_graph_scan_skill_markdown, + "change_note": "初始化财务风险图谱巡检能力。", + "config": { + "skill_name": "financial-risk-graph-scanner", + "scan_scope": [ + "expense_claims", + "invoices", + "approval_chain", + "employee_profiles", + "risk_rules", + ], + "output_format": "risk_observation_report", + "writes_risk_observations": True, + }, + }, + { + "code": DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + "name": "员工行为画像巡检", + "description": "按计划更新员工费用行为、材料完整性、审批效率和智能协作画像,为风险图谱提供画像基线。", + "scenario_json": ["schedule", "employee_profile", "baseline", "risk_graph"], + "owner": "风控与审计部", + "reviewer": "顾承宇", + "cron": "30 8 * * 1", + "skill_category": "评估", + "markdown": self._employee_behavior_profile_scan_skill_markdown, + "change_note": "初始化员工行为画像巡检能力。", + "config": { + "skill_name": "employee-behavior-profile-scanner", + "profile_dimensions": [ + "expense_intensity", + "material_completeness", + "approval_efficiency", + "ai_collaboration", + ], + "output_format": "employee_behavior_profile_snapshot", + "writes_profile_snapshots": True, + }, + }, + { + "code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, + "name": "风险规则候选发现", + "description": "按计划复盘风险观察和人工反馈,生成带证据、来源和置信度的候选规则,不直接上线。", + "scenario_json": ["schedule", "risk_observation", "feedback", "rule_candidate"], + "owner": "风控与审计部", + "reviewer": "顾承宇", + "cron": "0 10 * * 1", + "skill_category": "升级", + "markdown": self._risk_rule_discovery_skill_markdown, + "change_note": "初始化风险规则候选发现能力。", + "config": { + "skill_name": "risk-rule-discovery", + "input_sources": [ + "risk_observations", + "risk_observation_feedback", + "algorithm_replay_sets", + ], + "output_format": "candidate_risk_rules", + "auto_publish": False, + }, + }, + ) + + def _upsert_runtime_digital_employee_tasks(self, existing_codes: set[str]) -> None: + for spec in self._runtime_digital_employee_task_specs(): + self._upsert_runtime_digital_employee_task(existing_codes, spec) + + def _upsert_runtime_digital_employee_task( + self, + existing_codes: set[str], + spec: dict[str, object], + ) -> None: + code = str(spec["code"]) + config = self._build_runtime_digital_employee_config(spec) + + if code not in existing_codes: + asset = self._create_seed_asset( + asset_type=AgentAssetType.TASK.value, + code=code, + name=str(spec["name"]), + description=str(spec["description"]), + domain=AgentAssetDomain.SYSTEM.value, + scenario_json=list(spec["scenario_json"]), + owner=str(spec["owner"]), + reviewer=str(spec["reviewer"]), + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + config_json=config, + ) + else: + asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + if asset is None: + return + self._refresh_runtime_digital_employee_asset(asset, spec) + + markdown_builder = spec["markdown"] + if not callable(markdown_builder): + return + self._ensure_asset_version( + asset, + version="v1.0.0", + content=markdown_builder(), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note=str(spec["change_note"]), + created_by="系统初始化", + ) + + def _build_runtime_digital_employee_config( + self, + spec: dict[str, object], + *, + existing_config: dict[str, object] | None = None, + ) -> dict[str, object]: + code = str(spec["code"]) + cron = str(spec["cron"]) + base = { + **self._digital_employee_task_config(code, cron), + "schedule": cron, + "cron_expression": cron, + **dict(spec["config"]), + } + if not existing_config: + return base + + existing_cron = ( + existing_config.get("cron") + or existing_config.get("schedule") + or existing_config.get("cron_expression") + ) + schedule_config = ( + {"cron": existing_cron, "schedule": existing_cron, "cron_expression": existing_cron} + if existing_cron + else {} + ) + return { + **existing_config, + "agent": AgentName.HERMES.value, + "task_type": code.replace("task.hermes.", "").replace(".", "_"), + "skill_category": str(spec["skill_category"]), + "skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES), + **dict(spec["config"]), + **schedule_config, + } + + def _refresh_runtime_digital_employee_asset( + self, + asset: AgentAsset, + spec: dict[str, object], + ) -> None: + asset.name = str(spec["name"]) + asset.description = str(spec["description"]) + asset.owner = str(spec["owner"]) + asset.reviewer = str(spec["reviewer"]) + asset.domain = AgentAssetDomain.SYSTEM.value + asset.scenario_json = list(spec["scenario_json"]) + if not str(asset.status or "").strip(): + asset.status = AgentAssetStatus.ACTIVE.value + if not str(asset.current_version or "").strip(): + asset.current_version = "v1.0.0" + if not str(asset.working_version or "").strip(): + asset.working_version = asset.current_version + + asset.config_json = self._build_runtime_digital_employee_config( + spec, + existing_config=dict(asset.config_json or {}), + ) + self.db.add(asset) diff --git a/server/src/app/services/agent_runs.py b/server/src/app/services/agent_runs.py index 886ceb8..e7173ab 100644 --- a/server/src/app/services/agent_runs.py +++ b/server/src/app/services/agent_runs.py @@ -11,7 +11,12 @@ 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 -from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead +from app.schemas.agent_run import ( + AgentRunRead, + AgentRunStatsRead, + AgentToolCallRead, + SemanticParseRead, +) from app.services.agent_foundation import AgentFoundationService from app.services.knowledge_ingest_log import enrich_knowledge_ingest_route_json @@ -47,6 +52,86 @@ class AgentRunService: return None return self._serialize_run(run, enrich_knowledge_ingest=True) + def summarize_runs( + self, + *, + agent: str | None = None, + status: str | None = None, + source: str | None = None, + limit: int = 200, + ) -> AgentRunStatsRead: + self._ensure_ready() + self._reconcile_stale_knowledge_index_runs() + runs = self.repository.list(agent=agent, status=status, source=source, limit=limit) + agents: dict[str, int] = {} + statuses: dict[str, int] = {} + tool_statuses: dict[str, int] = {} + tool_call_count = 0 + failed_tool_call_count = 0 + llm_call_count = 0 + failed_llm_call_count = 0 + model_fallback_count = 0 + model_guardrail_count = 0 + recent_errors: list[dict[str, Any]] = [] + + for run in runs: + agents[run.agent] = agents.get(run.agent, 0) + 1 + statuses[run.status] = statuses.get(run.status, 0) + 1 + ontology_json = run.ontology_json or {} + if ontology_json.get("parse_strategy") == "rule_fallback": + model_fallback_count += 1 + model_summary = ontology_json.get("model_invocation_summary") + if isinstance(model_summary, dict) and model_summary.get("model_guardrail_reason"): + model_guardrail_count += 1 + if run.status == AgentRunStatus.FAILED.value and run.error_message: + recent_errors.append( + { + "run_id": run.run_id, + "agent": run.agent, + "stage": (run.route_json or {}).get("stage"), + "message": run.error_message, + } + ) + + for tool_call in run.tool_calls: + tool_call_count += 1 + tool_statuses[tool_call.status] = tool_statuses.get(tool_call.status, 0) + 1 + failed = tool_call.status == "failed" + if failed: + failed_tool_call_count += 1 + if tool_call.tool_type == "llm": + llm_call_count += 1 + if failed: + failed_llm_call_count += 1 + if tool_call.error_message: + recent_errors.append( + { + "run_id": run.run_id, + "agent": run.agent, + "tool_name": tool_call.tool_name, + "tool_type": tool_call.tool_type, + "message": tool_call.error_message, + } + ) + + return AgentRunStatsRead( + window_limit=limit, + total_runs=len(runs), + succeeded_runs=statuses.get(AgentRunStatus.SUCCEEDED.value, 0), + blocked_runs=statuses.get(AgentRunStatus.BLOCKED.value, 0), + failed_runs=statuses.get(AgentRunStatus.FAILED.value, 0), + tool_call_count=tool_call_count, + failed_tool_call_count=failed_tool_call_count, + llm_call_count=llm_call_count, + failed_llm_call_count=failed_llm_call_count, + model_fallback_count=model_fallback_count, + model_guardrail_count=model_guardrail_count, + agents=agents, + statuses=statuses, + tool_statuses=tool_statuses, + recent_errors=recent_errors[:10], + ) + def create_run( self, *, diff --git a/server/src/app/services/audit.py b/server/src/app/services/audit.py index 4207ce8..a747ffb 100644 --- a/server/src/app/services/audit.py +++ b/server/src/app/services/audit.py @@ -1,7 +1,8 @@ from __future__ import annotations -import uuid -from typing import Any +import uuid +from datetime import UTC, datetime +from typing import Any from sqlalchemy.orm import Session @@ -47,15 +48,16 @@ class AuditLogService: after_json: dict[str, Any] | None = None, request_id: str | None = None, ) -> AuditLog: - log = AuditLog( - actor=actor, - action=action, - resource_type=resource_type, - resource_id=resource_id, - before_json=before_json, - after_json=after_json, - request_id=request_id or uuid.uuid4().hex, - ) + log = AuditLog( + actor=actor, + action=action, + resource_type=resource_type, + resource_id=resource_id, + before_json=before_json, + after_json=after_json, + request_id=request_id or uuid.uuid4().hex, + created_at=datetime.now(UTC), + ) created = self.repository.create(log) logger.info( "Created audit log id=%s action=%s resource=%s:%s", diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index 7d14e01..c8f6be1 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -16,6 +16,7 @@ from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse from app.services.employee import EmployeeService from app.services.employee_seed import ROLE_DISPLAY_ORDER from app.services.settings import SettingsService +from app.services.user_session_metrics import UserSessionMetricService logger = get_logger("app.services.auth") @@ -62,7 +63,7 @@ class AuthService: admin_user = self._authenticate_admin(identifier, password) if admin_user is not None: logger.info("Admin login succeeded identifier=%s", identifier) - return LoginResponse(user=self._serialize_user(admin_user)) + return self._build_login_response(admin_user) employee_user = self._authenticate_employee(identifier, password) if employee_user is not None: @@ -71,11 +72,15 @@ class AuthService: identifier, ",".join(employee_user.role_codes), ) - return LoginResponse(user=self._serialize_user(employee_user)) + return self._build_login_response(employee_user) logger.warning("Login failed identifier=%s", identifier) raise ValueError("账号或密码错误。") + def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse: + session = UserSessionMetricService(self.db).start_session(user) + return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id) + def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None: record = SettingsService(self.db).verify_admin_login(identifier, password) if record is None: diff --git a/server/src/app/services/employee_behavior_profile_helpers.py b/server/src/app/services/employee_behavior_profile_helpers.py index 51819a7..2b34b92 100644 --- a/server/src/app/services/employee_behavior_profile_helpers.py +++ b/server/src/app/services/employee_behavior_profile_helpers.py @@ -9,6 +9,7 @@ from app.algorithem.employee_behavior_profile import ALGORITHM_VERSION from app.models.agent_run import AgentRun from app.models.employee import Employee from app.models.financial_record import ExpenseClaim +from app.services.user_session_metrics import UserSessionMetricService TRAVEL_EXPENSE_TYPES = { "travel", @@ -174,6 +175,50 @@ class EmployeeBehaviorProfileMetricHelpers: def _sum_agent_run_duration_ms(self, runs: list[AgentRun]) -> int: return sum(self._agent_run_duration_ms(run) for run in runs) + def _resolve_usage_duration_metrics( + self, + identifiers: set[str], + cutoff: Any, + runs: list[AgentRun], + ) -> dict[str, Any]: + ai_duration_ms = self._sum_agent_run_duration_ms(runs) + online_duration_ms = UserSessionMetricService(self.db).sum_duration_ms(identifiers, cutoff) + if online_duration_ms > 0: + usage_duration_ms = online_duration_ms + usage_duration_mode = "online_session" + else: + usage_duration_ms = ai_duration_ms + usage_duration_mode = "agent_run_fallback" + return { + "online_duration_ms": online_duration_ms, + "usage_duration_ms": usage_duration_ms, + "usage_duration_mode": usage_duration_mode, + "ai_run_duration_ms": ai_duration_ms, + "ai_run_duration_mode": "elapsed_or_tool_call_fallback", + } + + def _merge_live_usage_duration_metrics( + self, + payloads: list[dict[str, Any]], + identifiers: set[str], + cutoff: Any, + ) -> list[dict[str, Any]]: + online_duration_ms = UserSessionMetricService(self.db).sum_duration_ms(identifiers, cutoff) + if online_duration_ms <= 0: + return payloads + + next_payloads: list[dict[str, Any]] = [] + for payload in payloads: + if payload.get("profile_type") != "ai_usage": + next_payloads.append(payload) + continue + metrics = dict(payload.get("metrics") or {}) + metrics["online_duration_ms"] = online_duration_ms + metrics["usage_duration_ms"] = online_duration_ms + metrics["usage_duration_mode"] = "online_session" + next_payloads.append({**payload, "metrics": metrics}) + return next_payloads + def _agent_run_duration_ms(self, run: AgentRun) -> int: if run.started_at is not None and run.finished_at is not None: try: diff --git a/server/src/app/services/employee_behavior_profile_service.py b/server/src/app/services/employee_behavior_profile_service.py index 0343dc0..4b24ac7 100644 --- a/server/src/app/services/employee_behavior_profile_service.py +++ b/server/src/app/services/employee_behavior_profile_service.py @@ -466,7 +466,9 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"} ] estimated_tokens = self._estimate_tokens(runs) - duration_ms = self._sum_agent_run_duration_ms(runs) + usage_duration_metrics = self._resolve_usage_duration_metrics( + context["employee_identifiers"], context["cutoff"], runs + ) override_score = 0 token_mode = "estimated_token_count" if estimated_tokens else "unavailable" @@ -525,8 +527,7 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): "token_count_mode": token_mode, "estimated_token_count": estimated_tokens, "exact_token_count": None, - "ai_run_duration_ms": duration_ms, - "ai_run_duration_mode": "elapsed_or_tool_call_fallback", + **usage_duration_metrics, }, ) @@ -688,7 +689,11 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): expense_score=expense_score, process_score=process_score, ) - profile_payloads = build_profile_payloads(rows) + profile_payloads = self._merge_live_usage_duration_metrics( + build_profile_payloads(rows), + self._employee_identifiers(employee), + datetime.now(UTC) - timedelta(days=window_days), + ) profile_tags = build_profile_tags(profile_payloads, scene=scene) radar = build_profile_radar(profile_payloads, profile_tags, scene=scene) diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 0e84013..374283a 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -591,27 +591,30 @@ class ExpenseClaimAccessPolicy: *, include_approval_scope: bool = False, ) -> Any: - if self.has_privileged_claim_access(current_user): - owned_conditions = self.build_personal_claim_conditions(current_user) - archived_condition = self.build_archived_claim_condition() - if owned_conditions: - return stmt.where( + conditions = self.build_personal_claim_conditions(current_user) + + if include_approval_scope: + role_codes = self.normalize_role_codes(current_user) + if current_user.is_admin or "executive" in role_codes: + conditions.append(ExpenseClaim.status.in_(("submitted", PAYMENT_PENDING_STATUS, "returned"))) + elif "finance" in role_codes: + conditions.append( or_( - ~archived_condition, - and_(archived_condition, or_(*owned_conditions)), + and_( + ExpenseClaim.status == "submitted", + ExpenseClaim.approval_stage == FINANCE_APPROVAL_STAGE, + ), + ExpenseClaim.status.in_((PAYMENT_PENDING_STATUS, "returned")), ) ) - return stmt.where(~archived_condition) - - conditions = self.build_personal_claim_conditions(current_user) + conditions.extend(self.build_budget_approval_claim_conditions(current_user)) + conditions.extend(self.build_approval_claim_conditions(current_user)) + if self.has_archive_center_access(current_user): + conditions.append(self.build_archived_claim_condition()) if not conditions: return stmt.where(ExpenseClaim.id == "__no_visible_claim__") - if include_approval_scope: - conditions.extend(self.build_budget_approval_claim_conditions(current_user)) - conditions.extend(self.build_approval_claim_conditions(current_user)) - return stmt.where(or_(*conditions)) def apply_archived_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: diff --git a/server/src/app/services/expense_claim_application_handoff.py b/server/src/app/services/expense_claim_application_handoff.py index f3327cf..eecf709 100644 --- a/server/src/app/services/expense_claim_application_handoff.py +++ b/server/src/app/services/expense_claim_application_handoff.py @@ -27,6 +27,45 @@ class ExpenseClaimApplicationHandoffMixin: return normalized.removesuffix("_application") or "other" return normalized or "other" + @staticmethod + def _resolve_application_detail(application_claim: ExpenseClaim) -> dict[str, str]: + for flag in list(application_claim.risk_flags_json or []): + if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail": + continue + detail = flag.get("application_detail") or flag.get("applicationDetail") or {} + if isinstance(detail, dict): + return {str(key): str(value or "").strip() for key, value in detail.items()} + return {} + + @staticmethod + def _build_application_handoff_detail(application_claim: ExpenseClaim) -> dict[str, str]: + detail = ExpenseClaimApplicationHandoffMixin._resolve_application_detail(application_claim) + application_time = str(detail.get("time") or "").strip() + if not application_time and application_claim.occurred_at is not None: + application_time = application_claim.occurred_at.isoformat() + + application_amount = str(detail.get("amount") or "").strip() + if not application_amount: + application_amount = str(application_claim.amount or Decimal("0.00")) + + return { + "application_type": str(detail.get("application_type") or application_claim.expense_type or "").strip(), + "application_content": " / ".join( + item + for item in [ + str(detail.get("application_type") or application_claim.expense_type or "").strip(), + str(detail.get("location") or application_claim.location or "").strip(), + ] + if item + ), + "application_reason": str(detail.get("reason") or application_claim.reason or "").strip(), + "application_days": str(detail.get("days") or "").strip(), + "application_location": str(detail.get("location") or application_claim.location or "").strip(), + "application_amount": application_amount, + "application_time": application_time, + "application_transport_mode": str(detail.get("transport_mode") or "").strip(), + } + def _create_reimbursement_draft_from_application( self, *, @@ -67,6 +106,7 @@ class ExpenseClaimApplicationHandoffMixin: "application_claim_id": application_claim.id, "application_claim_no": application_claim.claim_no, "application_budget_amount": str(application_claim.amount or Decimal("0.00")), + "application_detail": self._build_application_handoff_detail(application_claim), "application_approval_event_id": str(approval_flag.get("approval_event_id") or ""), "leader_opinion": str( approval_flag.get("leader_opinion") or approval_flag.get("opinion") or "" diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index 133fcec..6276a01 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -36,6 +36,7 @@ class ExpenseClaimApprovalFlowMixin: previous_stage = str(claim.approval_stage or "").strip() is_application_claim = self._is_expense_application_claim(claim) next_budget_manager = None + merged_budget_approval = False if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有当前直属领导审批人可以审批通过该单据。") @@ -43,10 +44,17 @@ class ExpenseClaimApprovalFlowMixin: event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval" label = "领导审批通过" if is_application_claim: - next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) - next_status = "submitted" - next_stage = BUDGET_MANAGER_APPROVAL_STAGE - default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。" + merged_budget_approval = self._access_policy.is_department_p8_budget_monitor(current_user, claim) + if merged_budget_approval: + label = "领导及预算审核通过" + next_status = "approved" + next_stage = APPROVAL_DONE_STAGE + default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" + else: + next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) + next_status = "submitted" + next_stage = BUDGET_MANAGER_APPROVAL_STAGE + default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。" else: next_status = "submitted" next_stage = FINANCE_APPROVAL_STAGE @@ -108,6 +116,13 @@ class ExpenseClaimApprovalFlowMixin: "next_approval_stage": next_stage, "created_at": datetime.now(UTC).isoformat(), } + if merged_budget_approval: + approval_flag.update( + { + "budget_approval_merged": True, + "budget_approval_merged_reason": "direct_manager_is_department_budget_monitor", + } + ) if next_budget_manager is not None: approval_flag.update( { @@ -122,12 +137,16 @@ class ExpenseClaimApprovalFlowMixin: claim.approval_stage = next_stage if claim.submitted_at is None: claim.submitted_at = datetime.now(UTC) - if is_application_claim and previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: - approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion( - claim, - source="manual_approval", - ) - approval_flag["budget_opinion"] = approval_opinion + if is_application_claim and next_stage == APPROVAL_DONE_STAGE: + if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: + approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion( + claim, + source="manual_approval", + ) + approval_flag["budget_opinion"] = approval_opinion + elif merged_budget_approval: + approval_flag["leader_opinion"] = approval_opinion + approval_flag["budget_opinion"] = approval_opinion generated_draft = self._create_reimbursement_draft_from_application( application_claim=claim, approval_flag=approval_flag, diff --git a/server/src/app/services/expense_claim_risk_review.py b/server/src/app/services/expense_claim_risk_review.py index e0428d9..9359130 100644 --- a/server/src/app/services/expense_claim_risk_review.py +++ b/server/src/app/services/expense_claim_risk_review.py @@ -5,6 +5,7 @@ from typing import Any from sqlalchemy import or_, select +from app.core.logging import get_logger from app.models.financial_record import ExpenseClaim from app.services.expense_claim_constants import ( AI_REVIEW_LOOKBACK_DAYS, @@ -14,6 +15,9 @@ from app.services.expense_claim_constants import ( from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin +from app.services.risk_observations import RiskObservationService + +logger = get_logger("app.services.expense_claim_risk_review") class ExpenseClaimRiskReviewMixin( @@ -26,12 +30,16 @@ class ExpenseClaimRiskReviewMixin( attachment_flags = [ flag for flag in base_flags - if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis" + if isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "attachment_analysis" ] preserved_flags = [ flag for flag in base_flags - if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review") + if not ( + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "submission_review" + ) ] review_flags: list[dict[str, Any]] = [] @@ -66,7 +74,10 @@ class ExpenseClaimRiskReviewMixin( "source": "submission_review", "severity": "medium", "label": "AI预审提醒", - "message": f"AI预审发现 {len(medium_attachment_flags)} 条中风险附件,已随单流转给审批人复核。", + "message": ( + f"AI预审发现 {len(medium_attachment_flags)} 条中风险附件," + "已随单流转给审批人复核。" + ), } ) @@ -90,7 +101,8 @@ class ExpenseClaimRiskReviewMixin( "severity": "medium", "label": "历史风险偏高", "message": ( - f"近 {AI_REVIEW_LOOKBACK_DAYS} 天内该员工已有 {historical_risk_count} 笔带风险标记的报销," + f"近 {AI_REVIEW_LOOKBACK_DAYS} 天内该员工已有 " + f"{historical_risk_count} 笔带风险标记的报销," "本次已追加到审批链重点关注。" ), } @@ -102,7 +114,8 @@ class ExpenseClaimRiskReviewMixin( "severity": "low", "label": "历史风险提醒", "message": ( - f"近 {AI_REVIEW_LOOKBACK_DAYS} 天内该员工已有 {historical_risk_count} 笔带风险标记的报销," + f"近 {AI_REVIEW_LOOKBACK_DAYS} 天内该员工已有 " + f"{historical_risk_count} 笔带风险标记的报销," "建议直属领导重点复核。" ), } @@ -118,7 +131,19 @@ class ExpenseClaimRiskReviewMixin( platform_risk_review = self.evaluate_platform_risk_rules(claim) attention_reasons.extend(platform_risk_review["blocking_reasons"]) - review_flags.extend(platform_risk_review["flags"]) + platform_risk_flags = list(platform_risk_review["flags"]) + review_flags.extend(platform_risk_flags) + if platform_risk_flags: + try: + RiskObservationService(self.db).upsert_platform_risk_flags( + claim, + platform_risk_flags, + ) + except Exception: + logger.exception( + "Failed to persist platform risk observations for claim_id=%s", + claim.id, + ) if attention_reasons: summary_message = "AI预审发现需审批重点关注事项:" + ";".join( @@ -150,7 +175,10 @@ class ExpenseClaimRiskReviewMixin( if claim.employee is not None: if claim.employee.manager is not None and claim.employee.manager.name: return str(claim.employee.manager.name).strip() - if claim.employee.organization_unit is not None and claim.employee.organization_unit.manager_name: + if ( + claim.employee.organization_unit is not None + and claim.employee.organization_unit.manager_name + ): return str(claim.employee.organization_unit.manager_name).strip() return "" diff --git a/server/src/app/services/finance_dashboard.py b/server/src/app/services/finance_dashboard.py new file mode 100644 index 0000000..4eca971 --- /dev/null +++ b/server/src/app/services/finance_dashboard.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import re +from collections import defaultdict +from datetime import UTC, date, datetime, time, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.budget import BudgetAllocation +from app.models.financial_record import ExpenseClaim +from app.models.risk_observation import RiskObservation +from app.schemas.finance_dashboard import FinanceDashboardRead +from app.services.budget_support import BudgetSupportMixin +from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS + +SLA_TARGET_HOURS = Decimal("8.0") +PENDING_STATUSES = { + "submitted", + "review", + "pending_review", + "manager_review", + "budget_review", + "finance_review", + "approving", +} +SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"} +EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"} +EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}] +CHART_COLORS = [ + "var(--theme-primary)", + "var(--chart-blue)", + "var(--chart-amber)", + "var(--chart-purple)", + "var(--success)", + "var(--danger)", +] +STAGE_LABELS = { + "manager": "直属经理", + "manager_review": "直属经理", + "budget": "预算复核", + "budget_review": "预算复核", + "finance": "财务审核", + "finance_review": "财务审核", + "payment": "付款确认", + "pending_payment": "付款确认", +} +RISK_SIGNAL_LABELS = { + "duplicate_invoice": "重复发票", + "split_billing": "拆分报销", + "frequent_small_claims": "高频小额", + "location_mismatch": "地点不一致", + "amount_outlier": "金额异常", + "preapproval_absent": "缺少事前申请", +} + + +class FinanceDashboardService(BudgetSupportMixin): + def __init__(self, db: Session) -> None: + self.db = db + + def build_dashboard( + self, + *, + range_key: str = "近10日", + start_date: date | None = None, + end_date: date | None = None, + trend_range: str = "近12天", + department_range: str = "本月", + ) -> FinanceDashboardRead: + self._ensure_storage_ready() + now = datetime.now(UTC) + start, end, resolved_key = self._resolve_scope( + range_key=range_key, + start_date=start_date, + end_date=end_date, + now=now, + ) + previous_start = start - (end - start) + trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now) + department_start, department_end = self._resolve_department_scope(department_range, now) + + claims = self._fetch_claims() + observations = self._fetch_risk_observations() + scope_claims = self._claims_between(claims, start, end) + previous_claims = self._claims_between(claims, previous_start, start) + trend_claims = self._claims_between(claims, trend_start, trend_end) + department_claims = self._claims_between(claims, department_start, department_end) + scope_observations = self._observations_between(observations, start, end) + + totals = self._totals(scope_claims, scope_observations, now) + previous_totals = self._totals(previous_claims, [], now) + + return FinanceDashboardRead( + range_key=resolved_key, + start_date=start.date().isoformat(), + end_date=(end - timedelta(days=1)).date().isoformat(), + generated_at=now.isoformat(), + has_real_data=bool(claims or observations or self._fetch_budget_allocations(now.year)), + totals=totals, + metric_meta=self._metric_meta(totals, previous_totals), + trend=self._trend(trend_labels, trend_claims, now), + spend_by_category=self._spend_by_category(scope_claims), + exception_mix=self._exception_mix(scope_claims, scope_observations), + department_ranking=self._department_ranking(department_claims), + bottlenecks=self._bottlenecks(scope_claims, now), + budget_summary=self._budget_summary(now.year), + ) + + def _ensure_storage_ready(self) -> None: + Base.metadata.create_all(bind=self.db.get_bind()) + + def _fetch_claims(self) -> list[ExpenseClaim]: + stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc()) + return list(self.db.scalars(stmt).all()) + + def _fetch_risk_observations(self) -> list[RiskObservation]: + stmt = select(RiskObservation).order_by(RiskObservation.created_at.asc()) + return list(self.db.scalars(stmt).all()) + + def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]: + stmt = ( + select(BudgetAllocation) + .where(BudgetAllocation.fiscal_year == fiscal_year) + .order_by(BudgetAllocation.period_key.asc()) + ) + return list(self.db.scalars(stmt).all()) + + def _resolve_scope( + self, + *, + range_key: str, + start_date: date | None, + end_date: date | None, + now: datetime, + ) -> tuple[datetime, datetime, str]: + today = now.date() + normalized_key = str(range_key or "").strip() or "近10日" + + if start_date and end_date: + start_day = min(start_date, end_date) + end_day = max(start_date, end_date) + return self._day_start(start_day), self._day_after(end_day), "自定义" + + if normalized_key == "今日": + start_day = today + elif normalized_key == "本周": + start_day = today - timedelta(days=today.weekday()) + elif normalized_key == "本月": + start_day = today.replace(day=1) + else: + days = self._days_from_label(normalized_key, default=10) + start_day = today - timedelta(days=days - 1) + + return self._day_start(start_day), self._day_after(today), normalized_key + + def _resolve_trend_scope( + self, + trend_range: str, + now: datetime, + ) -> tuple[datetime, datetime, list[str]]: + days = self._days_from_label(trend_range, default=12) + end_day = now.date() + start_day = end_day - timedelta(days=days - 1) + labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)] + return self._day_start(start_day), self._day_after(end_day), labels + + def _resolve_department_scope( + self, + department_range: str, + now: datetime, + ) -> tuple[datetime, datetime]: + today = now.date() + key = str(department_range or "").strip() + if key == "本周": + start_day = today - timedelta(days=today.weekday()) + elif key == "本季度": + quarter_month = ((today.month - 1) // 3) * 3 + 1 + start_day = today.replace(month=quarter_month, day=1) + else: + start_day = today.replace(day=1) + return self._day_start(start_day), self._day_after(today) + + def _claims_between( + self, + claims: list[ExpenseClaim], + start: datetime, + end: datetime, + ) -> list[ExpenseClaim]: + return [claim for claim in claims if start <= self._claim_time(claim) < end] + + def _observations_between( + self, + observations: list[RiskObservation], + start: datetime, + end: datetime, + ) -> list[RiskObservation]: + return [item for item in observations if start <= self._as_utc(item.created_at) < end] + + def _totals( + self, + claims: list[ExpenseClaim], + observations: list[RiskObservation], + now: datetime, + ) -> dict[str, Any]: + active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}] + pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES] + success_claims = [claim for claim in active_claims if self._status(claim) in SUCCESS_STATUSES] + risk_claim_keys = {self._claim_key(claim) for claim in active_claims if self._has_claim_risk(claim)} + observation_keys = { + str(item.claim_no or item.subject_key or item.id).strip() + for item in observations + if str(item.status or "").strip().lower() != "false_positive" + } + sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at] + sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS) + clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim)) + + return { + "pendingCount": len(pending_claims), + "pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))), + "avgSla": self._decimal_number(self._average(sla_hours)), + "autoPassRate": self._percent(clean_success, len(active_claims)), + "riskCount": len({key for key in risk_claim_keys | observation_keys if key}), + "slaRate": self._percent(sla_met, len(sla_hours)), + } + + def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]: + unit_by_key = { + "pendingCount": "单", + "pendingAmount": "元", + "avgSla": "h", + "autoPassRate": "%", + "riskCount": "单", + "slaRate": "%", + } + meta: dict[str, Any] = {} + for key, current_value in current.items(): + previous_value = Decimal(str(previous.get(key, 0) or 0)) + value = Decimal(str(current_value or 0)) + diff = value - previous_value + change = self._change_percent(value, previous_value) + unit = unit_by_key.get(key, "") + meta[key] = { + "changeText": f"{'+' if change >= 0 else ''}{change:.1f}%", + "delta": f"较上一周期 {'+' if diff >= 0 else ''}{self._format_delta(diff, unit)}", + "trend": "up" if diff >= 0 else "down", + } + return meta + + def _trend( + self, + labels: list[str], + claims: list[ExpenseClaim], + now: datetime, + ) -> dict[str, Any]: + applications = [0 for _ in labels] + approved = [0 for _ in labels] + hours: list[list[Decimal]] = [[] for _ in labels] + index = {label: idx for idx, label in enumerate(labels)} + + for claim in claims: + if self._status(claim) == "draft": + continue + label = self._date_label(self._claim_time(claim).date()) + if label not in index: + continue + bucket = index[label] + applications[bucket] += 1 + if self._status(claim) in SUCCESS_STATUSES: + approved[bucket] += 1 + if claim.submitted_at: + hours[bucket].append(self._claim_sla_hours(claim, now)) + + return { + "labels": labels, + "applications": applications, + "approved": approved, + "avgHours": [self._decimal_number(self._average(row)) for row in hours], + } + + def _spend_by_category(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]: + buckets: dict[str, Decimal] = defaultdict(Decimal) + for claim in claims: + if self._status(claim) in EXCLUDED_SPEND_STATUSES: + continue + label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type) + buckets[str(label or "其他费用")] += self._claim_amount(claim) + + rows = [ + {"name": name, "value": self._decimal_number(value), "color": CHART_COLORS[index % len(CHART_COLORS)]} + for index, (name, value) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]) + ] + return rows or EMPTY_DONUT + + def _exception_mix( + self, + claims: list[ExpenseClaim], + observations: list[RiskObservation], + ) -> list[dict[str, Any]]: + buckets: dict[str, int] = defaultdict(int) + + for observation in observations: + key = str(observation.risk_signal or observation.risk_type or "").strip() + buckets[RISK_SIGNAL_LABELS.get(key, key.replace("_", " ") or "风险观察")] += 1 + + if not buckets: + for claim in claims: + if self._status(claim) in {"draft", "deleted"}: + continue + for label in self._claim_risk_labels(claim): + buckets[label] += 1 + + rows = [ + {"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]} + for index, (name, count) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]) + ] + return rows or EMPTY_DONUT + + def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]: + buckets: dict[str, Decimal] = defaultdict(Decimal) + for claim in claims: + if self._status(claim) not in PENDING_STATUSES: + continue + buckets[str(claim.department_name or "未归属部门")] += self._claim_amount(claim) + + rows = [ + { + "name": name, + "amount": self._decimal_number(amount), + "value": self._decimal_number(amount), + "color": CHART_COLORS[index % len(CHART_COLORS)], + } + for index, (name, amount) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:5]) + ] + return rows + + def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]: + buckets: dict[str, list[Decimal]] = defaultdict(list) + for claim in claims: + if self._status(claim) not in PENDING_STATUSES: + continue + stage = self._stage_label(claim) + buckets[stage].append(self._claim_sla_hours(claim, now)) + + rows: list[dict[str, Any]] = [] + for index, (stage, values) in enumerate(sorted(buckets.items(), key=lambda item: self._average(item[1]), reverse=True)[:3]): + avg_hours = self._average(values) + rows.append( + { + "name": stage, + "role": "审批节点", + "duration": f"{self._decimal_number(avg_hours):.1f} h", + "status": self._duration_status(avg_hours), + "tone": self._duration_tone(avg_hours), + "avatar": stage[:1] or str(index + 1), + } + ) + return rows + + def _budget_summary(self, fiscal_year: int) -> dict[str, Any]: + allocations = self._fetch_budget_allocations(fiscal_year) + total = Decimal("0.00") + used = Decimal("0.00") + available = Decimal("0.00") + + for allocation in allocations: + balance = self.get_balance(allocation) + total += balance.total_amount + used += balance.reserved_amount + balance.consumed_amount + available += balance.available_amount + + ratio = Decimal("0.00") + if total > Decimal("0.00"): + ratio = (used / total) * Decimal("100") + + return { + "ratio": self._decimal_number(ratio), + "total": self._currency(total), + "used": self._currency(used), + "left": self._currency(available), + } + + def _claim_time(self, claim: ExpenseClaim) -> datetime: + return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at) + + def _claim_sla_hours(self, claim: ExpenseClaim, now: datetime) -> Decimal: + start = self._as_utc(claim.submitted_at or claim.created_at or claim.occurred_at) + end = now + if self._status(claim) in SUCCESS_STATUSES | {"rejected", "returned"} and claim.updated_at: + end = self._as_utc(claim.updated_at) + hours = Decimal(str(max((end - start).total_seconds(), 0))) / Decimal("3600") + return hours.quantize(Decimal("0.1")) + + def _claim_amount(self, claim: ExpenseClaim) -> Decimal: + return Decimal(str(claim.amount or 0)) + + def _claim_key(self, claim: ExpenseClaim) -> str: + return str(claim.claim_no or claim.id or "").strip() + + def _has_claim_risk(self, claim: ExpenseClaim) -> bool: + return bool(claim.hermes_risk_flag or self._risk_flags(claim)) + + def _claim_risk_labels(self, claim: ExpenseClaim) -> list[str]: + labels: list[str] = [] + if claim.hermes_risk_flag: + labels.append("风险扫描命中") + for flag in self._risk_flags(claim): + if isinstance(flag, dict): + label = str(flag.get("label") or flag.get("message") or flag.get("type") or "").strip() + else: + label = str(flag or "").strip() + labels.append(label or "规则异常") + return labels + + def _risk_flags(self, claim: ExpenseClaim) -> list[Any]: + flags = claim.risk_flags_json or [] + return flags if isinstance(flags, list) else [] + + def _stage_label(self, claim: ExpenseClaim) -> str: + stage = str(claim.approval_stage or self._status(claim) or "").strip().lower() + return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批") + + def _status(self, claim: ExpenseClaim) -> str: + return str(claim.status or "").strip().lower() + + def _as_utc(self, value: datetime | None) -> datetime: + if value is None: + return datetime.now(UTC) + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + def _day_start(self, value: date) -> datetime: + return datetime.combine(value, time.min, tzinfo=UTC) + + def _day_after(self, value: date) -> datetime: + return datetime.combine(value + timedelta(days=1), time.min, tzinfo=UTC) + + def _date_label(self, value: date) -> str: + return value.strftime("%m-%d") + + def _days_from_label(self, value: str, *, default: int) -> int: + match = re.search(r"\d+", str(value or "")) + if not match: + return default + return max(1, min(int(match.group(0)), 90)) + + def _duration_status(self, hours: Decimal) -> str: + if hours >= Decimal("12"): + return "较慢" + if hours >= SLA_TARGET_HOURS: + return "偏慢" + return "正常" + + def _duration_tone(self, hours: Decimal) -> str: + if hours >= Decimal("12"): + return "danger" + if hours >= SLA_TARGET_HOURS: + return "warning" + return "success" + + def _average(self, values: list[Decimal]) -> Decimal: + if not values: + return Decimal("0.00") + return sum(values, Decimal("0.00")) / Decimal(str(len(values))) + + def _percent(self, part: int | Decimal, total: int | Decimal) -> float: + total_decimal = Decimal(str(total or 0)) + if total_decimal <= Decimal("0"): + return 0.0 + return self._decimal_number((Decimal(str(part or 0)) / total_decimal) * Decimal("100")) + + def _change_percent(self, current: Decimal, previous: Decimal) -> float: + if previous == Decimal("0"): + return 0.0 if current == Decimal("0") else 100.0 + return self._decimal_number(((current - previous) / previous) * Decimal("100")) + + def _decimal_number(self, value: Decimal) -> float: + return float(value.quantize(Decimal("0.1"))) + + def _format_delta(self, value: Decimal, unit: str) -> str: + if unit == "元": + return self._currency(value) + if unit == "h": + return f"{self._decimal_number(value):.1f}h" + if unit == "%": + return f"{self._decimal_number(value):.1f}%" + return f"{int(value)}{unit}" + + def _currency(self, value: Decimal) -> str: + prefix = "-¥" if value < Decimal("0") else "¥" + amount = abs(value) + return f"{prefix}{amount:,.0f}" diff --git a/server/src/app/services/hermes_employee_profile_scanner.py b/server/src/app/services/hermes_employee_profile_scanner.py index e3bfb93..f07f2c3 100644 --- a/server/src/app/services/hermes_employee_profile_scanner.py +++ b/server/src/app/services/hermes_employee_profile_scanner.py @@ -2,9 +2,14 @@ from __future__ import annotations import json +from sqlalchemy import select from sqlalchemy.orm import Session +from sqlalchemy.orm import selectinload from app.core.logging import get_logger +from app.algorithem.risk_graph.models import RiskGraphClaimSnapshot +from app.algorithem.risk_graph.profile_baselines import ProfileBaselineUpdater +from app.models.financial_record import ExpenseClaim from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService logger = get_logger("app.services.hermes_employee_profile_scanner") @@ -17,8 +22,23 @@ class HermesEmployeeProfileScannerService: def scan_employee_profiles(self, log_id: str | None = None) -> dict: logger.info("Starting Hermes employee behavior profile scan...") summary = EmployeeBehaviorProfileService(self.db).scan_profiles(log_id=log_id) + baseline_summary = self._build_baseline_summary() + summary["baseline_summary"] = baseline_summary logger.info( "Hermes employee profile scan completed: %s", json.dumps(summary, ensure_ascii=False), ) return summary + + def _build_baseline_summary(self) -> dict: + stmt = ( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .order_by(ExpenseClaim.occurred_at.desc()) + .limit(500) + ) + claims = [ + RiskGraphClaimSnapshot.from_orm(claim) + for claim in self.db.scalars(stmt).all() + ] + return ProfileBaselineUpdater().build_from_claims(claims).as_dict() diff --git a/server/src/app/services/hermes_risk_scanner.py b/server/src/app/services/hermes_risk_scanner.py index 5b78f77..43dce92 100644 --- a/server/src/app/services/hermes_risk_scanner.py +++ b/server/src/app/services/hermes_risk_scanner.py @@ -1,135 +1,128 @@ from __future__ import annotations -import json from datetime import datetime, timezone -from typing import Any from sqlalchemy import or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload +from app.algorithem.risk_graph import ( + RiskGraphClaimSnapshot, + RiskGraphEvaluationContext, + evaluate_financial_risk_graph, +) from app.core.logging import get_logger from app.models.financial_record import ExpenseClaim -from app.models.hermes_config import HermesTaskExecutionLog from app.models.hermes_report import HermesRiskReport -from app.services.runtime_chat import RuntimeChatService +from app.services.risk_observations import RiskObservationService logger = get_logger("app.services.hermes_risk_scanner") + class HermesRiskScannerService: def __init__(self, db: Session) -> None: self.db = db - self.chat_service = RuntimeChatService(db) - def scan_global_risks(self, log_id: str | None = None) -> None: + def scan_global_risks( + self, + log_id: str | None = None, + run_id: str | None = None, + ) -> dict[str, int]: logger.info("Starting global risk scan for Hermes...") - - # 1. Fetch unscanned claims + claims = self._fetch_unscanned_claims() if not claims: logger.info("No unscanned claims found. Aborting scan.") - return + return {"scanned_claim_count": 0, "risk_observation_count": 0} logger.info(f"Fetched {len(claims)} claims to analyze.") - - # 2. Extract context for LLM - claims_context = [] - for c in claims: - claims_context.append({ - "claim_id": c.id, - "claim_no": c.claim_no, - "employee_name": c.employee_name, - "department_name": c.department_name, - "expense_type": c.expense_type, - "location": c.location, - "amount": float(c.amount), - "occurred_at": str(c.occurred_at) if c.occurred_at else None, - "reason": c.reason, - }) - - # 3. Analyze with LLM - risk_results = self._analyze_claims_with_llm(claims_context) - - # 4. Process and persist results - detected_risk_count = 0 - if risk_results: - for risk in risk_results: - claim_ids = risk.get("claim_ids", []) - if not claim_ids: - continue - - detected_risk_count += 1 - for cid in claim_ids: - report = HermesRiskReport( - claim_id=cid, - execution_log_id=log_id, - risk_level=risk.get("risk_level", "medium"), - risk_type=risk.get("risk_type", "unknown"), - risk_description=risk.get("description", "No description provided"), - related_claim_ids=claim_ids, - ) - self.db.add(report) - - # Update claim flags - claim_obj = next((c for c in claims if c.id == cid), None) - if claim_obj: - claim_obj.hermes_risk_flag = True + observation_service = RiskObservationService(self.db) - # 5. Mark all as scanned - now = datetime.now(timezone.utc) - for c in claims: - c.hermes_scanned_at = now - - self.db.commit() - logger.info(f"Hermes risk scan completed. Found {detected_risk_count} risks.") - - def _fetch_unscanned_claims(self) -> list[ExpenseClaim]: - stmt = select(ExpenseClaim).where( - ExpenseClaim.status.in_(["draft", "submitted", "review"]), - or_( - ExpenseClaim.hermes_scanned_at.is_(None), - ExpenseClaim.hermes_risk_flag.is_(False) # only rescan if it has no flags yet + result = evaluate_financial_risk_graph( + RiskGraphEvaluationContext( + claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in claims], + target_claim_ids={claim.id for claim in claims}, + history_stats=observation_service.build_history_stats( + expense_types={str(claim.expense_type or "") for claim in claims}, + ), ) - ).limit(50) # Batch size to prevent Token overflow - + ) + claims_by_id = {claim.id: claim for claim in claims} + + for observation in result.observations: + claim = claims_by_id.get(observation.claim_id) + if claim is None: + continue + observation_service.upsert_observation( + observation, + run_id=run_id, + execution_log_id=log_id, + ) + claim.hermes_risk_flag = True + claim.risk_flags_json = self._append_algorithm_flag(claim, observation.as_dict()) + + if log_id: + self.db.add( + HermesRiskReport( + claim_id=observation.claim_id, + execution_log_id=log_id, + risk_level=observation.risk_level, + risk_type=observation.risk_signal, + risk_description=observation.description, + related_claim_ids=[ + observation.claim_id, + *observation.similar_case_claim_ids, + ], + ) + ) + + now = datetime.now(timezone.utc) + for claim in claims: + claim.hermes_scanned_at = now + + self.db.commit() + logger.info( + "Hermes risk graph scan completed. Found %s observations.", + len(result.observations), + ) + return { + "scanned_claim_count": len(claims), + "risk_observation_count": len(result.observations), + "graph_node_count": len(result.nodes), + "graph_edge_count": len(result.edges), + } + + def _fetch_unscanned_claims(self) -> list[ExpenseClaim]: + stmt = ( + select(ExpenseClaim) + .options(selectinload(ExpenseClaim.items)) + .where( + ExpenseClaim.status.in_(["draft", "submitted", "review"]), + or_( + ExpenseClaim.hermes_scanned_at.is_(None), + ExpenseClaim.hermes_risk_flag.is_(False), + ), + ) + .limit(50) + ) + return list(self.db.scalars(stmt).all()) - - def _analyze_claims_with_llm(self, claims_context: list[dict[str, Any]]) -> list[dict[str, Any]]: - system_prompt = ( - "你是 X-Financial 的 Hermes 内控审计智能体。请分析以下近期的报销单数据集合,寻找以下潜在风险:\n" - "1. 拆单行为 (split_billing):同一人在相邻日期针对同一类目/商户提交多笔恰好贴近免审额度的小额单据。\n" - "2. 群体合谋 (collusion):不同部门的员工在同一天去同一家非标准酒店类偏僻商户高额消费。\n" - "3. 异常频次 (frequency_anomaly):某员工在短时间内的打车或招待频次极度不合理。\n" - "请严格以 JSON 数组格式返回结果,如果没有风险返回空数组 `[]`。\n" - "JSON 格式要求:\n" - "[\n" - " {\n" - ' "risk_type": "split_billing",\n' - ' "risk_level": "high",\n' - ' "claim_ids": ["uuid-1", "uuid-2"],\n' - ' "description": "详细推理过程,为什么判定为拆单。"\n' - " }\n" - "]\n" - ) - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": json.dumps(claims_context, ensure_ascii=False, indent=2)} - ] - - response_text = self.chat_service.complete( - messages, - max_tokens=1500, - temperature=0.1 - ) - - if not response_text: - logger.warning("LLM returned empty response for risk scan.") - return [] - - # Clean markdown formatting if present - cleaned_text = response_text.replace("```json", "").replace("```", "").strip() - try: - return json.loads(cleaned_text) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse LLM risk scan response as JSON: {e}\nResponse: {response_text}") - return [] + + @staticmethod + def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list: + existing = list(claim.risk_flags_json or []) + flag = { + "source": "financial_risk_graph", + "risk_signal": observation.get("risk_signal"), + "severity": observation.get("risk_level"), + "risk_score": observation.get("risk_score"), + "confidence_score": observation.get("confidence_score"), + "algorithm_version": observation.get("algorithm_version"), + "observation_key": observation.get("observation_key"), + } + if any( + isinstance(item, dict) + and item.get("observation_key") == flag["observation_key"] + for item in existing + ): + return existing + return [*existing, flag] diff --git a/server/src/app/services/hermes_scheduler.py b/server/src/app/services/hermes_scheduler.py index fa0e497..f6f5e22 100644 --- a/server/src/app/services/hermes_scheduler.py +++ b/server/src/app/services/hermes_scheduler.py @@ -152,7 +152,11 @@ class HermesScheduler: try: if config.task_type == "global_risk_scan": scanner = HermesRiskScannerService(db) - scanner.scan_global_risks(log_id=log_record.id) + summary = scanner.scan_global_risks(log_id=log_record.id) + log_record.result_summary = ( + f"风险图谱巡检完成:扫描 {summary.get('scanned_claim_count', 0)} 张单据," + f"生成 {summary.get('risk_observation_count', 0)} 条风险观察。" + ) elif config.task_type == "weekly_expense_report": reporter = HermesExpenseReportService(db) reporter.generate_weekly_report(log_id=log_record.id) diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index 1201989..e769fde 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -11,6 +11,7 @@ from app.core.agent_enums import ( AgentPermissionLevel, AgentRunSource, AgentRunStatus, + AgentToolType, ) from app.core.logging import get_logger from app.models.employee import Employee @@ -59,6 +60,7 @@ class SemanticOntologyService( ontology_json=self._build_ontology_json(analyzed), route_json={ "stage": "semantic_parse", + "model_invocation_summary": self._build_model_invocation_summary(analyzed), "clarification_required": analyzed["clarification_required"], "field_error_count": len(analyzed["field_errors"]), }, @@ -86,11 +88,13 @@ class SemanticOntologyService( payload=payload, analyzed=analyzed, ) + self._record_model_invocations(run_id=run.run_id, analyzed=analyzed) return self._build_result(analyzed, run.run_id) def parse_for_run(self, payload: OntologyParseRequest, *, run_id: str) -> OntologyParseResult: analyzed = self._analyze(payload) self._record_semantic_parse(run_id=run_id, payload=payload, analyzed=analyzed) + self._record_model_invocations(run_id=run_id, analyzed=analyzed) return self._build_result(analyzed, run_id) def _analyze(self, payload: OntologyParseRequest) -> dict[str, object]: @@ -160,8 +164,10 @@ class SemanticOntologyService( metrics = self._extract_metrics(compact_query) constraints = self._extract_constraints(compact_query, entities) model_parse = None + model_invocations: list[dict[str, Any]] = [] + model_parse_error = None if session_scenario != "knowledge": - model_parse = self._parse_with_model( + model_parse, model_invocations, model_parse_error = self._parse_with_model( payload=payload, query=query, compact_query=compact_query, @@ -172,12 +178,23 @@ class SemanticOntologyService( metrics=metrics, constraints=constraints, ) - scenario = self._resolve_scenario(rule_scenario, model_parse) + model_guardrail_reason = ( + self._resolve_model_guardrail_reason( + model_parse, + rule_scenario=rule_scenario, + application_query=application_query, + ) + if session_scenario != "knowledge" + else None + ) + accepted_model_parse = None if model_guardrail_reason else model_parse + + scenario = self._resolve_scenario(rule_scenario, accepted_model_parse) if session_scenario == "knowledge": scenario = "knowledge" entities = self._merge_entities( entities, - model_parse.entity_hints if model_parse is not None else [], + accepted_model_parse.entity_hints if accepted_model_parse is not None else [], compact_query, ) intent = self._resolve_intent( @@ -186,10 +203,10 @@ class SemanticOntologyService( scenario=scenario, entities=entities, time_range=time_range, - model_parse=model_parse, + model_parse=accepted_model_parse, ) missing_slots = self._normalize_short_text_list( - model_parse.missing_slots if model_parse is not None else [] + accepted_model_parse.missing_slots if accepted_model_parse is not None else [] ) missing_slots = self._normalize_short_text_list( missing_slots @@ -216,7 +233,7 @@ class SemanticOntologyService( if relax_knowledge_follow_up: missing_slots = [item for item in missing_slots if item != "expense_type"] ambiguity = self._normalize_short_text_list( - model_parse.ambiguity if model_parse is not None else [] + accepted_model_parse.ambiguity if accepted_model_parse is not None else [] ) risk_flags = self._extract_risk_flags(compact_query, scenario) permission = self._resolve_permission( @@ -246,11 +263,13 @@ class SemanticOntologyService( intent=intent, ), model_clarification_required=bool( - model_parse is not None - and model_parse.clarification_required + accepted_model_parse is not None + and accepted_model_parse.clarification_required ), model_clarification_question=( - model_parse.clarification_question if model_parse is not None else None + accepted_model_parse.clarification_question + if accepted_model_parse is not None + else None ), ) if relax_knowledge_follow_up: @@ -270,8 +289,8 @@ class SemanticOntologyService( ) confidence = self._resolve_confidence( model_confidence=( - model_parse.confidence - if model_parse is not None + accepted_model_parse.confidence + if accepted_model_parse is not None else None ), fallback_confidence=fallback_confidence, @@ -290,12 +309,34 @@ class SemanticOntologyService( "confidence": confidence, "missing_slots": missing_slots, "ambiguity": ambiguity, - "parse_strategy": "llm_primary" if model_parse is not None else "rule_fallback", + "parse_strategy": ( + "llm_primary" if accepted_model_parse is not None else "rule_fallback" + ), + "model_invocations": model_invocations, + "model_parse_error": model_parse_error, + "model_guardrail_reason": model_guardrail_reason, "clarification_required": clarification_required, "clarification_question": clarification_question, "field_errors": field_errors, } + @staticmethod + def _resolve_model_guardrail_reason( + model_parse: LlmOntologyParseResult | None, + *, + rule_scenario: str, + application_query: bool, + ) -> str | None: + if model_parse is None: + return "model_unavailable_or_invalid" + if model_parse.confidence < 0.55: + return "model_confidence_low" + if model_parse.scenario == "unknown": + return "model_scenario_unknown" + if application_query and rule_scenario == "expense" and model_parse.scenario != "expense": + return "model_conflicts_with_application_stage_signal" + return None + @staticmethod def _should_relax_knowledge_follow_up_clarification( *, @@ -388,6 +429,79 @@ class SemanticOntologyService( analyzed["permission"].level, ) + def _record_model_invocations( + self, + *, + run_id: str, + analyzed: dict[str, object], + ) -> None: + invocations = [ + item + for item in list(analyzed.get("model_invocations") or []) + if isinstance(item, dict) + ] + if not invocations: + return + + parse_strategy = str(analyzed.get("parse_strategy") or "") + parse_error = str(analyzed.get("model_parse_error") or "").strip() + guardrail_reason = str(analyzed.get("model_guardrail_reason") or "").strip() + for item in invocations: + call_status = str(item.get("status") or "unknown").strip() + slot = str(item.get("slot") or "unknown").strip() + provider = str(item.get("provider") or "").strip() + model = str(item.get("model") or "").strip() + postprocess_error = parse_error or guardrail_reason + status = "succeeded" + error_message = str(item.get("error_message") or "").strip() or None + if call_status == "skipped": + status = "skipped" + error_message = str(item.get("skipped_reason") or "").strip() or None + elif call_status != "succeeded" or postprocess_error: + status = "failed" + error_message = error_message or postprocess_error or call_status + + self.run_service.record_tool_call( + run_id=run_id, + tool_type=AgentToolType.LLM.value, + tool_name=f"semantic_ontology.{slot}", + request_json={ + "stage": "semantic_parse", + "slot": slot, + "provider": provider, + "model": model, + "attempt": item.get("attempt"), + }, + response_json={ + "model_call_status": call_status, + "parse_strategy": parse_strategy, + "model_parse_error": parse_error, + "model_guardrail_reason": guardrail_reason, + "duration_ms": item.get("duration_ms", 0), + }, + status=status, + duration_ms=int(item.get("duration_ms") or 0), + error_message=error_message, + ) + + @staticmethod + def _build_model_invocation_summary(analyzed: dict[str, object]) -> dict[str, object]: + invocations = [ + item + for item in list(analyzed.get("model_invocations") or []) + if isinstance(item, dict) + ] + statuses = [str(item.get("status") or "unknown") for item in invocations] + return { + "attempt_count": len(invocations), + "succeeded_count": statuses.count("succeeded"), + "failed_count": statuses.count("failed") + statuses.count("empty"), + "skipped_count": statuses.count("skipped"), + "parse_strategy": analyzed.get("parse_strategy"), + "model_parse_error": analyzed.get("model_parse_error"), + "model_guardrail_reason": analyzed.get("model_guardrail_reason"), + } + @staticmethod def _build_ontology_json(analyzed: dict[str, object]) -> dict[str, object]: return { @@ -402,6 +516,9 @@ class SemanticOntologyService( "missing_slots": list(analyzed["missing_slots"]), "ambiguity": list(analyzed["ambiguity"]), "parse_strategy": analyzed["parse_strategy"], + "model_invocation_summary": SemanticOntologyService._build_model_invocation_summary( + analyzed + ), "confidence": analyzed["confidence"], } diff --git a/server/src/app/services/ontology_detection.py b/server/src/app/services/ontology_detection.py index e315e59..f158eab 100644 --- a/server/src/app/services/ontology_detection.py +++ b/server/src/app/services/ontology_detection.py @@ -23,12 +23,12 @@ from app.services.ontology_rules import ( DRAFT_FOLLOW_UP_KEYWORDS, DRAFT_KEYWORDS, EXPENSE_APPLICATION_CONTEXT_TYPES, - EXPENSE_APPLICATION_KEYWORDS, EXPENSE_NARRATIVE_KEYWORDS, EXPENSE_REVIEW_ACTIONS, EXPLAIN_KEYWORDS, GENERIC_EXPENSE_PROMPTS, KNOWLEDGE_INTENTS, + looks_like_expense_application_signal, OPERATE_KEYWORDS, QUERY_KEYWORDS, RISK_KEYWORDS, @@ -90,7 +90,7 @@ class OntologyDetectionMixin: @staticmethod def _looks_like_expense_application(compact_query: str) -> bool: - return any(keyword in compact_query for keyword in EXPENSE_APPLICATION_KEYWORDS) + return looks_like_expense_application_signal(compact_query) def _detect_scenario(self, compact_query: str) -> tuple[str, float]: scores = {key: 0.0 for key in SCENARIO_KEYWORDS} @@ -320,7 +320,7 @@ class OntologyDetectionMixin: time_range: OntologyTimeRange, metrics: list[OntologyMetric], constraints: list[OntologyConstraint], - ) -> LlmOntologyParseResult | None: + ) -> tuple[LlmOntologyParseResult | None, list[dict[str, Any]], str | None]: messages = self._build_model_messages( payload=payload, query=query, @@ -332,20 +332,22 @@ class OntologyDetectionMixin: metrics=metrics, constraints=constraints, ) - response_text = self.runtime_chat_service.complete( + chat_result = self.runtime_chat_service.complete_with_trace( messages, max_tokens=600, temperature=0.0, ) + response_text = chat_result.text + traces = chat_result.calls_as_dicts() payload_json = self._extract_json_payload(response_text) if payload_json is None: - return None + return None, traces, "model_output_empty_or_invalid_json" try: - return LlmOntologyParseResult.model_validate(payload_json) + return LlmOntologyParseResult.model_validate(payload_json), traces, None except ValidationError as exc: logger.warning("Semantic model output validation failed: %s", exc) - return None + return None, traces, "model_output_validation_failed" @staticmethod def _build_model_messages( diff --git a/server/src/app/services/ontology_extraction.py b/server/src/app/services/ontology_extraction.py index ba58265..77c8a96 100644 --- a/server/src/app/services/ontology_extraction.py +++ b/server/src/app/services/ontology_extraction.py @@ -20,7 +20,6 @@ from app.services.ontology_rules import ( DATE_RANGE_PATTERN, EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES, EXPENSE_APPLICATION_CONTEXT_TYPES, - EXPENSE_APPLICATION_KEYWORDS, EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, EXPENSE_TYPE_KEYWORDS, EXPLICIT_DATE_PATTERN, @@ -32,6 +31,7 @@ from app.services.ontology_rules import ( STATUS_KEYWORDS, TOP_N_PATTERN, ReferenceCatalog, + looks_like_expense_application_signal, ) @@ -51,7 +51,7 @@ class OntologyExtractionMixin(BudgetOntologyMixin): @staticmethod def _has_expense_application_signal(compact_query: str) -> bool: - return any(keyword in compact_query for keyword in EXPENSE_APPLICATION_KEYWORDS) + return looks_like_expense_application_signal(compact_query) def _infer_default_missing_slots( self, @@ -234,7 +234,8 @@ class OntologyExtractionMixin(BudgetOntologyMixin): ) if employee_match: name = employee_match.group("name") - upsert(self._make_entity("employee", name, name, role="filter")) + if name not in {"申请"}: + upsert(self._make_entity("employee", name, name, role="filter")) for name in reference.employees: if self._compact(name) in compact_query: diff --git a/server/src/app/services/ontology_rules.py b/server/src/app/services/ontology_rules.py index 4a20062..8d58936 100644 --- a/server/src/app/services/ontology_rules.py +++ b/server/src/app/services/ontology_rules.py @@ -209,10 +209,14 @@ EXPENSE_APPLICATION_KEYWORDS = ( "发起申请", "提交申请", "提出申请", + "申请出差", + "申请差旅", "前置申请", "报销申请", "申请报销", + "差旅费用申请", "差旅申请", + "申请差旅费用", "出差申请", "会务申请", "会议申请", @@ -220,6 +224,117 @@ EXPENSE_APPLICATION_KEYWORDS = ( "培训申请", "预算申请", ) +EXPENSE_APPLICATION_REIMBURSEMENT_KEYWORDS = ( + "报销", + "报销单", + "报账", + "票据", + "发票", + "行程单", + "草稿", + "归集", + "上传", + "关联单据", +) +EXPENSE_APPLICATION_COMPLETED_EXPENSE_KEYWORDS = ( + "已经", + "已", + "昨天", + "前天", + "上周", + "上月", + "去年", + "花了", + "花销", + "消费", + "垫付", + "支付", + "付了", + "买了", + "采购了", + "招待了", + "发生了", +) +EXPENSE_APPLICATION_KNOWLEDGE_QUESTION_KEYWORDS = ( + "制度", + "政策", + "标准", + "规则", + "规定", + "流程", + "口径", + "依据", + "上限", + "额度", + "补贴", + "可不可以", + "能不能", + "多少", + "怎么算", + "如何计算", +) +EXPENSE_APPLICATION_PLANNING_KEYWORDS = ( + "计划", + "安排", + "准备", + "需要", + "打算", + "预计", + "申请", + "发起", + "提交", + "提出", + "先走", + "先办", + "要去", + "将要", + "下周", + "下月", + "明天", + "后天", + "近期", + "月底", + "去", + "到", + "赴", + "前往", + "参加", +) +EXPENSE_APPLICATION_BUSINESS_KEYWORDS = ( + "出差", + "差旅", + "客户现场", + "现场", + "客户", + "项目", + "部署", + "实施", + "支撑", + "支持", + "协助", + "拜访", + "调研", + "培训", + "会议", + "会务", + "驻场", + "上线", + "验收", + "采购", + "购置", + "用款", + "立项", +) +EXPENSE_APPLICATION_FUTURE_OR_DURATION_PATTERN = re.compile( + r"明天|后天|下周|下月|近期|月底|预计|计划|安排|准备|将要|" + r"[0-9]+天|[一二两三四五六七八九十]+天" +) +EXPENSE_APPLICATION_ROUTE_PATTERN = re.compile( + r"(?:去|到|赴|前往)[^,,。;;!??!\n]{0,24}" + r"(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)" + r"|(?:出差|差旅)[^,,。;;!??!\n]{0,24}" + r"(?:[0-9]+天|[一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)" +) GENERIC_EXPENSE_APPLICATION_PROMPTS = { "申请", "费用申请", @@ -363,6 +478,35 @@ CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "b KNOWLEDGE_INTENTS = {"query", "explain", "compare"} +def looks_like_expense_application_signal(compact_query: str) -> bool: + if not compact_query: + return False + if any(keyword in compact_query for keyword in EXPENSE_APPLICATION_KEYWORDS): + return True + if any(keyword in compact_query for keyword in EXPENSE_APPLICATION_REIMBURSEMENT_KEYWORDS): + return False + if any(keyword in compact_query for keyword in EXPENSE_APPLICATION_COMPLETED_EXPENSE_KEYWORDS): + return False + if any(keyword in compact_query for keyword in EXPENSE_APPLICATION_KNOWLEDGE_QUESTION_KEYWORDS): + return False + + has_business_signal = any( + keyword in compact_query for keyword in EXPENSE_APPLICATION_BUSINESS_KEYWORDS + ) + if not has_business_signal: + return False + + score = 0 + if any(keyword in compact_query for keyword in EXPENSE_APPLICATION_PLANNING_KEYWORDS): + score += 1 + if EXPENSE_APPLICATION_FUTURE_OR_DURATION_PATTERN.search(compact_query): + score += 1 + if EXPENSE_APPLICATION_ROUTE_PATTERN.search(compact_query): + score += 2 + + return score >= 2 + + @dataclass(slots=True) class ReferenceCatalog: employees: list[str] diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 55d2fc6..47b53e1 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -61,6 +61,7 @@ class OrchestratorService: self.user_agent_service = UserAgentService(db) self.database_query_builder = OrchestratorDatabaseQueryBuilder(db) self.execution_engine = OrchestratorExecutionEngine( + db=db, run_service=self.run_service, expense_claim_service=self.expense_claim_service, knowledge_service=self.knowledge_service, @@ -152,6 +153,11 @@ class OrchestratorService: "selected_capability_codes": selected_capability_codes, "ontology_run_id": ontology.run_id, } + if task_asset is not None: + task_config = task_asset.config_json or {} + route_json["job_type"] = str(task_config.get("task_type") or "").strip() + route_json["task_code"] = task_asset.code + route_json["task_name"] = task_asset.name if ontology.permission.level == AgentPermissionLevel.FORBIDDEN.value: outcome = ExecutionOutcome( diff --git a/server/src/app/services/orchestrator_execution.py b/server/src/app/services/orchestrator_execution.py index 73fe053..b625e69 100644 --- a/server/src/app/services/orchestrator_execution.py +++ b/server/src/app/services/orchestrator_execution.py @@ -1,14 +1,20 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from time import perf_counter from typing import Any +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext from app.core.agent_enums import AgentRunSource, AgentRunStatus, AgentToolType from app.schemas.agent_asset import AgentAssetListItem, AgentAssetRead from app.schemas.ontology import OntologyParseResult from app.schemas.orchestrator import OrchestratorRequest from app.schemas.user_agent import UserAgentRequest, UserAgentResponse +from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService +from app.services.hermes_risk_scanner import HermesRiskScannerService +from app.services.knowledge_sync import KnowledgeSyncDispatchService @dataclass(slots=True) @@ -24,12 +30,14 @@ class OrchestratorExecutionEngine: def __init__( self, *, + db: Session, run_service, expense_claim_service, knowledge_service, user_agent_service, database_query_builder, ) -> None: + self.db = db self.run_service = run_service self.expense_claim_service = expense_claim_service self.knowledge_service = knowledge_service @@ -298,6 +306,15 @@ class OrchestratorExecutionEngine: failed_tool_count=0, ) + digital_employee_outcome = self._execute_digital_employee_task( + payload=payload, + run_id=run_id, + task_asset=task_asset, + context_json=context_json, + ) + if digital_employee_outcome is not None: + return digital_employee_outcome + rule_response, rule_degraded = self._invoke_tool( run_id=run_id, tool_type=AgentToolType.RULE_ENGINE.value, @@ -346,6 +363,155 @@ class OrchestratorExecutionEngine: failed_tool_count=failed_tool_count, ) + def _execute_digital_employee_task( + self, + *, + payload: OrchestratorRequest, + run_id: str, + task_asset: AgentAssetRead | None, + context_json: dict[str, Any], + ) -> ExecutionOutcome | None: + task_type = self._resolve_task_type(task_asset) + if task_type == "global_risk_scan": + return self._execute_risk_graph_scan(run_id=run_id, context_json=context_json) + if task_type == "employee_behavior_profile_scan": + return self._execute_employee_profile_scan(run_id=run_id, context_json=context_json) + if task_type == "finance_policy_knowledge_organize": + return self._execute_finance_policy_knowledge_sync( + payload=payload, + run_id=run_id, + task_asset=task_asset, + context_json=context_json, + ) + return None + + def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome: + summary, degraded = self._invoke_tool( + run_id=run_id, + tool_type=AgentToolType.RULE_ENGINE.value, + tool_name="digital_employee.financial_risk_graph.scan", + request_json={"task_type": "global_risk_scan"}, + context_json=context_json, + executor=lambda: HermesRiskScannerService(self.db).scan_global_risks(run_id=run_id), + fallback_factory=lambda exc: { + "message": f"财务风险图谱巡检失败,已转人工检查:{exc}", + "degraded": True, + }, + ) + message = ( + str(summary.get("message") or "").strip() + or "财务风险图谱巡检完成:" + f"扫描 {summary.get('scanned_claim_count', 0)} 张单据," + f"生成 {summary.get('risk_observation_count', 0)} 条风险观察。" + ) + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result={"message": message, "report_type": "global_risk_scan", "summary": summary, "degraded": degraded}, + degraded=degraded, + tool_count=1, + failed_tool_count=1 if degraded else 0, + ) + + def _execute_employee_profile_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome: + summary, degraded = self._invoke_tool( + run_id=run_id, + tool_type=AgentToolType.DATABASE.value, + tool_name="digital_employee.employee_behavior_profile.scan", + request_json={"task_type": "employee_behavior_profile_scan"}, + context_json=context_json, + executor=lambda: HermesEmployeeProfileScannerService(self.db).scan_employee_profiles( + log_id=run_id + ), + fallback_factory=lambda exc: { + "message": f"员工行为画像巡检失败,已保留失败记录:{exc}", + "degraded": True, + }, + ) + message = ( + str(summary.get("message") or "").strip() + or "员工行为画像巡检完成:" + f"目标 {summary.get('target_employee_count', 0)} 人," + f"生成 {summary.get('snapshot_count', 0)} 条快照," + f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。" + ) + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result={"message": message, "report_type": "employee_behavior_profile_scan", "summary": summary, "degraded": degraded}, + degraded=degraded, + tool_count=1, + failed_tool_count=1 if degraded else 0, + ) + + def _execute_finance_policy_knowledge_sync( + self, + *, + payload: OrchestratorRequest, + run_id: str, + task_asset: AgentAssetRead | None, + context_json: dict[str, Any], + ) -> ExecutionOutcome: + config = task_asset.config_json if task_asset is not None else {} + username = str( + context_json.get("requested_by_username") + or context_json.get("actor") + or payload.user_id + or "digital_employee" + ).strip() + display_name = str(context_json.get("requested_by_name") or username).strip() + force = bool(context_json.get("force") or config.get("force")) + changed_only = bool(config.get("changed_only", True)) and not force + + dispatch, degraded = self._invoke_tool( + run_id=run_id, + tool_type=AgentToolType.DATABASE.value, + tool_name="digital_employee.finance_policy_knowledge.sync", + request_json={ + "task_type": "finance_policy_knowledge_organize", + "folder": config.get("folder"), + "changed_only": changed_only, + "force": force, + }, + context_json=context_json, + executor=lambda: asdict( + KnowledgeSyncDispatchService(self.db).queue_sync( + current_user=CurrentUserContext( + username=username or "digital_employee", + name=display_name or username or "数字员工", + role_codes=["admin"], + is_admin=True, + ), + folder=str(config.get("folder") or "").strip() or None, + source=AgentRunSource.SCHEDULE.value, + force=force, + changed_only=changed_only, + ) + ), + fallback_factory=lambda exc: { + "message": f"知识制度整理任务入队失败:{exc}", + "degraded": True, + }, + ) + message = str(dispatch.get("summary") or "").strip() or "知识制度整理任务已提交。" + if dispatch.get("agent_run_id"): + message = f"{message} 日志编号:{dispatch['agent_run_id']}" + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result={"message": message, "report_type": "finance_policy_knowledge_organize", "summary": dispatch, "degraded": degraded}, + degraded=degraded, + tool_count=1, + failed_tool_count=1 if degraded else 0, + ) + + @staticmethod + def _resolve_task_type(task_asset: AgentAssetRead | None) -> str: + if task_asset is None: + return "" + config = task_asset.config_json or {} + task_type = str(config.get("task_type") or "").strip() + if task_type: + return task_type.replace("-", "_").replace(".", "_") + return str(task_asset.code or "").removeprefix("task.hermes.").replace(".", "_") + @staticmethod def _resolve_next_step( ontology: OntologyParseResult, diff --git a/server/src/app/services/receipt_folder.py b/server/src/app/services/receipt_folder.py index 84456c8..f5690bf 100644 --- a/server/src/app/services/receipt_folder.py +++ b/server/src/app/services/receipt_folder.py @@ -22,6 +22,30 @@ from app.schemas.receipt_folder import ( from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation from app.services.ocr import SUPPORTED_SUFFIXES +RECEIPT_DATE_PATTERN = re.compile( + r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)" +) +RECEIPT_TIME_PATTERN = re.compile(r"(?|—|–|-)\s*" + r"([\u4e00-\u9fa5]{2,12})站?" +) +TRAIN_NO_PATTERN = re.compile(r"(?:车次|列车号)\s*[::]?\s*([GCDZKTLYS]\d{1,5})", re.IGNORECASE) +TRAIN_STANDALONE_NO_PATTERN = re.compile(r"(? None: @@ -372,8 +396,8 @@ class ReceiptFolderService: def _is_previewable(media_type: str) -> bool: return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf" - @staticmethod - def _build_document_meta(document: Any | None) -> dict[str, Any]: + @classmethod + def _build_document_meta(cls, document: Any | None) -> dict[str, Any]: fields = [] for field in list(getattr(document, "document_fields", []) or []): if isinstance(field, dict): @@ -393,18 +417,33 @@ class ReceiptFolderService: } ) fields = [field for field in fields if field["label"] and field["value"]] + ocr_text = str(getattr(document, "text", "") or "") + summary = str(getattr(document, "summary", "") or "") + document_type = str(getattr(document, "document_type", "") or "other") + document_type_label = str(getattr(document, "document_type_label", "") or "其他单据") + scene_label = str(getattr(document, "scene_label", "") or "其他票据") + if cls._is_train_ticket_values( + document_type=document_type, + document_type_label=document_type_label, + scene_label=scene_label, + text=f"{summary}\n{ocr_text}", + ): + fields = cls._enrich_train_ticket_field_dicts( + fields, + text=f"{ocr_text}\n{summary}\n{str(getattr(document, 'filename', '') or '')}", + ) return { "engine": str(getattr(document, "engine", "") or ""), "model": str(getattr(document, "model", "") or ""), - "ocr_text": str(getattr(document, "text", "") or ""), - "summary": str(getattr(document, "summary", "") or ""), + "ocr_text": ocr_text, + "summary": summary, "ocr_avg_score": float(getattr(document, "avg_score", 0.0) or 0.0), "ocr_line_count": int(getattr(document, "line_count", 0) or 0), "page_count": int(getattr(document, "page_count", 1) or 1), - "document_type": str(getattr(document, "document_type", "") or "other"), - "document_type_label": str(getattr(document, "document_type_label", "") or "其他单据"), + "document_type": document_type, + "document_type_label": document_type_label, "scene_code": str(getattr(document, "scene_code", "") or "other"), - "scene_label": str(getattr(document, "scene_label", "") or "其他票据"), + "scene_label": scene_label, "ocr_classification_source": str(getattr(document, "classification_source", "") or ""), "ocr_classification_confidence": float(getattr(document, "classification_confidence", 0.0) or 0.0), "ocr_classification_evidence": [ @@ -484,8 +523,8 @@ class ReceiptFolderService: scene_label=str(meta.get("scene_label") or "其他票据"), summary=str(meta.get("summary") or ""), amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")), - document_date=self._resolve_editable_or_field(meta, "document_date", labels=("日期", "开票日期", "乘车日期")), - merchant_name=self._resolve_editable_or_field(meta, "merchant_name", labels=("商户", "销售方", "收款方")), + document_date=self._resolve_receipt_document_date(meta), + merchant_name=self._resolve_receipt_merchant_name(meta), avg_score=float(meta.get("ocr_avg_score") or 0.0), uploaded_at=self._parse_datetime(meta.get("uploaded_at")), linked_at=self._parse_datetime(meta.get("linked_at")), @@ -499,7 +538,7 @@ class ReceiptFolderService: ) def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]: - return [ + fields = [ ReceiptFolderFieldRead( key=str(field.get("key") or ""), label=str(field.get("label") or ""), @@ -508,6 +547,45 @@ class ReceiptFolderService: for field in list(meta.get("document_fields") or []) if isinstance(field, dict) and str(field.get("label") or "").strip() ] + if self._is_train_ticket_meta(meta): + return [ + ReceiptFolderFieldRead(**field) + for field in self._enrich_train_ticket_field_dicts( + [field.model_dump() for field in fields], + text=self._receipt_text(meta), + ) + ] + return fields + + def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str: + editable = meta.get("editable_fields") + if isinstance(editable, dict): + value = str(editable.get("document_date") or "").strip() + if value: + return value + + fields = self._resolve_fields(meta) + for field in fields: + if field.key in {"invoice_date", "issue_date"} or field.label in {"开票日期", "发票日期"}: + return self._normalize_receipt_date_value(field.value) + + if self._is_train_ticket_meta(meta): + invoice_date = self._extract_train_invoice_date(self._receipt_text(meta)) + if invoice_date: + return invoice_date + + for field in fields: + if field.key == "document_date" or field.label in {"日期", "乘车日期", "列车出发时间", "行程日期"}: + return self._normalize_receipt_date_value(field.value) + return "" + + def _resolve_receipt_merchant_name(self, meta: dict[str, Any]) -> str: + value = self._resolve_editable_or_field(meta, "merchant_name", labels=("商户", "销售方", "收款方", "开票方")) + if value: + return value + if self._is_train_ticket_meta(meta): + return "中国铁路" + return "" def _resolve_editable_or_field(self, meta: dict[str, Any], key: str, *, labels: tuple[str, ...]) -> str: editable = meta.get("editable_fields") @@ -521,6 +599,254 @@ class ReceiptFolderService: return field.value return "" + @classmethod + def _enrich_train_ticket_field_dicts( + cls, + fields: list[dict[str, Any]], + *, + text: str, + ) -> list[dict[str, str]]: + normalized: list[dict[str, str]] = [] + for field in fields: + key = str(field.get("key") or "").strip() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + if not label or not value: + continue + if key == "trip_no" and label == "车次/航班": + label = "车次" + if key == "route" and label == "行程": + label = "行程" + normalized.append({"key": key, "label": label, "value": value}) + + def add_field(key: str, label: str, value: str) -> None: + cleaned = str(value or "").strip() + if not cleaned: + return + if any(item["key"] == key for item in normalized if item["key"]): + return + if any(item["label"] == label for item in normalized): + return + normalized.append({"key": key, "label": label, "value": cleaned}) + + invoice_date = cls._extract_train_invoice_date(text) + add_field("invoice_date", "开票日期", invoice_date) + + trip_datetime = cls._extract_train_trip_datetime(text) + add_field("trip_date", "列车出发时间", trip_datetime) + + departure, arrival = cls._extract_train_route_points(text) + add_field("departure_station", "出发地点", departure) + add_field("arrival_station", "到达地点", arrival) + if departure and arrival: + add_field("route", "行程", f"{departure}-{arrival}") + + add_field("train_no", "车次", cls._extract_first(TRAIN_NO_PATTERN, text) or cls._extract_first(TRAIN_STANDALONE_NO_PATTERN, text)) + id_number = cls._extract_train_id_number(text) + add_field("passenger_name", "乘车人", cls._extract_train_passenger_name(text, id_number=id_number)) + add_field("id_number", "身份证号", id_number) + add_field("electronic_ticket_no", "电子客票号", cls._extract_first(TRAIN_ETICKET_PATTERN, text)) + add_field("seat_class", "席别", cls._extract_first(TRAIN_SEAT_CLASS_PATTERN, text)) + carriage_no, seat_no = cls._extract_train_carriage_and_seat(text) + add_field("carriage_no", "车厢", carriage_no) + add_field("seat_no", "座位号", seat_no) + add_field("fare", "票价", cls._extract_train_fare(text)) + return normalized + + @staticmethod + def _is_train_ticket_values( + *, + document_type: str, + document_type_label: str, + scene_label: str, + text: str, + ) -> bool: + if str(document_type or "").strip().lower() == "train_ticket": + return True + compact = "".join([document_type_label, scene_label, text]).replace(" ", "") + return any(token in compact for token in ("火车", "高铁", "动车", "铁路", "电子客票", "车次")) + + @classmethod + def _is_train_ticket_meta(cls, meta: dict[str, Any]) -> bool: + return cls._is_train_ticket_values( + document_type=str(meta.get("document_type") or ""), + document_type_label=str(meta.get("document_type_label") or ""), + scene_label=str(meta.get("scene_label") or ""), + text=cls._receipt_text(meta), + ) + + @staticmethod + def _receipt_text(meta: dict[str, Any]) -> str: + field_text = "\n".join( + f"{field.get('label', '')} {field.get('value', '')}" + for field in list(meta.get("document_fields") or []) + if isinstance(field, dict) + ) + return "\n".join( + value + for value in ( + str(meta.get("ocr_text") or ""), + str(meta.get("summary") or ""), + str(meta.get("file_name") or ""), + field_text, + ) + if value + ) + + @classmethod + def _extract_train_invoice_date(cls, text: str) -> str: + match = TRAIN_INVOICE_DATE_PATTERN.search(str(text or "")) + if not match: + return "" + return cls._normalize_receipt_date_value(match.group(1)) + + @classmethod + def _extract_train_trip_datetime(cls, text: str) -> str: + raw_text = str(text or "") + candidates: list[tuple[int, int, str]] = [] + for index, match in enumerate(RECEIPT_DATE_PATTERN.finditer(raw_text)): + window = raw_text[max(0, match.start() - 14): match.end() + 8].replace(" ", "") + if any(token in window for token in ("开票日期", "发票日期", "开票时间")): + continue + value = cls._format_date_match_with_time(raw_text, match) + score = 0 + nearby = raw_text[max(0, match.start() - 32): match.end() + 32] + compact = nearby.replace(" ", "") + if ":" in value or ":" in value: + score += 8 + if any(token in compact for token in ("开车时间", "发车时间", "乘车日期", "乘车时间", "检票", "车次")): + score += 6 + if any(token in compact for token in ("二等座", "一等座", "商务座", "硬座", "软卧", "硬卧")): + score += 3 + candidates.append((score, -index, value)) + if not candidates: + return "" + return max(candidates, key=lambda item: (item[0], item[1]))[2] + + @classmethod + def _format_date_match_with_time(cls, text: str, match: re.Match[str]) -> str: + date_value = cls._normalize_receipt_date_value(match.group(1)) + if not date_value: + return "" + surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24] + time_match = RECEIPT_TIME_PATTERN.search(surrounding) + if not time_match: + return date_value + return f"{date_value} {str(time_match.group(1)).zfill(2)}:{str(time_match.group(2)).zfill(2)}" + + @staticmethod + def _normalize_receipt_date_value(value: str) -> str: + raw = str(value or "").strip() + match = RECEIPT_DATE_PATTERN.search(raw) + if not match: + return raw + normalized = match.group(1).replace("年", "-").replace("月", "-").replace("日", "") + normalized = normalized.replace("/", "-").replace(".", "-") + parts = [part for part in normalized.split("-") if part] + if len(parts) != 3: + return match.group(1) + year, month, day = parts + return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}" + + @classmethod + def _extract_train_route_points(cls, text: str) -> tuple[str, str]: + raw_text = str(text or "") + station_candidates: list[str] = [] + for line in raw_text.replace("\r", "\n").splitlines(): + candidate = cls._clean_train_station(line) + if not candidate or candidate in station_candidates: + continue + if not str(line or "").strip().endswith("站"): + continue + if any(token in candidate for token in ("发票", "客票", "铁路", "票价", "日期")): + continue + station_candidates.append(candidate) + if len(station_candidates) >= 2: + return station_candidates[0], station_candidates[1] + + match = TRAIN_ROUTE_PATTERN.search(raw_text) + if match: + departure = cls._clean_train_station(match.group(1)) + arrival = cls._clean_train_station(match.group(2)) + if departure and arrival and departure != arrival: + return departure, arrival + return "", "" + + @staticmethod + def _clean_train_station(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9\u4e00-\u9fa5()()·]", "", str(value or "")) + cleaned = re.sub(r"(?:火车站|高铁站|站)$", "", cleaned) + return cleaned.strip() + + @staticmethod + def _extract_first(pattern: re.Pattern[str], text: str) -> str: + match = pattern.search(str(text or "")) + return str(match.group(1) or "").strip() if match else "" + + @classmethod + def _extract_train_passenger_name(cls, text: str, *, id_number: str = "") -> str: + labeled = cls._extract_first(TRAIN_PASSENGER_PATTERN, text) + if labeled: + return labeled + + lines = [line.strip() for line in str(text or "").replace("\r", "\n").splitlines() if line.strip()] + for index, line in enumerate(lines): + if id_number and id_number not in line: + continue + for offset in (1, -1, 2): + target_index = index + offset + if target_index < 0 or target_index >= len(lines): + continue + candidate = cls._clean_train_passenger_candidate(lines[target_index]) + if candidate: + return candidate + for line in lines: + if "购买方名称" in line: + candidate = cls._clean_train_passenger_candidate(line.split(":", 1)[-1].split(":", 1)[-1]) + if candidate: + return candidate + return "" + + @staticmethod + def _clean_train_passenger_candidate(value: str) -> str: + cleaned = re.sub(r"[^·\u4e00-\u9fa5]", "", str(value or "")).strip() + if not 2 <= len(cleaned) <= 8: + return "" + if any(token in cleaned for token in ("电子", "客票", "铁路", "发票", "税务", "湖北省", "中国铁路", "开票", "日期")): + return "" + return cleaned + + @classmethod + def _extract_train_id_number(cls, text: str) -> str: + labeled = cls._extract_first(TRAIN_ID_PATTERN, text) + if labeled: + return labeled + for line in str(text or "").replace("\r", "\n").splitlines(): + compact_line = line.replace(" ", "") + if any(token in compact_line for token in ("发票号码", "电子客票号", "客票号", "订单号")): + continue + match = TRAIN_ID_FALLBACK_PATTERN.search(compact_line) + if match: + return str(match.group(1) or "").strip() + return "" + + @staticmethod + def _extract_train_carriage_and_seat(text: str) -> tuple[str, str]: + combined_match = TRAIN_COMBINED_SEAT_PATTERN.search(str(text or "")) + if combined_match: + return f"{combined_match.group(1)}车", combined_match.group(2) + carriage_no = ReceiptFolderService._extract_first(TRAIN_CARRIAGE_PATTERN, text).replace(" ", "") + seat_no = ReceiptFolderService._extract_first(TRAIN_SEAT_NO_PATTERN, text) + return carriage_no, seat_no + + @staticmethod + def _extract_train_fare(text: str) -> str: + match = TRAIN_FARE_PATTERN.search(str(text or "")) + if not match: + return "" + value = str(match.group(1) or "").replace(",", ".").strip() + return f"{value}元" if value else "" + @staticmethod def _parse_datetime(value: Any) -> datetime | None: raw = str(value or "").strip() diff --git a/server/src/app/services/risk_observations.py b/server/src/app/services/risk_observations.py new file mode 100644 index 0000000..392c287 --- /dev/null +++ b/server/src/app/services/risk_observations.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft +from app.db.base import Base +from app.models.financial_record import ExpenseClaim +from app.models.risk_observation import RiskObservation, RiskObservationFeedback +from app.schemas.risk_observation import ( + RiskObservationDashboardRead, + RiskObservationFeedbackCreate, +) + +HIGH_LEVELS = {"high", "critical"} +SEVERITY_SCORE = { + "low": 32, + "medium": 58, + "high": 82, + "critical": 100, +} +FEEDBACK_STATUS_MAP = { + "confirm": ("confirmed", "confirmed"), + "false_positive": ("false_positive", "false_positive"), + "ignore": ("ignored", "ignored"), + "resolve": ("resolved", "resolved"), +} + + +class RiskObservationService: + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_storage_ready(self) -> None: + Base.metadata.create_all( + bind=self.db.get_bind(), + tables=[ + RiskObservation.__table__, + RiskObservationFeedback.__table__, + ], + ) + + def upsert_observation( + self, + observation: RiskObservationDraft | dict[str, Any], + *, + run_id: str | None = None, + execution_log_id: str | None = None, + ) -> RiskObservation: + self.ensure_storage_ready() + payload = ( + observation.as_dict() + if isinstance(observation, RiskObservationDraft) + else dict(observation) + ) + observation_key = str(payload.get("observation_key") or "").strip() + if not observation_key: + raise ValueError("Risk observation requires observation_key.") + + item = self.db.scalar( + select(RiskObservation).where(RiskObservation.observation_key == observation_key) + ) + if item is None: + item = RiskObservation(observation_key=observation_key) + self.db.add(item) + + item.subject_type = _text(payload.get("subject_type")) + item.subject_key = _text(payload.get("subject_key")) + item.subject_label = _text(payload.get("subject_label")) + item.claim_id = _optional_text(payload.get("claim_id")) + item.claim_no = _text(payload.get("claim_no")) + item.run_id = _optional_text(run_id or payload.get("run_id")) + item.execution_log_id = _optional_text(execution_log_id or payload.get("execution_log_id")) + item.risk_type = _text(payload.get("risk_type")) + item.risk_signal = _text(payload.get("risk_signal")) + item.title = _text(payload.get("title")) + item.description = _text(payload.get("description")) + item.risk_score = _clamp_score(payload.get("risk_score")) + item.risk_level = _text(payload.get("risk_level")) or "low" + item.confidence_score = _float(payload.get("confidence_score")) + item.control_stage = _text(payload.get("control_stage")) + item.control_mode = _text(payload.get("control_mode")) + item.automation_mode = _text(payload.get("automation_mode")) + item.source = _text(payload.get("source")) + item.algorithm_version = _text(payload.get("algorithm_version")) + item.contribution_scores_json = _dict(payload.get("contribution_scores")) + item.baseline_json = _dict(payload.get("baseline")) + item.evidence_json = _list(payload.get("evidence")) + item.graph_node_keys_json = _list(payload.get("graph_node_keys")) + item.graph_edge_keys_json = _list(payload.get("graph_edge_keys")) + item.policy_refs_json = _list(payload.get("policy_refs")) + item.similar_case_claim_ids_json = _list(payload.get("similar_case_claim_ids")) + item.ontology_json = _risk_ontology_payload(payload) + item.decision_trace_json = _risk_decision_trace_payload(payload) + + self.db.flush() + return item + + def upsert_platform_risk_flags( + self, + claim: ExpenseClaim, + flags: list[dict[str, Any]], + *, + run_id: str | None = None, + execution_log_id: str | None = None, + ) -> list[RiskObservation]: + observations: list[RiskObservation] = [] + for flag in flags: + if not isinstance(flag, dict): + continue + if str(flag.get("rule_type") or "").strip() and flag.get("rule_type") != "risk": + continue + if str(flag.get("hit_source") or "").strip() not in {"", "rule_center"}: + continue + signal = _risk_signal_from_flag(flag) + if not signal: + continue + severity = _normalize_level(flag.get("severity")) + score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"]) + rule_code = _text(flag.get("rule_code")) + observation_key = ( + f"risk:{claim.id}:platform:{rule_code or signal}" + ) + observations.append( + self.upsert_observation( + { + "observation_key": observation_key, + "subject_type": "expense_claim", + "subject_key": f"claim:{claim.id}", + "subject_label": claim.claim_no, + "claim_id": claim.id, + "claim_no": claim.claim_no, + "risk_type": signal, + "risk_signal": signal, + "title": _text(flag.get("label")) or signal, + "description": _text(flag.get("message")), + "risk_score": score, + "risk_level": severity, + "confidence_score": "0.78", + "control_stage": "reimbursement", + "control_mode": "risk_observation", + "automation_mode": ( + "semi_auto_review" + if severity in HIGH_LEVELS + else "manual_review" + ), + "source": "rule_center", + "algorithm_version": _text(flag.get("rule_version")) or "v1.0.0", + "contribution_scores": {"S_rule": score}, + "baseline": {}, + "evidence": [ + { + "code": "platform_risk_rule", + "title": _text(flag.get("label")) or signal, + "detail": _text(flag.get("message")), + "source": "rule_center", + "score": score, + "metadata": flag, + } + ], + "graph_node_keys": [f"claim:{claim.id}"], + "graph_edge_keys": [], + "policy_refs": [rule_code] if rule_code else [], + "similar_case_claim_ids": [], + "ontology_json": {}, + "decision_trace": { + "rule_code": rule_code, + "rule_version": _text(flag.get("rule_version")), + "action": _text(flag.get("action")), + }, + }, + run_id=run_id, + execution_log_id=execution_log_id, + ) + ) + return observations + + def build_history_stats( + self, + *, + risk_signals: set[str] | None = None, + expense_types: set[str] | None = None, + limit: int = 2000, + ) -> list[RiskHistoryStats]: + self.ensure_storage_ready() + stmt = ( + select(RiskObservation, ExpenseClaim.expense_type) + .outerjoin(ExpenseClaim, RiskObservation.claim_id == ExpenseClaim.id) + .order_by(RiskObservation.created_at.desc()) + .limit(limit) + ) + rows = list(self.db.execute(stmt).all()) + signal_filter = {_canonical_key(item) for item in (risk_signals or set()) if item} + expense_filter = {_canonical_key(item) for item in (expense_types or set()) if item} + grouped: dict[tuple[str, str], RiskHistoryStats] = {} + + for observation, expense_type in rows: + signal = _canonical_key(observation.risk_signal) + expense = _canonical_key(expense_type or "") + if signal_filter and signal not in signal_filter: + continue + if expense_filter and expense and expense not in expense_filter: + continue + key = (signal, expense) + stats = grouped.setdefault( + key, + RiskHistoryStats(risk_signal=signal, expense_type=expense), + ) + stats.similar_case_count += 1 + feedback_status = _canonical_key(observation.feedback_status) + if feedback_status == "confirmed": + stats.confirmed_count += 1 + elif feedback_status == "false_positive": + stats.false_positive_count += 1 + if _has_return_feedback(observation): + stats.returned_count += 1 + + return list(grouped.values()) + + def list_observations( + self, + *, + claim_id: str | None = None, + run_id: str | None = None, + execution_log_id: str | None = None, + risk_level: str | None = None, + risk_signal: str | None = None, + status: str | None = None, + source: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[RiskObservation], int]: + self.ensure_storage_ready() + conditions = [] + if claim_id: + conditions.append(RiskObservation.claim_id == claim_id) + if run_id: + conditions.append(RiskObservation.run_id == run_id) + if execution_log_id: + conditions.append(RiskObservation.execution_log_id == execution_log_id) + if risk_level: + conditions.append(RiskObservation.risk_level == risk_level) + if risk_signal: + conditions.append(RiskObservation.risk_signal == risk_signal) + if status: + conditions.append(RiskObservation.status == status) + if source: + conditions.append(RiskObservation.source == source) + + count_stmt = select(func.count()).select_from(RiskObservation) + stmt = select(RiskObservation).order_by( + RiskObservation.risk_score.desc(), + RiskObservation.created_at.desc(), + ) + if conditions: + count_stmt = count_stmt.where(*conditions) + stmt = stmt.where(*conditions) + + total = int(self.db.scalar(count_stmt) or 0) + items = list(self.db.scalars(stmt.offset(offset).limit(limit)).all()) + return items, total + + def get_observation(self, observation_key_or_id: str) -> RiskObservation | None: + self.ensure_storage_ready() + value = str(observation_key_or_id or "").strip() + if not value: + return None + return self.db.scalar( + select(RiskObservation).where( + (RiskObservation.observation_key == value) | (RiskObservation.id == value) + ) + ) + + def list_claim_observations(self, claim_id: str) -> list[RiskObservation]: + items, _ = self.list_observations(claim_id=claim_id, limit=100, offset=0) + return items + + def list_execution_log_observations(self, execution_log_id: str) -> list[RiskObservation]: + items, _ = self.list_observations( + execution_log_id=execution_log_id, + limit=200, + offset=0, + ) + return items + + def create_feedback( + self, + observation_key_or_id: str, + payload: RiskObservationFeedbackCreate, + ) -> RiskObservationFeedback: + self.ensure_storage_ready() + observation = self.get_observation(observation_key_or_id) + if observation is None: + raise LookupError("Risk observation not found.") + + feedback = RiskObservationFeedback( + observation_id=observation.id, + feedback_type=payload.feedback_type, + action=payload.action or "", + actor=payload.actor or "", + comment=payload.comment, + payload_json=payload.payload_json, + ) + self.db.add(feedback) + + mapped = FEEDBACK_STATUS_MAP.get(payload.feedback_type) + if mapped: + observation.status, observation.feedback_status = mapped + self.db.commit() + self.db.refresh(feedback) + return feedback + + def summarize_dashboard( + self, + *, + window_days: int = 30, + limit: int = 500, + ) -> RiskObservationDashboardRead: + self.ensure_storage_ready() + since = datetime.now(UTC) - timedelta(days=window_days) + stmt = ( + select(RiskObservation) + .where(RiskObservation.created_at >= since) + .order_by(RiskObservation.created_at.desc()) + .limit(limit) + ) + observations = list(self.db.scalars(stmt).all()) + total = len(observations) + confirmed = sum(1 for item in observations if item.feedback_status == "confirmed") + false_positive = sum(1 for item in observations if item.feedback_status == "false_positive") + pending = sum(1 for item in observations if item.status == "pending_review") + high_or_above = sum(1 for item in observations if item.risk_level in HIGH_LEVELS) + score_sum = sum(int(item.risk_score or 0) for item in observations) + reviewed = confirmed + false_positive + signal_distribution = _count_by(observations, "risk_signal") + total_amount = sum((_claim_amount(item.claim) for item in observations), Decimal("0")) + + return RiskObservationDashboardRead( + window_days=window_days, + total_observations=total, + pending_count=pending, + high_or_above_count=high_or_above, + confirmed_count=confirmed, + false_positive_count=false_positive, + total_amount=float(total_amount), + average_score=round(score_sum / total, 2) if total else 0.0, + level_distribution=_count_by(observations, "risk_level"), + status_distribution=_count_by(observations, "status"), + signal_distribution=signal_distribution, + risk_type_distribution=_count_by(observations, "risk_type"), + source_distribution=_count_by(observations, "source"), + automation_distribution=_count_by(observations, "automation_mode"), + department_distribution=_claim_distribution( + observations, + lambda claim: claim.department_name if claim else "", + ), + expense_type_distribution=_claim_distribution( + observations, + lambda claim: claim.expense_type if claim else "", + ), + supplier_distribution=_supplier_distribution(observations), + employee_grade_distribution=_claim_distribution( + observations, + lambda claim: claim.employee_grade if claim else "", + ), + daily_trend=_daily_trend(observations), + top_risk_signals=_top_counts(signal_distribution), + top_departments=_top_claim_dimension( + observations, + lambda claim: claim.department_name if claim else "", + ), + top_employees=_top_claim_dimension( + observations, + lambda claim: claim.employee_name if claim else "", + ), + top_suppliers=_top_suppliers(observations), + top_expense_types=_top_claim_dimension( + observations, + lambda claim: claim.expense_type if claim else "", + ), + top_rules=_top_rules(observations), + candidate_rule_count=0, + confirmation_rate=round(confirmed / reviewed, 4) if reviewed else 0.0, + false_positive_rate=round(false_positive / reviewed, 4) if reviewed else 0.0, + recent_high_observations=[ + item for item in observations if item.risk_level in HIGH_LEVELS + ][:10], + ) + + +def _count_by(items: list[RiskObservation], field: str) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + value = _text(getattr(item, field, "")) or "unknown" + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _claim_distribution( + items: list[RiskObservation], + getter: Any, +) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + value = _text(getter(item.claim)) or "unknown" + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _supplier_distribution(items: list[RiskObservation]) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + for supplier in _supplier_names(item): + counts[supplier] = counts.get(supplier, 0) + 1 + return counts + + +def _top_claim_dimension( + items: list[RiskObservation], + getter: Any, + *, + limit: int = 5, +) -> list[dict[str, Any]]: + buckets: dict[str, dict[str, Any]] = {} + for item in items: + name = _text(getter(item.claim)) or "unknown" + bucket = buckets.setdefault(name, {"name": name, "count": 0, "amount": Decimal("0")}) + bucket["count"] += 1 + bucket["amount"] += _claim_amount(item.claim) + return _top_dimension_rows(buckets, limit=limit) + + +def _top_suppliers(items: list[RiskObservation], *, limit: int = 5) -> list[dict[str, Any]]: + buckets: dict[str, dict[str, Any]] = {} + for item in items: + suppliers = _supplier_names(item) + if not suppliers: + continue + amount = _claim_amount(item.claim) + for supplier in suppliers: + bucket = buckets.setdefault( + supplier, + {"name": supplier, "count": 0, "amount": Decimal("0")}, + ) + bucket["count"] += 1 + bucket["amount"] += amount + return _top_dimension_rows(buckets, limit=limit) + + +def _top_rules(items: list[RiskObservation], *, limit: int = 5) -> list[dict[str, Any]]: + buckets: dict[str, dict[str, Any]] = {} + for item in items: + rules = [_text(value) for value in (item.policy_refs_json or []) if _text(value)] + if not rules and item.source == "rule_center": + rules = [_text(item.risk_signal)] + for rule in rules: + bucket = buckets.setdefault(rule, {"name": rule, "count": 0, "amount": Decimal("0")}) + bucket["count"] += 1 + bucket["amount"] += _claim_amount(item.claim) + return _top_dimension_rows(buckets, limit=limit) + + +def _top_dimension_rows( + buckets: dict[str, dict[str, Any]], + *, + limit: int, +) -> list[dict[str, Any]]: + ranked = sorted( + buckets.values(), + key=lambda item: (item["count"], item["amount"]), + reverse=True, + )[:limit] + return [ + { + "name": item["name"], + "count": item["count"], + "amount": float(item["amount"]), + } + for item in ranked + ] + + +def _supplier_names(item: RiskObservation) -> list[str]: + names: list[str] = [] + for value in item.graph_node_keys_json or []: + text = _text(value) + lowered = text.lower() + if lowered.startswith(("supplier:", "vendor:", "merchant:")): + names.append(text.split(":", 1)[1] or text) + for evidence in item.evidence_json or []: + if isinstance(evidence, dict): + metadata = evidence.get("metadata") if isinstance(evidence.get("metadata"), dict) else {} + for key in ("supplier_name", "vendor_name", "merchant_name", "supplier", "vendor"): + name = _text(evidence.get(key)) or _text(metadata.get(key)) + if name: + names.append(name) + return list(dict.fromkeys(names)) + + +def _claim_amount(claim: ExpenseClaim | None) -> Decimal: + if claim is None: + return Decimal("0") + try: + return Decimal(str(claim.amount or "0")) + except Exception: + return Decimal("0") + + +def _daily_trend(items: list[RiskObservation]) -> list[dict[str, Any]]: + grouped: dict[str, dict[str, int]] = {} + for item in items: + day = item.created_at.date().isoformat() if item.created_at else "unknown" + bucket = grouped.setdefault(day, {"date": day, "total": 0, "high_or_above": 0}) + bucket["total"] += 1 + if item.risk_level in HIGH_LEVELS: + bucket["high_or_above"] += 1 + return [grouped[key] for key in sorted(grouped)] + + +def _top_counts(counts: dict[str, int], limit: int = 10) -> list[dict[str, Any]]: + return [ + {"name": key, "count": value} + for key, value in sorted(counts.items(), key=lambda item: item[1], reverse=True)[:limit] + ] + + +def _risk_signal_from_flag(flag: dict[str, Any]) -> str: + raw = _text(flag.get("risk_signal")) or _text(flag.get("rule_code")) or _text(flag.get("label")) + if not raw: + return "" + if "." in raw: + raw = raw.split(".")[-1] + return _canonical_key(raw) + + +def _normalize_level(value: Any) -> str: + normalized = _canonical_key(value) + return normalized if normalized in {"low", "medium", "high", "critical"} else "medium" + + +def _has_return_feedback(observation: RiskObservation) -> bool: + if _canonical_key(observation.status) in {"returned", "supplement_required"}: + return True + for feedback in list(observation.feedback_items or []): + action = _canonical_key(feedback.action) + feedback_type = _canonical_key(feedback.feedback_type) + if action in {"return", "returned", "supplement", "supplement_required"}: + return True + if feedback_type in {"return", "returned"}: + return True + return False + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _canonical_key(value: Any) -> str: + return "_".join(_text(value).lower().split()) + + +def _optional_text(value: Any) -> str | None: + normalized = _text(value) + return normalized or None + + +def _dict(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return list(value) if isinstance(value, list) else [] + + +def _risk_ontology_payload(payload: dict[str, Any]) -> dict[str, Any]: + ontology = _dict(payload.get("ontology_json")) + for key in ( + "ontology_parse_id", + "ontology_version", + "domain", + "scenario", + "intent", + "ontology_entities_json", + "risk_signals_json", + "canonical_subject_key", + ): + value = payload.get(key) + if value not in (None, "", [], {}): + ontology[key] = value + return ontology + + +def _risk_decision_trace_payload(payload: dict[str, Any]) -> dict[str, Any]: + decision_trace = _dict(payload.get("decision_trace")) + for key in ("sampling_strategy", "evaluation_case_id"): + value = payload.get(key) + if value not in (None, "", [], {}): + decision_trace[key] = value + return decision_trace + + +def _float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _clamp_score(value: Any) -> int: + try: + numeric = int(float(value or 0)) + except (TypeError, ValueError): + numeric = 0 + return max(0, min(100, numeric)) diff --git a/server/src/app/services/risk_rule_dsl_examples.py b/server/src/app/services/risk_rule_dsl_examples.py new file mode 100644 index 0000000..fa12acc --- /dev/null +++ b/server/src/app/services/risk_rule_dsl_examples.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY + + +def list_risk_rule_dsl_examples() -> list[dict[str, Any]]: + return deepcopy(RISK_RULE_DSL_EXAMPLES) + + +def get_risk_rule_dsl_example(code: str) -> dict[str, Any] | None: + for example in RISK_RULE_DSL_EXAMPLES: + if example["code"] == code: + return deepcopy(example) + return None + + +def _manifest( + *, + field_keys: list[str], + conditions: list[dict[str, Any]], + hit_logic: dict[str, Any], + message: str, + summary: str, + semantic_type: str, +) -> dict[str, Any]: + params = { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "semantic_type": semantic_type, + "field_keys": field_keys, + "conditions": conditions, + "hit_logic": hit_logic, + "condition_summary": summary, + "message_template": message, + "keywords": [], + } + return {"template_key": COMPOSITE_RULE_TEMPLATE_KEY, "params": params} + + +RISK_RULE_DSL_EXAMPLES: list[dict[str, Any]] = [ + { + "code": "travel_city_mismatch", + "title": "差旅票据城市不一致", + "natural_language": ( + "差旅报销时,读取交通票或住宿票据城市、申报目的地、明细发生地点和报销事由。" + "若票据城市无法与申报目的地或明细地点形成一致关系,且事由未说明绕行、跨城办事" + "或临时改签,则标记为高风险并要求补充说明。" + ), + "manifest": _manifest( + field_keys=[ + "attachment.route_cities", + "attachment.hotel_city", + "claim.location", + "item.item_location", + "claim.reason", + ], + conditions=[ + { + "id": "attachment_city_evidence_present", + "operator": "exists_any", + "fields": ["attachment.route_cities", "attachment.hotel_city"], + }, + { + "id": "city_outside_business_scope", + "operator": "not_in_scope", + "left_fields": ["attachment.route_cities", "attachment.hotel_city"], + "right_fields": ["claim.location", "item.item_location"], + }, + { + "id": "missing_reasonable_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["绕行", "跨城", "改签", "临时任务"], + }, + ], + hit_logic={ + "all": [ + "attachment_city_evidence_present", + "city_outside_business_scope", + "missing_reasonable_exception", + ] + }, + message="票据城市与申报行程城市不一致,且未说明合理绕行或改签原因。", + summary="票据城市集合与申报行程城市集合无交集,且缺少合理例外说明时命中。", + semantic_type="travel_route_city_consistency", + ), + }, + { + "code": "lodging_date_outside_range", + "title": "住宿日期超出差旅行程", + "natural_language": ( + "差旅住宿报销时,读取住宿票据日期、差旅开始日期、差旅结束日期和报销事由。" + "若住宿发生时间早于出差开始或晚于出差结束,且没有延期、改签、临时任务说明," + "则标记为高风险。" + ), + "manifest": _manifest( + field_keys=[ + "attachment.stay_start_date", + "attachment.stay_end_date", + "claim.trip_start_date", + "claim.trip_end_date", + "claim.reason", + ], + conditions=[ + { + "id": "lodging_date_evidence_present", + "operator": "exists_any", + "fields": ["attachment.stay_start_date", "attachment.stay_end_date"], + }, + { + "id": "lodging_date_outside_trip_range", + "operator": "date_outside_range", + "date_fields": ["attachment.stay_start_date", "attachment.stay_end_date"], + "range_start_fields": ["claim.trip_start_date"], + "range_end_fields": ["claim.trip_end_date"], + "tolerance_days": 0, + }, + { + "id": "missing_lodging_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["延期", "改签", "临时任务"], + }, + ], + hit_logic={ + "all": [ + "lodging_date_evidence_present", + "lodging_date_outside_trip_range", + "missing_lodging_exception", + ] + }, + message="住宿日期超出本次差旅行程范围,且未说明延期或临时任务原因。", + summary="住宿票据日期不在差旅行程日期范围内,且缺少合理例外说明时命中。", + semantic_type="lodging_date_range_consistency", + ), + }, + { + "code": "budget_threshold", + "title": "申请金额超过可用预算", + "natural_language": ( + "费用申请时,读取申请金额和当前可用预算。若申请金额超过可用预算余额," + "则提示预算风险并要求补充审批说明。" + ), + "manifest": _manifest( + field_keys=["claim.amount", "budget.remaining_amount", "claim.reason"], + conditions=[ + { + "id": "amount_exceeds_budget", + "operator": "numeric_compare", + "left_fields": ["claim.amount"], + "right_fields": ["budget.remaining_amount"], + "compare": "gt", + } + ], + hit_logic={"all": ["amount_exceeds_budget"]}, + message="申请金额超过当前可用预算余额。", + summary="申请金额大于可用预算余额时命中。", + semantic_type="budget_available_balance_check", + ), + }, + { + "code": "duplicate_invoice", + "title": "重复发票识别", + "natural_language": ( + "费用报销时,读取附件识别出的发票号码和报销明细中的附件编号。若同一发票号" + "在本次提交中重复出现,则标记为高风险并要求删除重复票据或补充说明。" + ), + "manifest": _manifest( + field_keys=["attachment.invoice_no", "item.invoice_id", "claim.reason"], + conditions=[ + { + "id": "same_invoice_no_repeated", + "operator": "duplicate_value", + "fields": ["attachment.invoice_no", "item.invoice_id"], + } + ], + hit_logic={"all": ["same_invoice_no_repeated"]}, + message="同一发票号在本次提交中重复出现。", + summary="附件发票号或明细附件编号出现重复值时命中。", + semantic_type="duplicate_invoice_check", + ), + }, + { + "code": "entertainment_per_capita_over_limit", + "title": "招待人均金额超标", + "natural_language": ( + "业务招待报销时,读取申报总金额、参与人数、人均金额和报销事由。若人均金额" + "超过公司招待标准 500 元,且没有高级审批或特殊客户接待说明,则标记为中风险。" + ), + "manifest": _manifest( + field_keys=[ + "claim.amount", + "claim.attendee_count", + "claim.per_capita_amount", + "claim.reason", + ], + conditions=[ + { + "id": "per_capita_amount_exceeds_limit", + "operator": "numeric_compare", + "left_fields": ["claim.per_capita_amount"], + "threshold": 500, + "compare": "gt", + }, + { + "id": "missing_special_approval_reason", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["高级审批", "特殊客户", "重要客户", "专项审批"], + }, + ], + hit_logic={"all": ["per_capita_amount_exceeds_limit", "missing_special_approval_reason"]}, + message="业务招待人均金额超过公司标准,且缺少特殊审批或客户接待说明。", + summary="人均金额大于招待标准阈值,且缺少合理审批说明时命中。", + semantic_type="entertainment_per_capita_limit_check", + ), + }, +] diff --git a/server/src/app/services/risk_rule_dsl_validator.py b/server/src/app/services/risk_rule_dsl_validator.py new file mode 100644 index 0000000..9e1ffb0 --- /dev/null +++ b/server/src/app/services/risk_rule_dsl_validator.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from app.services.risk_rule_generation_interpreter import ( + COMPOSITE_RULE_OPERATORS, + COMPOSITE_RULE_TEMPLATE_KEY, +) +from app.services.risk_rule_generation_ontology import RiskRuleField +from app.services.risk_rule_generation_semantics import CITY_CONSISTENCY_SEMANTIC_TYPE + + +STRUCTURED_TERMS = ( + "一致", + "不一致", + "匹配", + "不匹配", + "范围", + "早于", + "晚于", + "超过", + "超出", + "超预算", + "预算", + "余额", + "阈值", + "重复", + "同一发票", + "未上传", + "缺少附件", +) +CITY_TERMS = ("城市", "地点", "目的地", "行程", "交通票", "住宿") +DATE_TERMS = ("日期", "时间", "开始", "结束", "早于", "晚于", "入住", "离店") +AMOUNT_TERMS = ("金额", "预算", "余额", "阈值", "超过", "超出", "超预算") +ATTACHMENT_TERMS = ("附件", "票据", "发票", "水单", "上传", "未上传") +DUPLICATE_TERMS = ("重复", "同一发票", "发票号", "票据号") +KEYWORD_FALLBACK_TERMS = ("风险关键词", "关键词匹配", "规则描述中的风险关键词") + + +def validate_risk_rule_draft( + draft: dict[str, Any], + *, + fields: list[RiskRuleField], + natural_language: str, +) -> dict[str, Any]: + """Normalize generated DSL and record validation issues. + + This guardrail is intentionally deterministic. Hermes may provide semantic + understanding, but executable JSON must still pass a controlled schema. + """ + + normalized = deepcopy(draft) if isinstance(draft, dict) else {} + field_by_key = {field.key: field for field in fields} + field_keys = _filter_fields(_read_string_list(normalized.get("field_keys")), field_by_key) + if not field_keys: + field_keys = [field.key for field in fields[:8]] + normalized["field_keys"] = field_keys + + issues: list[str] = [] + text = _join_text( + natural_language, + normalized.get("description"), + normalized.get("condition_summary"), + normalized.get("formula"), + ) + template_key = str(normalized.get("template_key") or "field_required_v1").strip() + if template_key != COMPOSITE_RULE_TEMPLATE_KEY and _looks_like_city_rule(text, field_keys): + normalized["template_key"] = "field_compare_v1" + normalized["semantic_type"] = CITY_CONSISTENCY_SEMANTIC_TYPE + normalized["keywords"] = [] + issues.append("city_rule_normalized_to_structured_compare") + elif template_key == "keyword_match_v1" and _requires_structured_dsl(text, field_keys, field_by_key): + normalized = _rewrite_keyword_rule_to_composite(normalized, text=text, fields=fields) + issues.append("keyword_rule_rewritten_to_composite_dsl") + elif template_key == COMPOSITE_RULE_TEMPLATE_KEY and not _read_list(normalized.get("conditions")): + normalized = _rewrite_keyword_rule_to_composite(normalized, text=text, fields=fields) + issues.append("empty_composite_rule_built_from_structured_fields") + + if normalized.get("template_key") == COMPOSITE_RULE_TEMPLATE_KEY: + normalized = _normalize_composite_rule(normalized, fields=fields, issues=issues) + else: + normalized = _normalize_non_composite_rule(normalized, fields=fields, issues=issues) + + normalized["dsl_validation"] = { + "status": "passed", + "issues": issues, + "template_key": normalized.get("template_key"), + "operators": [ + str(item.get("operator") or "").strip() + for item in _read_list(normalized.get("conditions")) + if isinstance(item, dict) + ], + } + return normalized + + +def _normalize_non_composite_rule( + draft: dict[str, Any], + *, + fields: list[RiskRuleField], + issues: list[str], +) -> dict[str, Any]: + field_by_key = {field.key: field for field in fields} + normalized = dict(draft) + normalized["field_keys"] = _filter_fields(_read_string_list(normalized.get("field_keys")), field_by_key) + summary = str(normalized.get("condition_summary") or "").strip() + if any(term in summary for term in KEYWORD_FALLBACK_TERMS) and normalized.get("template_key") != "keyword_match_v1": + normalized["condition_summary"] = _generic_structured_summary(normalized.get("field_keys") or []) + issues.append("keyword_fallback_summary_replaced") + return normalized + + +def _normalize_composite_rule( + draft: dict[str, Any], + *, + fields: list[RiskRuleField], + issues: list[str], +) -> dict[str, Any]: + field_by_key = {field.key: field for field in fields} + normalized = dict(draft) + conditions = [] + for index, condition in enumerate(_read_list(normalized.get("conditions")), start=1): + if not isinstance(condition, dict): + issues.append("non_dict_condition_removed") + continue + normalized_condition = _normalize_condition(condition, index=index, field_by_key=field_by_key) + if normalized_condition: + conditions.append(normalized_condition) + else: + issues.append(f"invalid_condition_removed:{index}") + if not conditions: + conditions = _build_fallback_conditions(fields) + issues.append("fallback_conditions_created") + normalized["conditions"] = conditions + normalized["field_keys"] = _collect_condition_fields(conditions) or [ + field.key for field in fields[:8] + ] + normalized["hit_logic"] = _normalize_hit_logic(normalized.get("hit_logic"), conditions) + summary = str(normalized.get("condition_summary") or "").strip() + if not summary or any(term in summary for term in KEYWORD_FALLBACK_TERMS): + normalized["condition_summary"] = _generic_structured_summary(normalized["field_keys"]) + issues.append("keyword_fallback_summary_replaced") + normalized["keywords"] = [] + return normalized + + +def _normalize_condition( + condition: dict[str, Any], + *, + index: int, + field_by_key: dict[str, RiskRuleField], +) -> dict[str, Any] | None: + operator = str(condition.get("operator") or "").strip() + if operator not in COMPOSITE_RULE_OPERATORS: + return None + item = dict(condition) + item["id"] = str(item.get("id") or f"condition_{index}").strip() + item["operator"] = operator + for key in ("fields", "left_fields", "right_fields", "date_fields", "range_start_fields", "range_end_fields"): + item[key] = _filter_fields(_read_string_list(item.get(key)), field_by_key) + if operator in {"contains_any", "not_contains_any"}: + keywords = _read_string_list(item.get("keywords")) + if not keywords: + return None + item["keywords"] = keywords[:12] + if operator == "date_outside_range" and not item["date_fields"]: + return None + if operator == "numeric_compare": + item["compare"] = str(item.get("compare") or item.get("comparator") or "gt").strip() + if not item["left_fields"] and item["fields"]: + item["left_fields"] = item["fields"] + has_right = bool(item["right_fields"]) or item.get("threshold") is not None or item.get("value") is not None + if not item["left_fields"] or not has_right: + return None + if operator == "duplicate_value" and not item["fields"]: + return None + return item + + +def _rewrite_keyword_rule_to_composite( + draft: dict[str, Any], + *, + text: str, + fields: list[RiskRuleField], +) -> dict[str, Any]: + conditions = _build_structured_conditions(text, fields) + rewritten = dict(draft) + rewritten["template_key"] = COMPOSITE_RULE_TEMPLATE_KEY + rewritten["conditions"] = conditions + rewritten["hit_logic"] = _logic_for_conditions(conditions) + rewritten["keywords"] = [] + if not rewritten.get("condition_summary") or any( + term in str(rewritten.get("condition_summary") or "") for term in KEYWORD_FALLBACK_TERMS + ): + rewritten["condition_summary"] = _generic_structured_summary(_collect_condition_fields(conditions)) + return rewritten + + +def _build_structured_conditions(text: str, fields: list[RiskRuleField]) -> list[dict[str, Any]]: + conditions: list[dict[str, Any]] = [] + field_keys = [field.key for field in fields] + attachment_fields = [key for key in field_keys if key.startswith("attachment.")] + city_left = [key for key in field_keys if key in {"attachment.hotel_city", "attachment.route_cities"}] + city_right = [key for key in field_keys if key in {"claim.location", "item.item_location", "employee.location"}] + date_fields = [key for key in field_keys if _field_type(key, fields) == "date" and key.startswith("attachment.")] + range_start = [key for key in field_keys if key in {"claim.trip_start_date", "item.item_date"}] + range_end = [key for key in field_keys if key in {"claim.trip_end_date", "item.item_date"}] + amount_left = [key for key in field_keys if key in {"claim.amount", "item.item_amount"}] + amount_right = [key for key in field_keys if key.startswith("budget.")] + duplicate_fields = [key for key in field_keys if key in {"attachment.invoice_no", "item.invoice_id"}] + + if attachment_fields and any(term in text for term in ATTACHMENT_TERMS): + conditions.append({"id": "attachment_evidence_present", "operator": "exists_any", "fields": attachment_fields[:4]}) + if city_left and city_right and any(term in text for term in CITY_TERMS): + conditions.append({"id": "city_outside_business_scope", "operator": "not_in_scope", "left_fields": city_left, "right_fields": city_right}) + if date_fields and (range_start or range_end) and any(term in text for term in DATE_TERMS): + conditions.append({"id": "date_outside_business_range", "operator": "date_outside_range", "date_fields": date_fields, "range_start_fields": range_start, "range_end_fields": range_end}) + if amount_left and amount_right and any(term in text for term in AMOUNT_TERMS): + conditions.append({"id": "amount_exceeds_budget", "operator": "numeric_compare", "left_fields": amount_left[:1], "right_fields": amount_right[:1], "compare": "gt"}) + if duplicate_fields and any(term in text for term in DUPLICATE_TERMS): + conditions.append({"id": "duplicate_invoice_no", "operator": "duplicate_value", "fields": duplicate_fields}) + exception_keywords = draft_exception_keywords_from_text(text) + exception_fields = [key for key in field_keys if key in {"claim.reason", "item.item_reason"}] + if exception_fields and exception_keywords: + conditions.append({"id": "missing_reasonable_exception", "operator": "not_contains_any", "fields": exception_fields, "keywords": exception_keywords}) + return conditions or [{"id": "structured_fields_present", "operator": "exists_any", "fields": field_keys[:4]}] + + +def draft_exception_keywords_from_text(text: str) -> list[str]: + candidates = ("延期", "改签", "临时任务", "跨城", "绕行", "补充说明", "审批说明") + return [item for item in candidates if item in text] + + +def _logic_for_conditions(conditions: list[dict[str, Any]]) -> dict[str, Any]: + required = [item["id"] for item in conditions if item.get("operator") in {"exists_any", "exists_all", "all_present"}] + exceptions = [item["id"] for item in conditions if item.get("operator") == "not_contains_any"] + anomaly = [item["id"] for item in conditions if item["id"] not in {*required, *exceptions}] + parts: list[Any] = [*required] + if len(anomaly) == 1: + parts.append(anomaly[0]) + elif anomaly: + parts.append({"any": anomaly}) + parts.extend(exceptions) + return {"all": parts or [item["id"] for item in conditions]} + + +def _normalize_hit_logic(value: Any, conditions: list[dict[str, Any]]) -> Any: + ids = {str(item.get("id") or "").strip() for item in conditions} + + def normalize(node: Any) -> Any: + if isinstance(node, str): + return node if node in ids else None + if isinstance(node, list): + return [item for item in (normalize(child) for child in node) if item] + if isinstance(node, dict): + result = {} + for key in ("all", "any"): + values = normalize(node.get(key)) + if values: + result[key] = values + if "not" in node: + result["not"] = normalize(node.get("not")) + return result or None + return None + + normalized = normalize(value) + return normalized if normalized else _logic_for_conditions(conditions) + + +def _build_fallback_conditions(fields: list[RiskRuleField]) -> list[dict[str, Any]]: + return [{"id": "required_evidence_present", "operator": "exists_any", "fields": [field.key for field in fields[:4]]}] + + +def _requires_structured_dsl( + text: str, + field_keys: list[str], + field_by_key: dict[str, RiskRuleField], +) -> bool: + if any(term in text for term in STRUCTURED_TERMS): + return True + return any( + field_by_key.get(key) and field_by_key[key].field_type in {"date", "number", "list"} + for key in field_keys + ) + + +def _looks_like_city_rule(text: str, field_keys: list[str]) -> bool: + has_city_field = any(key in {"claim.location", "item.item_location", "attachment.hotel_city", "attachment.route_cities"} for key in field_keys) + return has_city_field and any(term in text for term in CITY_TERMS) and any(term in text for term in ("一致", "匹配", "对应", "绕行", "跨城", "改签")) + + +def _collect_condition_fields(conditions: list[dict[str, Any]]) -> list[str]: + keys: list[str] = [] + for condition in conditions: + for name in ("fields", "left_fields", "right_fields", "date_fields", "range_start_fields", "range_end_fields"): + for key in _read_string_list(condition.get(name)): + if key not in keys: + keys.append(key) + return keys + + +def _generic_structured_summary(field_keys: list[str]) -> str: + fields = "、".join(field_keys[:6]) or "规则字段" + return f"按结构化字段执行判断:读取 {fields},根据字段关系、范围、阈值和例外说明决定是否命中风险。" + + +def _filter_fields(values: list[str], field_by_key: dict[str, RiskRuleField]) -> list[str]: + return [key for key in values if key in field_by_key] + + +def _field_type(key: str, fields: list[RiskRuleField]) -> str: + for field in fields: + if field.key == key: + return field.field_type + return "" + + +def _join_text(*values: Any) -> str: + return "\n".join(str(value or "") for value in values if str(value or "").strip()) + + +def _read_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _read_string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item or "").strip() for item in value if str(item or "").strip()] diff --git a/server/src/app/services/risk_rule_execution_trace.py b/server/src/app/services/risk_rule_execution_trace.py new file mode 100644 index 0000000..35694e8 --- /dev/null +++ b/server/src/app/services/risk_rule_execution_trace.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import Any + + +def build_risk_rule_execution_trace( + manifest: dict[str, Any], + *, + result: dict[str, Any] | None, +) -> dict[str, Any]: + evidence = result.get("evidence") if isinstance(result, dict) else {} + if not isinstance(evidence, dict): + evidence = {} + matched = isinstance(result, dict) + severity = _risk_severity(manifest) if matched else "none" + steps = _build_condition_steps(manifest, evidence) + if not steps: + steps = [_generic_step(manifest, evidence, matched)] + path_node_ids = ["start", "evidence", *[step["node_id"] for step in steps]] + path_node_ids.append("hit" if matched else "pass") + return { + "matched": matched, + "risk_level": severity, + "risk_score": _risk_score(manifest), + "path_node_ids": _dedupe(path_node_ids), + "steps": steps, + } + + +def _build_condition_steps(manifest: dict[str, Any], evidence: dict[str, Any]) -> list[dict[str, Any]]: + steps: list[dict[str, Any]] = [] + condition_results = evidence.get("condition_results") + condition_evidence = evidence.get("conditions") + if isinstance(condition_results, dict): + evidence_by_id = { + str(item.get("id") or ""): item + for item in condition_evidence + if isinstance(item, dict) + } if isinstance(condition_evidence, list) else {} + for condition_id, passed in condition_results.items(): + item = evidence_by_id.get(str(condition_id), {}) + steps.append( + { + "node_id": str(condition_id), + "title": _condition_title(manifest, str(condition_id)), + "result": bool(passed), + "operator": str(item.get("operator") or ""), + "inputs": _compact_inputs(item), + } + ) + return steps + + city_consistency = evidence.get("city_consistency") + if isinstance(city_consistency, dict): + steps.append( + { + "node_id": "city_consistency", + "title": "城市一致性判断", + "result": bool( + city_consistency.get("unexpected_route_cities") + or not _has_overlap( + city_consistency.get("attachment_values"), + city_consistency.get("reference_values"), + ) + ), + "operator": "route_city_consistency", + "inputs": { + "attachment_values": city_consistency.get("attachment_values") or [], + "reference_values": city_consistency.get("reference_values") or [], + "home_values": city_consistency.get("home_values") or [], + "unexpected_route_cities": city_consistency.get("unexpected_route_cities") or [], + "explanation_hits": city_consistency.get("explanation_hits") or [], + }, + } + ) + return steps + + failed_conditions = evidence.get("failed_conditions") + if isinstance(failed_conditions, list): + for index, item in enumerate(failed_conditions, start=1): + if not isinstance(item, dict): + continue + steps.append( + { + "node_id": str(item.get("id") or f"condition_{index}"), + "title": _condition_title(manifest, str(item.get("id") or f"condition_{index}")), + "result": True, + "operator": str(item.get("operator") or ""), + "inputs": _compact_inputs(item), + } + ) + return steps + + +def _generic_step( + manifest: dict[str, Any], + evidence: dict[str, Any], + matched: bool, +) -> dict[str, Any]: + params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} + return { + "node_id": "decision", + "title": "规则判断", + "result": matched, + "operator": str(params.get("template_key") or manifest.get("template_key") or ""), + "inputs": { + "condition_summary": evidence.get("condition_summary") or params.get("condition_summary") or "", + "missing_fields": evidence.get("missing_fields") or [], + "keyword_hits": evidence.get("keyword_hits") or [], + }, + } + + +def _condition_title(manifest: dict[str, Any], condition_id: str) -> str: + params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} + conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else [] + for index, condition in enumerate(conditions, start=1): + if not isinstance(condition, dict): + continue + current_id = str(condition.get("id") or f"condition_{index}") + if current_id == condition_id: + return str(condition.get("title") or condition.get("operator") or condition_id) + return condition_id + + +def _compact_inputs(item: dict[str, Any]) -> dict[str, Any]: + keys = ( + "fields", + "left_fields", + "right_fields", + "left_values", + "right_values", + "values", + "missing_fields", + "keyword_hits", + "dates", + "range_start", + "range_end", + "outside_dates", + ) + return {key: item.get(key) for key in keys if item.get(key) not in (None, "", [])} + + +def _risk_severity(manifest: dict[str, Any]) -> str: + outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {} + fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {} + return str(fail.get("severity") or "medium") + + +def _risk_score(manifest: dict[str, Any]) -> int | None: + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {} + fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {} + for value in (fail.get("risk_score"), metadata.get("risk_score")): + try: + return int(value) + except (TypeError, ValueError): + continue + return None + + +def _has_overlap(left: Any, right: Any) -> bool: + left_set = {str(item).strip().lower() for item in left or [] if str(item).strip()} + right_set = {str(item).strip().lower() for item in right or [] if str(item).strip()} + return bool(left_set & right_set) + + +def _dedupe(values: list[str]) -> list[str]: + rows: list[str] = [] + for value in values: + if value and value not in rows: + rows.append(value) + return rows diff --git a/server/src/app/services/risk_rule_explainability.py b/server/src/app/services/risk_rule_explainability.py new file mode 100644 index 0000000..ea1f374 --- /dev/null +++ b/server/src/app/services/risk_rule_explainability.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +from typing import Any + +from app.services.risk_rule_flow_diagram import ( + RiskRuleFlowDiagramField, + RiskRuleFlowDiagramRenderer, + build_risk_rule_flow_diagram_spec, +) +from app.services.risk_rule_generation_ontology import RiskRuleField + + +def build_risk_rule_explainability_artifacts( + payload: dict[str, Any], + *, + fields: list[RiskRuleField], + domain_label: str, + risk_level: str, + risk_level_label: str, +) -> dict[str, Any]: + diagram_fields = tuple( + RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields + ) + semantic_plan = build_semantic_plan( + payload, + fields=diagram_fields, + domain_label=domain_label, + risk_level=risk_level, + risk_level_label=risk_level_label, + ) + flow_model = build_flow_model( + payload, + fields=diagram_fields, + semantic_plan=semantic_plan, + risk_level=risk_level, + risk_level_label=risk_level_label, + ) + flow_explanation = build_flow_explanation(flow_model) + flow_diagram_svg = build_flow_diagram_svg( + payload, + fields=diagram_fields, + flow_model=flow_model, + domain_label=domain_label, + risk_level=risk_level, + risk_level_label=risk_level_label, + ) + return { + "semantic_plan": semantic_plan, + "flow_model": flow_model, + "flow_explanation": flow_explanation, + "flow_diagram_svg": flow_diagram_svg, + } + + +def build_semantic_plan( + payload: dict[str, Any], + *, + fields: tuple[RiskRuleFlowDiagramField, ...], + domain_label: str, + risk_level: str, + risk_level_label: str, +) -> dict[str, Any]: + params = _read_dict(payload.get("params")) + metadata = _read_dict(payload.get("metadata")) + outcomes = _read_dict(payload.get("outcomes")) + fail = _read_dict(outcomes.get("fail")) + return { + "rule_intent": _text(payload.get("description")) + or _text(metadata.get("natural_language")) + or _text(payload.get("name")), + "scope": { + "domain_label": domain_label, + "business_stage": _text(params.get("business_stage")) + or _text(metadata.get("business_stage")), + "business_stage_label": _text(params.get("business_stage_label")) + or _text(metadata.get("business_stage_label")), + "expense_category": _text(metadata.get("expense_category")), + "expense_category_label": _text(metadata.get("expense_category_label")) + or _text(payload.get("risk_category")), + }, + "required_fields": [ + { + "label": field.label or field.key, + "field": field.key, + "display": _field_display(field), + } + for field in fields + ], + "judgment_steps": _build_judgment_steps(params, fields), + "exception_conditions": _build_exception_conditions(params), + "risk_action": { + "risk_level": risk_level, + "risk_level_label": risk_level_label, + "risk_score": fail.get("risk_score") or metadata.get("risk_score"), + "decision": fail.get("action") or "manual_review", + "message": _text(params.get("message_template")) + or _text(params.get("condition_summary")) + or "命中后进入人工复核。", + }, + } + + +def build_flow_model( + payload: dict[str, Any], + *, + fields: tuple[RiskRuleFlowDiagramField, ...], + semantic_plan: dict[str, Any], + risk_level: str, + risk_level_label: str, +) -> dict[str, Any]: + params = _read_dict(payload.get("params")) + metadata = _read_dict(payload.get("metadata")) + flow = _read_dict(metadata.get("flow")) + conditions = _read_list(params.get("conditions")) + nodes: list[dict[str, Any]] = [ + { + "id": "start", + "type": "start", + "title": "业务输入", + "description": _text(flow.get("start")) or "业务单据提交", + }, + { + "id": "evidence", + "type": "evidence", + "title": "字段事实", + "description": _text(flow.get("evidence")) or "读取规则字段并形成判断事实", + "fields": [field.key for field in fields], + }, + ] + for index, condition in enumerate(conditions, start=1): + if not isinstance(condition, dict): + continue + nodes.append( + { + "id": _condition_id(condition, index), + "type": "decision", + "title": _condition_title(condition, index), + "description": _condition_description(condition), + "operator": _text(condition.get("operator")), + "fields": _condition_fields(condition), + } + ) + if len(nodes) == 2: + nodes.append( + { + "id": "decision", + "type": "decision", + "title": "判断依据", + "description": _text(params.get("condition_summary")) + or _text(flow.get("decision")) + or "判断是否命中风险", + "fields": [field.key for field in fields], + } + ) + nodes.extend( + [ + { + "id": "pass", + "type": "pass", + "title": "不命中风险", + "description": _text(flow.get("pass")) or "继续业务流转", + }, + { + "id": "hit", + "type": "risk", + "title": f"命中{risk_level_label}", + "description": _text(flow.get("fail")) + or f"命中{risk_level_label},进入人工复核", + "risk_level": risk_level, + }, + ] + ) + edges = _build_edges([node["id"] for node in nodes if node["id"] not in {"pass", "hit"}]) + return { + "version": "1.0", + "source": "json_dsl", + "nodes": nodes, + "edges": edges, + "risk_level": risk_level, + "risk_level_label": risk_level_label, + "semantic_plan_ref": semantic_plan.get("rule_intent", ""), + } + + +def build_flow_explanation(flow_model: dict[str, Any]) -> list[dict[str, str]]: + rows = [] + for node in _read_list(flow_model.get("nodes")): + if not isinstance(node, dict): + continue + if node.get("type") in {"start", "evidence", "decision", "risk", "pass"}: + rows.append( + { + "node_id": _text(node.get("id")), + "title": _text(node.get("title")), + "description": _text(node.get("description")), + } + ) + return rows + + +def build_flow_diagram_svg( + payload: dict[str, Any], + *, + fields: tuple[RiskRuleFlowDiagramField, ...], + flow_model: dict[str, Any] | None = None, + domain_label: str, + risk_level: str, + risk_level_label: str, +) -> str: + renderer = RiskRuleFlowDiagramRenderer() + return renderer.render(build_risk_rule_flow_diagram_spec( + payload, + fields=fields, + flow_model=flow_model, + domain_label=domain_label, + severity=risk_level, + severity_label=risk_level_label, + )) + + +def _build_judgment_steps( + params: dict[str, Any], + fields: tuple[RiskRuleFlowDiagramField, ...], +) -> list[dict[str, Any]]: + conditions = _read_list(params.get("conditions")) + if not conditions: + return [ + { + "id": "decision", + "operator": _text(params.get("template_key")), + "description": _text(params.get("condition_summary")) or "判断规则字段是否满足条件。", + "fields": [field.key for field in fields], + } + ] + steps = [] + for index, condition in enumerate(conditions, start=1): + if isinstance(condition, dict): + steps.append( + { + "id": _condition_id(condition, index), + "operator": _text(condition.get("operator")), + "description": _condition_description(condition), + "fields": _condition_fields(condition), + } + ) + return steps + + +def _build_exception_conditions(params: dict[str, Any]) -> list[dict[str, Any]]: + keywords = _read_string_list(params.get("exception_keywords")) + fields = _read_string_list(params.get("exception_fields")) + if not keywords and not fields: + return [] + return [{"fields": fields, "keywords": keywords, "effect": "作为复核或降级依据,不替代结构化判断"}] + + +def _build_edges(decision_node_ids: list[str]) -> list[dict[str, str]]: + if not decision_node_ids: + return [] + edges = [{"from": "start", "to": "evidence", "label": "开始"}] + previous = "evidence" + for node_id in decision_node_ids: + if node_id in {"start", "evidence"}: + continue + edges.append({"from": previous, "to": node_id, "label": "进入判断"}) + previous = node_id + edges.append({"from": previous, "to": "pass", "label": "否"}) + edges.append({"from": previous, "to": "hit", "label": "是"}) + return edges + + +def _condition_id(condition: dict[str, Any], index: int) -> str: + return _text(condition.get("id")) or f"condition_{index}" + + +def _condition_title(condition: dict[str, Any], index: int) -> str: + operator = _text(condition.get("operator")) or "condition" + return _text(condition.get("title")) or f"判断 {index}: {operator}" + + +def _condition_description(condition: dict[str, Any]) -> str: + operator = _text(condition.get("operator")) + if operator in {"not_in_scope", "not_in_set", "not_overlap"}: + return "左侧字段集合与右侧字段集合无交集时成立。" + if operator in {"in_scope", "overlap"}: + return "左侧字段集合与右侧字段集合存在交集时成立。" + if operator == "date_outside_range": + return "日期字段早于开始日期或晚于结束日期时成立。" + if operator == "numeric_compare": + return "数值字段与预算、阈值或金额字段比较后满足超额、低于或等于等关系时成立。" + if operator == "duplicate_value": + return "同一票据号、附件编号或业务唯一键在规则范围内重复出现时成立。" + if operator in {"contains_any", "not_contains_any"}: + return "检查文本字段是否包含指定说明关键词。" + if operator in {"exists_any", "exists_all", "all_present"}: + return "检查规则要求字段是否已提供。" + return _text(condition.get("description")) or "执行规则条件判断。" + + +def _condition_fields(condition: dict[str, Any]) -> list[str]: + keys: list[str] = [] + for name in ( + "fields", + "left_fields", + "right_fields", + "date_fields", + "range_start_fields", + "range_end_fields", + "exception_fields", + ): + for key in _read_string_list(condition.get(name)): + if key not in keys: + keys.append(key) + for name in ("left", "right"): + value = _text(condition.get(name)) + if value and value not in keys: + keys.append(value) + return keys + + +def _field_display(field: RiskRuleFlowDiagramField) -> str: + if field.label and field.label != field.key: + return f"{field.label}[{field.key}]" + return field.label or field.key + + +def _read_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _read_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _read_string_list(value: Any) -> list[str]: + return [_text(item) for item in _read_list(value) if _text(item)] + + +def _text(value: Any) -> str: + return str(value or "").strip() diff --git a/server/src/app/services/risk_rule_flow_diagram.py b/server/src/app/services/risk_rule_flow_diagram.py index 3e7428e..fe946df 100644 --- a/server/src/app/services/risk_rule_flow_diagram.py +++ b/server/src/app/services/risk_rule_flow_diagram.py @@ -257,6 +257,130 @@ def build_risk_rule_flow_diagram_details( } +def build_risk_rule_flow_diagram_spec( + payload: dict[str, Any], + *, + fields: tuple[RiskRuleFlowDiagramField, ...], + domain_label: str, + severity: str, + severity_label: str, + flow_model: dict[str, Any] | None = None, +) -> RiskRuleFlowDiagramSpec: + model_spec = _spec_from_flow_model( + payload, + fields=fields, + domain_label=domain_label, + severity=severity, + severity_label=severity_label, + flow_model=flow_model or {}, + ) + if model_spec: + return model_spec + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {} + details = build_risk_rule_flow_diagram_details(payload, list(fields)) + summary = str(metadata.get("condition_summary") or "").strip() + return RiskRuleFlowDiagramSpec( + title=str(payload.get("name") or "").strip() or "风险规则判断流程", + domain_label=domain_label, + severity=severity, + severity_label=severity_label, + fields=fields, + start=str(flow.get("start") or "").strip() or "业务单据提交", + evidence=str(flow.get("evidence") or "").strip() or "读取规则字段", + decision=str(flow.get("decision") or "").strip() or summary or "判断是否命中风险", + basis=summary or str(flow.get("decision") or "").strip() or "根据规则字段判断", + pass_text=str(flow.get("pass") or "").strip() or "未命中风险,继续流转", + fail_text=str(flow.get("fail") or "").strip() or f"命中{severity_label},进入人工复核", + fact_lines=details["fact_lines"], + condition_lines=details["condition_lines"], + hit_logic=str(details["hit_logic"] or ""), + ) + + +def _spec_from_flow_model( + payload: dict[str, Any], + *, + fields: tuple[RiskRuleFlowDiagramField, ...], + domain_label: str, + severity: str, + severity_label: str, + flow_model: dict[str, Any], +) -> RiskRuleFlowDiagramSpec | None: + nodes = flow_model.get("nodes") if isinstance(flow_model, dict) else [] + if not isinstance(nodes, list) or not nodes: + return None + by_type: dict[str, list[dict[str, Any]]] = {} + for node in nodes: + if isinstance(node, dict): + by_type.setdefault(str(node.get("type") or "").strip(), []).append(node) + decisions = by_type.get("decision") or [] + if not decisions: + return None + start = _node_description(by_type.get("start"), "业务单据提交") + evidence = _node_description(by_type.get("evidence"), "读取规则字段") + pass_text = _node_description(by_type.get("pass"), "未命中风险,继续流转") + fail_text = _node_description(by_type.get("risk"), f"命中{severity_label},进入人工复核") + condition_lines = _condition_lines_from_flow_nodes(decisions) + basis = condition_lines[0] if condition_lines else _node_description(decisions, "判断是否命中风险") + return RiskRuleFlowDiagramSpec( + title=str(payload.get("name") or "").strip() or "风险规则判断流程", + domain_label=domain_label, + severity=severity, + severity_label=severity_label, + fields=fields, + start=start, + evidence=evidence, + decision=_node_description(decisions, basis), + basis=basis, + pass_text=pass_text, + fail_text=fail_text, + fact_lines=tuple(_field_lines_from_flow_nodes(by_type.get("evidence"), fields)), + condition_lines=tuple(condition_lines), + hit_logic=_hit_logic_from_flow_model(flow_model, condition_lines), + ) + + +def _node_description(nodes: list[dict[str, Any]] | None, fallback: str) -> str: + node = nodes[0] if nodes else {} + return str(node.get("description") or node.get("title") or fallback).strip() + + +def _condition_lines_from_flow_nodes(nodes: list[dict[str, Any]]) -> list[str]: + visible = [ + f"{str(node.get('title') or node.get('id') or '判断').strip()}: {str(node.get('description') or '').strip()}" + for node in nodes[:4] + ] + if len(nodes) > 4: + visible[-1] = f"{visible[-1]};另有 {len(nodes) - 4} 个判断节点按命中逻辑汇总" + return visible + + +def _field_lines_from_flow_nodes( + nodes: list[dict[str, Any]] | None, + fields: tuple[RiskRuleFlowDiagramField, ...], +) -> list[str]: + field_keys = _read_string_list((nodes[0] if nodes else {}).get("fields")) + if not field_keys: + return [ + f"{chr(65 + index)}={field.label or field.key}[{field.key}]" + for index, field in enumerate(fields[:4]) + ] + label_by_key = {field.key: field.label or field.key for field in fields} + return [ + f"{chr(65 + index)}={label_by_key.get(key, key)}[{key}]" + for index, key in enumerate(field_keys[:4]) + ] + + +def _hit_logic_from_flow_model(flow_model: dict[str, Any], condition_lines: list[str]) -> str: + metadata = flow_model.get("metadata") if isinstance(flow_model.get("metadata"), dict) else {} + logic = str(metadata.get("hit_logic") or "").strip() + if logic: + return logic + return " AND ".join(line.split(":", 1)[0] for line in condition_lines[:4] if line) + + def _build_fact_lines( facts: list[Any], fields: list[RiskRuleFlowDiagramField], @@ -313,6 +437,15 @@ def _format_condition(condition: dict[str, Any], label_by_key: dict[str, str], i start = _field_group(condition.get("range_start_fields"), label_by_key) end = _field_group(condition.get("range_end_fields"), label_by_key) return f"{prefix}{dates} 不在 [{start}, {end}]" + if operator == "numeric_compare": + left = _field_group(condition.get("left_fields") or condition.get("fields"), label_by_key) + right = _field_group(condition.get("right_fields"), label_by_key) + compare = str(condition.get("compare") or "gt").strip().upper() + target = right or str(condition.get("threshold") or condition.get("value") or "阈值").strip() + return f"{prefix}{left} {compare} {target}" + if operator == "duplicate_value": + fields = _field_group(condition.get("fields"), label_by_key) + return f"{prefix}{fields} 出现重复值" if operator in {"contains_any", "not_contains_any"}: fields = _field_group(condition.get("fields"), label_by_key) keywords = "、".join(_read_string_list(condition.get("keywords"))[:4]) diff --git a/server/src/app/services/risk_rule_generation.py b/server/src/app/services/risk_rule_generation.py index a3ae3b5..3471406 100644 --- a/server/src/app/services/risk_rule_generation.py +++ b/server/src/app/services/risk_rule_generation.py @@ -13,12 +13,7 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.audit import AuditLogService -from app.services.risk_rule_flow_diagram import ( - RiskRuleFlowDiagramField, - RiskRuleFlowDiagramRenderer, - RiskRuleFlowDiagramSpec, - build_risk_rule_flow_diagram_details, -) +from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts from app.services.risk_rule_generation_ontology import ( BUSINESS_DOMAIN_LABELS, DOMAIN_FIELD_PREFIXES, @@ -38,6 +33,8 @@ from app.services.risk_rule_generation_semantics import ( build_city_consistency_draft, build_city_consistency_params, ) +from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload +from app.services.risk_rule_dsl_validator import validate_risk_rule_draft from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score from app.services.runtime_chat import RuntimeChatService @@ -54,7 +51,6 @@ class RiskRuleGenerationService: self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager() self.runtime_chat_service = runtime_chat_service or RuntimeChatService(db) self.audit_service = AuditLogService(db) - self.flow_diagram_renderer = RiskRuleFlowDiagramRenderer() def generate_rule_asset( self, @@ -98,12 +94,14 @@ class RiskRuleGenerationService: risk_level="medium", fields=fields, ) + draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language) draft = self._align_draft_fields( draft, natural_language=natural_language, risk_level="medium", fields=fields, ) + draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language) risk_score = calculate_risk_rule_score( natural_language=natural_language, draft=draft, @@ -261,6 +259,7 @@ class RiskRuleGenerationService: return None if not isinstance(payload, dict): return None + payload = unwrap_semantic_plan_payload(payload) return self._sanitize_model_draft(payload, fields=fields) def _sanitize_model_draft( @@ -341,6 +340,8 @@ class RiskRuleGenerationService: scoring_evidence = payload.get("risk_scoring_evidence") if isinstance(scoring_evidence, dict): draft["risk_scoring_evidence"] = scoring_evidence + if isinstance(payload.get("model_semantic_plan"), dict): + draft["model_semantic_plan"] = payload["model_semantic_plan"] for key in ("formula", "message_template"): value = self._clean_text(payload.get(key)) if value: @@ -435,6 +436,8 @@ class RiskRuleGenerationService: semantic_type = str(draft.get("semantic_type") or "").strip() if semantic_type: params["semantic_type"] = semantic_type + if isinstance(draft.get("dsl_validation"), dict): + params["dsl_validation"] = draft["dsl_validation"] if template_key == COMPOSITE_RULE_TEMPLATE_KEY and isinstance(draft.get("rule_ir"), dict): params["rule_ir"] = draft["rule_ir"] for key in ("conditions", "hit_logic", "field_groups", "formula", "message_template"): @@ -516,60 +519,28 @@ class RiskRuleGenerationService: "business_explanation": self._clean_text(draft.get("description")), "condition_summary": condition_summary, "rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {}, + "model_semantic_plan": draft.get("model_semantic_plan") if isinstance(draft.get("model_semantic_plan"), dict) else {}, "flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {}, }, } - payload["flow_diagram_svg"] = self._build_flow_diagram_svg( + explainability = build_risk_rule_explainability_artifacts( payload, fields=[field_by_key[key] for key in field_keys if key in field_by_key], - domain=domain, domain_label=risk_category, risk_level=risk_level, + risk_level_label=risk_level_label, + ) + payload.update(explainability) + payload["metadata"].update( + { + "semantic_plan": explainability["semantic_plan"], + "flow_model": explainability["flow_model"], + "flow_explanation": explainability["flow_explanation"], + "flow_diagram_svg": explainability["flow_diagram_svg"], + } ) return payload - def _build_flow_diagram_svg( - self, - payload: dict[str, Any], - *, - fields: list[RiskRuleField], - domain: str, - domain_label: str | None = None, - risk_level: str, - ) -> str: - metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} - flow = metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {} - condition_summary = self._clean_text(metadata.get("condition_summary")) - diagram_fields = [ - RiskRuleFlowDiagramField(key=field.key, label=field.label) for field in fields - ] - details = build_risk_rule_flow_diagram_details(payload, diagram_fields) - return self.flow_diagram_renderer.render( - RiskRuleFlowDiagramSpec( - title=self._clean_text(payload.get("name")) or "风险规则判断流程", - domain_label=domain_label or BUSINESS_DOMAIN_LABELS.get(domain, "业务"), - severity=risk_level, - severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"), - fields=tuple(diagram_fields), - start=self._clean_text(flow.get("start")) or "业务单据提交", - evidence=self._clean_text(flow.get("evidence")) or "读取规则字段", - decision=self._clean_text(flow.get("decision")) - or condition_summary - or "判断是否命中风险", - basis=( - condition_summary - or self._clean_text(flow.get("decision")) - or "根据规则字段判断" - ), - pass_text=self._clean_text(flow.get("pass")) or "未命中风险,继续流转", - fail_text=self._clean_text(flow.get("fail")) - or f"命中{RISK_LEVEL_LABELS.get(risk_level, '风险')},进入人工复核", - fact_lines=details["fact_lines"], - condition_lines=details["condition_lines"], - hit_logic=str(details["hit_logic"] or ""), - ) - ) - @staticmethod def _normalize_expense_category(value: str | None, domain: str) -> str | None: if domain != AgentAssetDomain.EXPENSE.value: @@ -759,6 +730,8 @@ class RiskRuleGenerationService: @staticmethod def _infer_template_key(text: str) -> str: + if any(keyword in text for keyword in ("超过", "超出", "超预算", "预算", "阈值", "早于", "晚于", "范围")): + return COMPOSITE_RULE_TEMPLATE_KEY if any( keyword in text for keyword in ("一致", "匹配", "相同", "不一致", "不符", "对应", "出现在") diff --git a/server/src/app/services/risk_rule_generation_interpreter.py b/server/src/app/services/risk_rule_generation_interpreter.py index 5b2f89d..7f1b1c2 100644 --- a/server/src/app/services/risk_rule_generation_interpreter.py +++ b/server/src/app/services/risk_rule_generation_interpreter.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + COMPOSITE_RULE_TEMPLATE_KEY = "composite_rule_v1" COMPOSITE_RULE_OPERATORS = { @@ -12,6 +14,62 @@ COMPOSITE_RULE_OPERATORS = { "overlap", "not_overlap", "date_outside_range", + "numeric_compare", + "duplicate_value", "contains_any", "not_contains_any", } + + +def build_dsl_from_semantic_plan(semantic_plan: dict[str, Any]) -> dict[str, Any]: + """把模型语义计划转换成可交给 validator 继续规范化的 DSL 草稿。""" + + if not isinstance(semantic_plan, dict): + return {} + text_parts = _semantic_text_parts(semantic_plan) + field_keys = _semantic_field_keys(semantic_plan) + if not text_parts and not field_keys: + return {} + return { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "field_keys": field_keys, + "description": str(semantic_plan.get("rule_intent") or "").strip(), + "condition_summary": ";".join(text_parts)[:800], + "keywords": [], + "rule_ir": { + "facts": field_keys, + "conditions": text_parts, + "hit_logic": "由 DSL validator 根据字段本体和语义步骤生成受控条件", + }, + } + + +def _semantic_text_parts(semantic_plan: dict[str, Any]) -> list[str]: + parts: list[str] = [] + for key in ("rule_intent", "scope", "judgment_steps", "exception_conditions", "risk_action"): + parts.extend(_flatten_semantic_text(semantic_plan.get(key))) + return [item for index, item in enumerate(parts) if item and item not in parts[:index]] + + +def _semantic_field_keys(semantic_plan: dict[str, Any]) -> list[str]: + keys: list[str] = [] + for value in (semantic_plan.get("required_fields"), semantic_plan.get("fields")): + for item in value if isinstance(value, list) else []: + key = item if isinstance(item, str) else next( + (item.get(name) for name in ("field", "key", "field_key") if isinstance(item, dict) and item.get(name)), + "", + ) + text = str(key or "").strip() + if "." in text and text not in keys: + keys.append(text) + return keys + + +def _flatten_semantic_text(value: Any) -> list[str]: + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + if isinstance(value, list): + return [item for value_item in value for item in _flatten_semantic_text(value_item)] + if isinstance(value, dict): + return [item for value_item in value.values() for item in _flatten_semantic_text(value_item)] + return [] diff --git a/server/src/app/services/risk_rule_generation_ontology.py b/server/src/app/services/risk_rule_generation_ontology.py index 09c8478..71a5c88 100644 --- a/server/src/app/services/risk_rule_generation_ontology.py +++ b/server/src/app/services/risk_rule_generation_ontology.py @@ -75,6 +75,22 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = ( ("出差结束", "行程结束", "结束日期", "返程日期", "返回日期"), ), RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额", "费用", "超额", "额度")), + RiskRuleField("claim.attendee_count", "参与人数", "number", "claim", ("人数", "参与人员数", "招待人数")), + RiskRuleField("claim.per_capita_amount", "人均金额", "number", "claim", ("人均", "人均金额", "人均招待")), + RiskRuleField( + "budget.remaining_amount", + "预算可用余额", + "number", + "budget", + ("预算余额", "可用预算", "可用余额", "剩余预算", "预算剩余"), + ), + RiskRuleField( + "budget.limit_amount", + "预算额度", + "number", + "budget", + ("预算额度", "预算上限", "预算阈值", "预算限额"), + ), RiskRuleField("claim.employee_name", "报销人", "text", "claim", ("报销人", "员工", "申请人")), RiskRuleField("claim.department_name", "部门", "text", "claim", ("部门", "组织")), RiskRuleField( @@ -88,6 +104,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = ( RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")), RiskRuleField("item.item_location", "明细地点", "text", "item", ("明细地点", "发生地点")), RiskRuleField("item.item_date", "明细发生日期", "date", "item", ("明细日期", "发生日期", "费用日期")), + RiskRuleField("item.invoice_id", "明细附件编号", "text", "item", ("附件编号", "票据编号", "发票附件")), RiskRuleField( "attachment.invoice_no", "发票号码", "text", "attachment", ("发票号", "发票号码", "票号") ), @@ -162,7 +179,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = ( ) DOMAIN_FIELD_PREFIXES: dict[str, tuple[str, ...]] = { - AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment.", "employee."), + AgentAssetDomain.EXPENSE.value: ("claim.", "item.", "attachment.", "employee.", "budget."), AgentAssetDomain.AR.value: ("receivable.",), AgentAssetDomain.AP.value: ("payable.",), } diff --git a/server/src/app/services/risk_rule_generation_prompt.py b/server/src/app/services/risk_rule_generation_prompt.py index e181d18..33ebe2c 100644 --- a/server/src/app/services/risk_rule_generation_prompt.py +++ b/server/src/app/services/risk_rule_generation_prompt.py @@ -40,7 +40,8 @@ def build_risk_rule_compiler_messages( "id": "稳定英文标识", "operator": ( "exists_any | exists_all | in_scope | not_in_scope | overlap | " - "not_overlap | date_outside_range | contains_any | not_contains_any" + "not_overlap | date_outside_range | numeric_compare | duplicate_value | " + "contains_any | not_contains_any" ), "fields": ["exists/contains 类操作使用"], "left_fields": ["集合比较左侧字段"], @@ -48,6 +49,8 @@ def build_risk_rule_compiler_messages( "date_fields": ["日期字段"], "range_start_fields": ["日期范围开始字段"], "range_end_fields": ["日期范围结束字段"], + "compare": "numeric_compare 使用:gt | gte | lt | lte | eq", + "threshold": "numeric_compare 可选固定阈值;若与预算余额比较,应使用 right_fields", "keywords": ["例外或风险词"], } ], @@ -74,8 +77,20 @@ def build_risk_rule_compiler_messages( "fail": "命中时说明", }, } + response_schema = { + "semantic_plan": { + "rule_intent": "用业务语言复述规则意图", + "scope": "适用业务域、环节、费用领域", + "required_fields": "字段本体映射,必须来自 available_fields", + "judgment_steps": "逐步判断链,先事实、再条件、再例外、最后动作", + "exception_conditions": "例外说明或豁免条件,不得当作风险关键词", + "risk_action": "命中后的业务动作与评分证据", + }, + "dsl": schema, + } guardrails = [ "只能输出 JSON 对象,不能输出 Markdown 或解释。", + "输出结构必须包含 semantic_plan 和 dsl;semantic_plan 先解释业务判断链,dsl 再承载可执行规则。", "必须区分业务环节:费用申请是事前风控,费用报销是事后核验;不要把二者的字段和流程语义混用。", "费用申请阶段更关注预算余额、申请金额、申请事由、预计行程、预计费用科目、是否超预算或缺少前置审批。", "费用报销阶段更关注真实票据、报销明细、发生日期、附件识别结果和申请/行程/票据一致性。", @@ -84,7 +99,10 @@ def build_risk_rule_compiler_messages( "城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。", "涉及多个字段、日期范围、金额范围、集合关系、例外说明的规则必须使用 composite_rule_v1。", "日期字段必须区分事实日期、票据日期和业务期间;如果只能拿到替代字段,要在 rule_ir 中说明这是 fallback evidence。", - "composite_rule_v1 只能使用受控 operator:exists_any、exists_all、in_scope、not_in_scope、overlap、not_overlap、date_outside_range、contains_any、not_contains_any。", + "composite_rule_v1 只能使用受控 operator:exists_any、exists_all、in_scope、not_in_scope、overlap、not_overlap、date_outside_range、numeric_compare、duplicate_value、contains_any、not_contains_any。", + "预算、金额、阈值和超标规则必须用 numeric_compare;例如 claim.amount GT budget.remaining_amount,不得写成金额风险关键词匹配。", + "人均超标规则必须优先使用字段本体中的人均金额字段,例如 claim.per_capita_amount GT 固定阈值,参与人数作为解释事实字段保留。", + "重复发票、同一票据号、重复报销等规则必须用 duplicate_value;例如 attachment.invoice_no 在本次附件或明细中出现重复,不得写成重复风险关键词匹配。", "差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。", "申报目的地和明细发生地点属于申报行程城市集合。", "员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地。", @@ -167,7 +185,7 @@ def build_risk_rule_compiler_messages( "expense_category_label": expense_category_label, "natural_language": natural_language, "available_fields": available_fields, - "required_json_shape": schema, + "required_json_shape": response_schema, "examples": examples, }, ensure_ascii=False, diff --git a/server/src/app/services/risk_rule_generation_semantic_plan.py b/server/src/app/services/risk_rule_generation_semantic_plan.py new file mode 100644 index 0000000..a52c5ac --- /dev/null +++ b/server/src/app/services/risk_rule_generation_semantic_plan.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from app.services.risk_rule_generation_interpreter import build_dsl_from_semantic_plan + + +DSL_PAYLOAD_KEYS = ("dsl", "json_dsl", "rule_dsl", "rule") + + +def unwrap_semantic_plan_payload(payload: dict[str, Any]) -> dict[str, Any]: + """兼容旧版扁平 JSON 与新版 semantic_plan + DSL 包装结构。""" + + if not isinstance(payload, dict): + return {} + semantic_plan = payload.get("semantic_plan") + semantic_plan = semantic_plan if isinstance(semantic_plan, dict) else {} + dsl = next((payload.get(key) for key in DSL_PAYLOAD_KEYS if isinstance(payload.get(key), dict)), None) + if not isinstance(dsl, dict): + result = build_dsl_from_semantic_plan(semantic_plan) or deepcopy(payload) + if semantic_plan: + result["model_semantic_plan"] = semantic_plan + return result + + result = deepcopy(dsl) + if semantic_plan: + result["model_semantic_plan"] = semantic_plan + for key in ("name", "description", "flow", "risk_scoring_evidence", "unsupported_fields"): + if key not in result and key in payload: + result[key] = deepcopy(payload[key]) + return result diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index ddbfdf5..bd66b31 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -5,7 +5,9 @@ from datetime import date, datetime, timedelta from typing import Any from app.models.financial_record import ExpenseClaim +from app.services.risk_rule_execution_trace import build_risk_rule_execution_trace from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY +from app.services.risk_rule_value_compare import compare_numbers, duplicate_text_values, parse_number_value CITY_CONSISTENCY_SEMANTIC_TYPES = { "travel_city_consistency", @@ -14,6 +16,20 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = { class RiskRuleTemplateExecutor: + def evaluate_with_trace( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any]: + result = self.evaluate(manifest, claim=claim, contexts=contexts) + return { + "hit": result is not None, + "result": result, + "trace": build_risk_rule_execution_trace(manifest, result=result), + } + def evaluate( self, manifest: dict[str, Any], @@ -53,7 +69,7 @@ class RiskRuleTemplateExecutor: missing = [ field_key for field_key in required_fields - if not self._has_resolved_value(field_key, claim=claim, contexts=contexts) + if not self._resolve_values(field_key, claim=claim, contexts=contexts) ] if not missing: return None @@ -77,9 +93,10 @@ class RiskRuleTemplateExecutor: ) -> dict[str, Any] | None: conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else [] failures: list[dict[str, Any]] = [] - for condition in conditions: + for index, condition in enumerate(conditions, start=1): if not isinstance(condition, dict): continue + condition_id = str(condition.get("id") or f"condition_{index}").strip() left_key = str(condition.get("left") or "").strip() right_key = str(condition.get("right") or "").strip() operator = str(condition.get("operator") or "not_overlap").strip() @@ -90,6 +107,7 @@ class RiskRuleTemplateExecutor: failures.append( { "left": left_key, + "id": condition_id, "operator": operator, "right": right_key, "left_values": left_values[:5], @@ -253,6 +271,12 @@ class RiskRuleTemplateExecutor: ], "condition_summary": params.get("condition_summary"), "formula": params.get("formula"), + "condition_results": { + "city_evidence_present": bool(attachment_values and reference_values), + "destination_overlap": has_destination_overlap, + "unexpected_route_city": bool(unexpected_route_cities), + "reasonable_exception": bool(keyword_hits), + }, "city_consistency": { "attachment_values": attachment_values[:8], "reference_values": reference_values[:8], @@ -354,6 +378,17 @@ class RiskRuleTemplateExecutor: } if operator == "date_outside_range": return self._evaluate_date_outside_range(condition, claim=claim, contexts=contexts) + if operator == "numeric_compare": + return self._evaluate_numeric_compare(condition, claim=claim, contexts=contexts) + if operator == "duplicate_value": + values = [ + value + for key in fields + for value in self._resolve_values(key, claim=claim, contexts=contexts) + ] + duplicates = duplicate_text_values(values) + evidence = {"operator": operator, "fields": fields, "values": values[:8], "duplicates": duplicates[:8]} + return bool(duplicates), evidence if operator in {"not_contains_any", "contains_any"}: keywords = self._read_string_list(condition.get("keywords")) values = self._resolve_group_values(fields, claim=claim, contexts=contexts) @@ -419,6 +454,35 @@ class RiskRuleTemplateExecutor: "outside_dates": [item.isoformat() for item in outside], } + def _evaluate_numeric_compare( + self, + condition: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> tuple[bool, dict[str, Any]]: + left_fields = self._read_string_list(condition.get("left_fields") or condition.get("fields")) + right_fields = self._read_string_list(condition.get("right_fields")) + left_numbers = self._resolve_group_numbers(left_fields, claim=claim, contexts=contexts) + right_numbers = self._resolve_group_numbers(right_fields, claim=claim, contexts=contexts) + threshold = parse_number_value(condition.get("threshold") or condition.get("value")) + if threshold is not None: + right_numbers.append(threshold) + compare = str(condition.get("compare") or condition.get("comparator") or "gt").strip().lower() + passed = any( + compare_numbers(left, right, compare) + for left in left_numbers + for right in right_numbers + ) + return passed, { + "operator": "numeric_compare", + "compare": compare, + "left_fields": left_fields, + "right_fields": right_fields, + "left_values": left_numbers[:8], + "right_values": right_numbers[:8], + } + def _resolve_group_values( self, field_keys: list[str], @@ -442,7 +506,22 @@ class RiskRuleTemplateExecutor: for key in field_keys: for value in self._resolve_values(key, claim=claim, contexts=contexts): parsed = self._parse_date_value(value) - if parsed and parsed not in values: + if parsed and parsed not in values: + values.append(parsed) + return values + + def _resolve_group_numbers( + self, + field_keys: list[str], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> list[float]: + values: list[float] = [] + for key in field_keys: + for value in self._resolve_values(key, claim=claim, contexts=contexts): + parsed = parse_number_value(value) + if parsed is not None and parsed not in values: values.append(parsed) return values @@ -614,15 +693,6 @@ class RiskRuleTemplateExecutor: } return any(item in label for item in label_map.get(field_key, ())) - def _has_resolved_value( - self, - field_key: str, - *, - claim: ExpenseClaim, - contexts: list[dict[str, Any]], - ) -> bool: - return bool(self._resolve_values(field_key, claim=claim, contexts=contexts)) - @staticmethod def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None: item_dates = [ @@ -696,7 +766,7 @@ class RiskRuleTemplateExecutor: normalized.extend(RiskRuleTemplateExecutor._normalize_values(list(value))) continue text = re.sub(r"\s+", " ", str(value or "")).strip() - if text and text not in normalized: + if text: normalized.append(text) return normalized diff --git a/server/src/app/services/risk_rule_value_compare.py b/server/src/app/services/risk_rule_value_compare.py new file mode 100644 index 0000000..23378e4 --- /dev/null +++ b/server/src/app/services/risk_rule_value_compare.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import re +from typing import Any + + +def parse_number_value(value: Any) -> float | None: + if isinstance(value, (int, float)): + return float(value) + text = re.sub(r"[,,\s元¥¥]", "", str(value or "")) + match = re.search(r"-?\d+(?:\.\d+)?", text) + if not match: + return None + try: + return float(match.group(0)) + except ValueError: + return None + + +def compare_numbers(left: float, right: float, compare: str) -> bool: + if compare in {"gt", ">", "greater_than"}: + return left > right + if compare in {"gte", ">=", "greater_or_equal"}: + return left >= right + if compare in {"lt", "<", "less_than"}: + return left < right + if compare in {"lte", "<=", "less_or_equal"}: + return left <= right + if compare in {"eq", "=", "equals"}: + return left == right + return left > right + + +def duplicate_text_values(values: list[Any]) -> list[str]: + seen: set[str] = set() + duplicates: list[str] = [] + for value in values: + items = value if isinstance(value, (list, tuple, set)) else [value] + for item in items: + text = re.sub(r"\s+", "", str(item or "")).strip().lower() + if not text: + continue + if text in seen and text not in duplicates: + duplicates.append(text) + seen.add(text) + return duplicates diff --git a/server/src/app/services/runtime_chat.py b/server/src/app/services/runtime_chat.py index 5df0f38..52073aa 100644 --- a/server/src/app/services/runtime_chat.py +++ b/server/src/app/services/runtime_chat.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from http import HTTPStatus from time import monotonic, sleep from typing import Any @@ -27,6 +28,39 @@ DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS = 90 _slot_failure_until: dict[str, float] = {} +@dataclass(slots=True) +class RuntimeChatCallTrace: + slot: str + provider: str + model: str + attempt: int + status: str + duration_ms: int = 0 + error_message: str | None = None + skipped_reason: str | None = None + + def model_dump(self) -> dict[str, Any]: + return { + "slot": self.slot, + "provider": self.provider, + "model": self.model, + "attempt": self.attempt, + "status": self.status, + "duration_ms": self.duration_ms, + "error_message": self.error_message, + "skipped_reason": self.skipped_reason, + } + + +@dataclass(slots=True) +class RuntimeChatResult: + text: str | None + calls: list[RuntimeChatCallTrace] + + def calls_as_dicts(self) -> list[dict[str, Any]]: + return [item.model_dump() for item in self.calls] + + class RuntimeChatService: def __init__(self, db: Session) -> None: self.db = db @@ -43,11 +77,47 @@ class RuntimeChatService: slot_timeouts: dict[str, int] | None = None, max_attempts: int | None = None, ) -> str | None: - configs = [ - config - for slot in slot_priority - if (config := self._load_chat_slot(slot)) is not None - ] + return self.complete_with_trace( + messages, + slot_priority=slot_priority, + max_tokens=max_tokens, + temperature=temperature, + timeout_seconds=timeout_seconds, + slot_timeouts=slot_timeouts, + max_attempts=max_attempts, + ).text + + def complete_with_trace( + self, + messages: list[dict[str, Any]], + *, + slot_priority: tuple[str, ...] = ("main", "backup"), + max_tokens: int = 500, + temperature: float = 0.2, + timeout_seconds: int | None = None, + slot_timeouts: dict[str, int] | None = None, + max_attempts: int | None = None, + ) -> RuntimeChatResult: + configs: list[dict[str, str]] = [] + calls: list[RuntimeChatCallTrace] = [] + for slot in slot_priority: + config = self._load_chat_slot(slot) + if config is None: + calls.append( + RuntimeChatCallTrace( + slot=slot, + provider="", + model="", + attempt=0, + status="skipped", + skipped_reason="not_configured", + ) + ) + continue + configs.append(config) + if not configs: + return RuntimeChatResult(None, calls) + resolved_timeout_seconds = timeout_seconds or DEFAULT_RUNTIME_CHAT_TIMEOUT_SECONDS resolved_slot_timeouts = dict(slot_timeouts or {}) resolved_max_attempts = max_attempts or DEFAULT_RUNTIME_CHAT_RETRY_ATTEMPTS @@ -61,7 +131,18 @@ class RuntimeChatService: config["slot"], config["provider"], ) + calls.append( + RuntimeChatCallTrace( + slot=config["slot"], + provider=config["provider"], + model=config["model"], + attempt=attempt, + status="skipped", + skipped_reason="cooldown", + ) + ) continue + started = monotonic() try: response_text = self._request_chat_completion( config, @@ -73,13 +154,47 @@ class RuntimeChatService: resolved_timeout_seconds, ), ) + duration_ms = int((monotonic() - started) * 1000) if response_text: _slot_failure_until.pop(cache_key, None) - return response_text.strip() + calls.append( + RuntimeChatCallTrace( + slot=config["slot"], + provider=config["provider"], + model=config["model"], + attempt=attempt, + status="succeeded", + duration_ms=duration_ms, + ) + ) + return RuntimeChatResult(response_text.strip(), calls) + calls.append( + RuntimeChatCallTrace( + slot=config["slot"], + provider=config["provider"], + model=config["model"], + attempt=attempt, + status="empty", + duration_ms=duration_ms, + error_message="模型返回空内容。", + ) + ) except Exception as exc: + duration_ms = int((monotonic() - started) * 1000) _slot_failure_until[cache_key] = ( monotonic() + DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS ) + calls.append( + RuntimeChatCallTrace( + slot=config["slot"], + provider=config["provider"], + model=config["model"], + attempt=attempt, + status="failed", + duration_ms=duration_ms, + error_message=str(exc), + ) + ) logger.warning( "Runtime chat request failed slot=%s provider=%s attempt=%s/%s: %s", config["slot"], @@ -91,7 +206,7 @@ class RuntimeChatService: if attempt < resolved_max_attempts: sleep(DEFAULT_RUNTIME_CHAT_RETRY_DELAY_SECONDS) - return None + return RuntimeChatResult(None, calls) @staticmethod def _build_slot_cache_key(config: dict[str, str]) -> str: diff --git a/server/src/app/services/system_dashboard.py b/server/src/app/services/system_dashboard.py new file mode 100644 index 0000000..41506e5 --- /dev/null +++ b/server/src/app/services/system_dashboard.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +import json +from datetime import UTC, date, datetime, timedelta +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.db.base import Base +from app.models.agent_feedback import AgentOperationFeedback +from app.models.agent_run import AgentRun, AgentToolCall +from app.models.user_session_metric import UserSessionMetric +from app.schemas.system_dashboard import SystemDashboardRead + +SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"} +FAILED_STATUSES = {"failed", "failure", "error", "errored"} +BLOCKED_STATUSES = {"blocked", "forbidden", "rejected"} +RUNNING_STATUSES = {"running", "pending"} + +TOOL_BUCKETS = [ + { + "key": "preAudit", + "name": "报销预审", + "color": "var(--theme-primary)", + "keywords": ("claim", "expense", "reimbursement", "draft", "review"), + }, + { + "key": "policyQa", + "name": "政策问答", + "color": "var(--chart-blue)", + "keywords": ("knowledge", "policy", "rag", "wiki", "qa"), + }, + { + "key": "invoiceOcr", + "name": "票据识别", + "color": "var(--chart-amber)", + "keywords": ("ocr", "invoice", "receipt", "ticket"), + }, + { + "key": "ruleAudit", + "name": "规则审核", + "color": "var(--chart-purple)", + "keywords": ("rule", "risk", "audit", "guard"), + }, + { + "key": "employeeLookup", + "name": "员工查询", + "color": "var(--success)", + "keywords": ("employee", "profile", "organization", "department"), + }, + { + "key": "diagnosis", + "name": "异常诊断", + "color": "var(--danger)", + "keywords": ("diagnosis", "exception", "error", "fallback"), + }, +] + + +class SystemDashboardService: + def __init__(self, db: Session) -> None: + self.db = db + + def build_dashboard(self, *, days: int = 7) -> SystemDashboardRead: + window_days = max(1, min(int(days or 7), 30)) + self._ensure_storage_ready() + now = datetime.now(UTC) + start = now - timedelta(days=window_days - 1) + previous_start = start - timedelta(days=window_days) + labels = self._date_labels(start.date(), window_days) + + runs = self._fetch_runs(start) + previous_runs = self._fetch_runs(previous_start, before=start) + sessions = self._fetch_sessions(start) + feedback_items = self._fetch_feedback(start) + tool_calls = [tool for run in runs for tool in run.tool_calls] + previous_tool_calls = [tool for run in previous_runs for tool in run.tool_calls] + user_names = self._session_display_names(sessions) + + token_records = self._build_token_records(runs) + total_tokens = sum(item["total"] for item in token_records) + previous_tokens = sum(item["total"] for item in self._build_token_records(previous_runs)) + positive_feedback = sum(1 for item in feedback_items if int(item.rating or 0) >= 4) + negative_feedback = sum(1 for item in feedback_items if int(item.rating or 0) <= 3) + succeeded_runs = sum(1 for run in runs if self._is_success(run.status)) + failed_runs = sum(1 for run in runs if self._is_failed(run.status)) + active_sessions = [item for item in sessions if str(item.status or "") == "active"] + + return SystemDashboardRead( + window_days=window_days, + generated_at=now.isoformat(), + has_real_data=bool(runs or sessions or feedback_items), + totals={ + "toolCalls": len(tool_calls), + "modelTokens": total_tokens, + "onlineUsers": len(active_sessions), + "avgOnlineMinutes": self._average_session_minutes(sessions, now), + "executionSuccessRate": self._percent(succeeded_runs, len(runs)), + "positiveFeedback": positive_feedback, + "negativeFeedback": negative_feedback, + "failedRuns": failed_runs, + "toolCallsChange": self._change_percent(len(tool_calls), len(previous_tool_calls)), + "modelTokensChange": self._change_percent(total_tokens, previous_tokens), + }, + agent_daily_ratio=self._agent_daily_ratio(labels, tool_calls), + login_wave=self._login_wave(sessions), + token_daily_wave=self._token_daily_wave(labels, token_records), + user_token_usage=self._user_token_usage(token_records, user_names), + accuracy_comparison=self._accuracy_comparison(tool_calls), + usage_duration_summary=self._usage_duration_summary(sessions, now), + feedback_summary=self._feedback_summary(feedback_items, len(runs)), + tool_detail_rows=self._tool_detail_rows(tool_calls, token_records), + ) + + 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]: + stmt = ( + select(AgentRun) + .options(selectinload(AgentRun.tool_calls)) + .where(AgentRun.started_at >= start) + .order_by(AgentRun.started_at.asc()) + ) + if before is not None: + stmt = stmt.where(AgentRun.started_at < before) + return list(self.db.scalars(stmt).all()) + + def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]: + stmt = ( + select(UserSessionMetric) + .where(UserSessionMetric.login_at >= start) + .order_by(UserSessionMetric.login_at.asc()) + ) + return list(self.db.scalars(stmt).all()) + + def _fetch_feedback(self, start: datetime) -> list[AgentOperationFeedback]: + stmt = ( + select(AgentOperationFeedback) + .where(AgentOperationFeedback.created_at >= start) + .order_by(AgentOperationFeedback.created_at.asc()) + ) + return list(self.db.scalars(stmt).all()) + + def _agent_daily_ratio(self, labels: list[str], tool_calls: list[AgentToolCall]) -> 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: + label = self._date_label(tool.created_at) + if label not in label_index: + continue + key = self._tool_bucket(tool)["key"] + counts[key][label_index[label]] += 1 + + ratio_series: dict[str, list[int]] = {bucket["key"]: [] for bucket in TOOL_BUCKETS} + for index in range(len(labels)): + total = sum(counts[bucket["key"]][index] for bucket in TOOL_BUCKETS) + for bucket in TOOL_BUCKETS: + value = counts[bucket["key"]][index] + ratio_series[bucket["key"]].append(round((value / total) * 100) if total else 0) + + return { + "labels": labels, + "agents": [ + {"key": bucket["key"], "name": bucket["name"], "color": bucket["color"]} + for bucket in TOOL_BUCKETS + ], + "series": ratio_series, + } + + def _login_wave(self, sessions: list[UserSessionMetric]) -> dict[str, Any]: + labels = [f"{hour:02d}:00" for hour in range(8, 21)] + login_users = [0 for _ in labels] + interactions = [0 for _ in labels] + index = {label: idx for idx, label in enumerate(labels)} + for session in sessions: + hour = self._as_utc(session.login_at).hour + label = f"{hour:02d}:00" + if label not in index: + continue + login_users[index[label]] += 1 + interactions[index[label]] += max(0, int(session.activity_event_count or 0)) + return {"labels": labels, "loginUsers": login_users, "interactions": interactions} + + def _token_daily_wave(self, labels: list[str], records: list[dict[str, Any]]) -> dict[str, Any]: + input_tokens = [0 for _ in labels] + output_tokens = [0 for _ in labels] + total_tokens = [0 for _ in labels] + index = {label: idx for idx, label in enumerate(labels)} + for record in records: + label = record["date"] + if label not in index: + continue + position = index[label] + input_tokens[position] += record["input"] + output_tokens[position] += record["output"] + total_tokens[position] += record["total"] + return { + "labels": labels, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "totalTokens": total_tokens, + } + + def _user_token_usage( + self, + records: list[dict[str, Any]], + user_names: dict[str, str], + ) -> list[dict[str, Any]]: + totals: dict[str, int] = {} + for record in records: + user_id = str(record.get("user_id") or "unknown").strip() or "unknown" + totals[user_id] = totals.get(user_id, 0) + int(record["total"]) + colors = [ + "var(--theme-primary)", + "var(--chart-blue)", + "var(--chart-amber)", + "var(--chart-purple)", + "var(--success)", + "var(--danger)", + ] + rows = sorted(totals.items(), key=lambda item: item[1], reverse=True)[:6] + return [ + { + "name": user_names.get(user_id) or self._short_user_label(user_id), + "role": user_id if user_id != "unknown" else "未知用户", + "tokens": value, + "color": colors[index % len(colors)], + } + for index, (user_id, value) in enumerate(rows) + ] + + def _accuracy_comparison(self, tool_calls: list[AgentToolCall]) -> 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: + name = self._tool_bucket(tool)["name"] + if self._is_success(tool.status): + correct[name] += 1 + else: + wrong[name] += 1 + categories = [bucket["name"] for bucket in TOOL_BUCKETS] + return { + "categories": categories, + "correct": [correct[name] for name in categories], + "wrong": [wrong[name] for name in categories], + } + + def _usage_duration_summary( + self, + sessions: list[UserSessionMetric], + now: datetime, + ) -> dict[str, Any]: + durations = [self._session_duration_ms(item, now) for item in sessions] + durations.sort() + average_ms = int(sum(durations) / len(durations)) if durations else 0 + median_ms = durations[len(durations) // 2] if durations else 0 + peak_ms = max(durations) if durations else 0 + buckets = [ + {"label": "0-10 分钟", "value": 0, "color": "var(--chart-blue)"}, + {"label": "10-30 分钟", "value": 0, "color": "var(--theme-primary)"}, + {"label": "30-60 分钟", "value": 0, "color": "var(--chart-purple)"}, + {"label": "60 分钟以上", "value": 0, "color": "var(--chart-amber)"}, + ] + for value in durations: + minutes = value / 60000 + if minutes < 10: + buckets[0]["value"] += 1 + elif minutes < 30: + buckets[1]["value"] += 1 + elif minutes < 60: + buckets[2]["value"] += 1 + else: + buckets[3]["value"] += 1 + return { + "average": self._format_minutes(average_ms), + "median": self._format_minutes(median_ms), + "peak": self._format_minutes(peak_ms), + "trend": "实时", + "rows": buckets, + } + + def _feedback_summary( + self, + feedback_items: list[AgentOperationFeedback], + run_count: int, + ) -> list[dict[str, Any]]: + positive = sum(1 for item in feedback_items if int(item.rating or 0) >= 4) + negative = sum(1 for item in feedback_items if int(item.rating or 0) <= 3) + rate = self._percent(len(feedback_items), run_count) + return [ + {"label": "好评次数", "value": positive, "tone": "success", "icon": "mdi mdi-thumb-up-outline"}, + {"label": "差评次数", "value": negative, "tone": "danger", "icon": "mdi mdi-thumb-down-outline"}, + {"label": "反馈率", "value": f"{rate:.1f}%", "tone": "info", "icon": "mdi mdi-message-processing-outline"}, + ] + + def _tool_detail_rows( + self, + tool_calls: list[AgentToolCall], + records: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + token_by_tool = {str(record["tool_id"]): int(record["total"]) for record in records} + rows: list[dict[str, Any]] = [] + for bucket in TOOL_BUCKETS: + bucket_calls = [tool for tool in tool_calls if self._tool_bucket(tool)["key"] == bucket["key"]] + if not bucket_calls: + rows.append( + { + "name": bucket["name"], + "calls": 0, + "successRate": 0, + "avgLatency": "0.0s", + "tokens": 0, + "color": bucket["color"], + } + ) + continue + success = sum(1 for tool in bucket_calls if self._is_success(tool.status)) + avg_ms = sum(max(0, int(tool.duration_ms or 0)) for tool in bucket_calls) / len(bucket_calls) + tokens = sum(token_by_tool.get(str(tool.id), 0) for tool in bucket_calls) + rows.append( + { + "name": bucket["name"], + "calls": len(bucket_calls), + "successRate": round(self._percent(success, len(bucket_calls)), 1), + "avgLatency": f"{avg_ms / 1000:.1f}s", + "tokens": tokens, + "color": bucket["color"], + } + ) + return rows + + def _build_token_records(self, runs: list[AgentRun]) -> 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 + if total <= 0: + total = self._estimate_tool_tokens(tool) + input_tokens = int(total * 0.62) + output_tokens = total - input_tokens + records.append( + { + "tool_id": tool.id, + "user_id": run.user_id or "", + "date": self._date_label(tool.created_at or run.started_at), + "input": input_tokens, + "output": output_tokens, + "total": total, + } + ) + return records + + def _extract_tool_tokens(self, tool: AgentToolCall) -> tuple[int, int]: + payload = { + "request": tool.request_json or {}, + "response": tool.response_json or {}, + } + input_tokens = self._first_int(payload, ("input_tokens", "prompt_tokens")) + output_tokens = self._first_int(payload, ("output_tokens", "completion_tokens")) + total_tokens = self._first_int(payload, ("total_tokens", "tokens", "token_count")) + if total_tokens and not input_tokens and not output_tokens: + input_tokens = int(total_tokens * 0.62) + output_tokens = total_tokens - input_tokens + return input_tokens, output_tokens + + def _estimate_tool_tokens(self, tool: AgentToolCall) -> int: + payload = { + "request": tool.request_json, + "response": tool.response_json, + "error": tool.error_message, + } + text = json.dumps(payload, ensure_ascii=False, default=str) + return max(0, len(text) // 4) + + def _first_int(self, payload: Any, keys: tuple[str, ...]) -> int: + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, (int, float)) and value > 0: + return int(value) + for value in payload.values(): + found = self._first_int(value, keys) + if found: + return found + if isinstance(payload, list): + for value in payload: + found = self._first_int(value, keys) + if found: + return found + return 0 + + def _tool_bucket(self, tool: AgentToolCall) -> 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] + for bucket in TOOL_BUCKETS: + if any(keyword in text for keyword in bucket["keywords"]): + return bucket + return TOOL_BUCKETS[0] + + def _session_display_names(self, sessions: list[UserSessionMetric]) -> dict[str, str]: + names: dict[str, str] = {} + for item in sessions: + display_name = str(item.display_name or item.username or item.email or "").strip() + for key in {item.username, item.email, item.employee_no, item.display_name}: + normalized = str(key or "").strip() + if normalized and display_name: + names[normalized] = display_name + return names + + def _average_session_minutes(self, sessions: list[UserSessionMetric], now: datetime) -> float: + if not sessions: + return 0.0 + durations = [self._session_duration_ms(item, now) for item in sessions] + return round((sum(durations) / len(durations)) / 60000, 1) + + def _session_duration_ms(self, session: UserSessionMetric, now: datetime) -> int: + if int(session.duration_ms or 0) > 0: + return max(0, int(session.duration_ms or 0)) + login_at = self._as_utc(session.login_at) + end_at = self._as_utc(session.logout_at or session.last_activity_at or now) + try: + return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000)) + except TypeError: + return 0 + + @staticmethod + def _date_labels(start_date: date, days: int) -> list[str]: + return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)] + + @staticmethod + def _date_label(value: datetime | None) -> str: + if value is None: + return "" + return SystemDashboardService._as_utc(value).strftime("%m-%d") + + @staticmethod + def _format_minutes(duration_ms: int) -> str: + return f"{duration_ms / 60000:.1f} 分钟" + + @staticmethod + def _percent(value: int | float, total: int | float) -> float: + if not total: + return 0.0 + return round((float(value) / float(total)) * 100, 1) + + @staticmethod + def _change_percent(value: int | float, previous: int | float) -> float: + if not previous: + return 0.0 + return round(((float(value) - float(previous)) / float(previous)) * 100, 1) + + @staticmethod + def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + @staticmethod + def _is_success(status: str | None) -> bool: + return str(status or "").strip().lower() in SUCCESS_STATUSES + + @staticmethod + def _is_failed(status: str | None) -> bool: + return str(status or "").strip().lower() in FAILED_STATUSES + + @staticmethod + def _short_user_label(user_id: str) -> str: + normalized = str(user_id or "").strip() + if not normalized or normalized == "unknown": + return "未知用户" + return normalized.split("@", 1)[0] diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 326c152..3746ada 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from decimal import Decimal, InvalidOperation from sqlalchemy import select @@ -19,6 +19,7 @@ from app.services.document_numbering import ( build_document_number, generate_unique_expense_claim_no, ) +from app.services.user_agent_application_dates import expand_application_time_with_days from app.services.user_agent_application_locations import normalize_application_location APPLICATION_CONTEXT_VALUES = { @@ -160,11 +161,10 @@ class UserAgentApplicationMixin: manager_name = str(facts.get("manager_name") or "").strip() or "直属领导" return "\n\n".join( [ - f"当前操作已完成,单据已经推送给 {manager_name} 进行审核,请耐心等待。", + "申请单据已生成,并已进入审批流程。", + f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。", f"申请单号:{application_no}", - "申请信息:\n" + self._build_application_summary_table(facts), - f"当前状态:{manager_name}审核中。", - "费用预估:预计费用已随申请提交,等待领导审核确认。", + "下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。", ] ) @@ -217,6 +217,7 @@ class UserAgentApplicationMixin: facts["time"] = self._expand_application_time_with_days( facts.get("time", ""), facts.get("days", ""), + payload.context_json or {}, ) return facts @@ -467,81 +468,16 @@ class UserAgentApplicationMixin: return text @staticmethod - def _expand_application_time_with_days(time_text: str, days_text: str) -> str: - normalized_time = str(time_text or "").strip() - if not normalized_time or re.search(r"\s*(?:至|到|~|-{2,}|—)\s*", normalized_time): - return normalized_time - - days = UserAgentApplicationMixin._resolve_application_days_count(days_text) - if not days: - return normalized_time - - match = re.search( - r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", - normalized_time, + def _expand_application_time_with_days( + time_text: str, + days_text: str, + context_json: dict[str, object] | None = None, + ) -> str: + return expand_application_time_with_days( + time_text, + days_text, + context_json=context_json or {}, ) - if not match: - return normalized_time - - parsed_start = UserAgentApplicationMixin._parse_application_date(match.group("date")) - if parsed_start is None: - return normalized_time - - end_date = parsed_start + timedelta(days=days) - return f"{parsed_start:%Y-%m-%d} 至 {end_date:%Y-%m-%d}" - - @staticmethod - def _resolve_application_days_count(days_text: str) -> int: - text = str(days_text or "").strip() - if not text: - return 0 - digit_match = re.search(r"\d+", text) - if digit_match: - return max(0, int(digit_match.group(0))) - - chinese_match = re.search(r"[一二两三四五六七八九十]{1,3}", text) - if not chinese_match: - return 0 - return UserAgentApplicationMixin._parse_chinese_number(chinese_match.group(0)) - - @staticmethod - def _parse_chinese_number(value: str) -> int: - digits = { - "一": 1, - "二": 2, - "两": 2, - "三": 3, - "四": 4, - "五": 5, - "六": 6, - "七": 7, - "八": 8, - "九": 9, - } - text = str(value or "").strip() - if not text: - return 0 - if text == "十": - return 10 - if "十" in text: - left, _, right = text.partition("十") - tens = digits.get(left, 1) if left else 1 - ones = digits.get(right, 0) if right else 0 - return tens * 10 + ones - return digits.get(text, 0) - - @staticmethod - def _parse_application_date(value: str) -> datetime | None: - normalized = str(value or "").strip().rstrip("日").replace("年", "-").replace("月", "-") - normalized = normalized.replace("/", "-").replace(".", "-") - parts = [part for part in normalized.split("-") if part] - if len(parts) != 3: - return None - try: - year, month, day = (int(part) for part in parts) - return datetime(year, month, day) - except ValueError: - return None def _resolve_application_amount( self, diff --git a/server/src/app/services/user_agent_application_dates.py b/server/src/app/services/user_agent_application_dates.py new file mode 100644 index 0000000..3fdd62d --- /dev/null +++ b/server/src/app/services/user_agent_application_dates.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import re +from datetime import UTC, date, datetime, timedelta +from typing import Any + + +def expand_application_time_with_days( + time_text: str, + days_text: str, + *, + context_json: dict[str, Any] | None = None, +) -> str: + normalized_time = str(time_text or "").strip() + days = resolve_application_days_count(days_text) + if not days: + return normalized_time + + if normalized_time and re.search(r"\s*(?:至|到|~|-{2,}|—)\s*", normalized_time): + return normalized_time + + parsed_start = _resolve_start_date(normalized_time, context_json or {}) + if parsed_start is None: + return normalized_time + + end_date = parsed_start + timedelta(days=max(days - 1, 0)) + start_text = f"{parsed_start:%Y-%m-%d}" + end_text = f"{end_date:%Y-%m-%d}" + return start_text if start_text == end_text else f"{start_text} 至 {end_text}" + + +def resolve_application_days_count(days_text: str) -> int: + text = str(days_text or "").strip() + if not text: + return 0 + digit_match = re.search(r"\d+", text) + if digit_match: + return max(0, int(digit_match.group(0))) + + chinese_match = re.search(r"[一二两三四五六七八九十]{1,3}", text) + if not chinese_match: + return 0 + return _parse_chinese_number(chinese_match.group(0)) + + +def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None: + if time_text: + match = re.search( + r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", + time_text, + ) + if match: + return _parse_application_date(match.group("date")) + return None + return _resolve_client_today(context_json) + + +def _resolve_client_today(context_json: dict[str, Any]) -> date: + raw_now = str(context_json.get("client_now_iso") or "").strip() + parsed_now = _parse_client_now(raw_now) + if parsed_now is None: + return datetime.now(UTC).date() + + offset_minutes = _parse_timezone_offset_minutes( + context_json.get("client_timezone_offset_minutes"), + ) + if offset_minutes is not None: + parsed_now = parsed_now - timedelta(minutes=offset_minutes) + return parsed_now.date() + + +def _parse_client_now(value: str) -> datetime | None: + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _parse_timezone_offset_minutes(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _parse_chinese_number(value: str) -> int: + digits = { + "一": 1, + "二": 2, + "两": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + text = str(value or "").strip() + if not text: + return 0 + if text == "十": + return 10 + if "十" in text: + left, _, right = text.partition("十") + tens = digits.get(left, 1) if left else 1 + ones = digits.get(right, 0) if right else 0 + return tens * 10 + ones + return digits.get(text, 0) + + +def _parse_application_date(value: str) -> date | None: + normalized = str(value or "").strip().rstrip("日").replace("年", "-").replace("月", "-") + normalized = normalized.replace("/", "-").replace(".", "-") + parts = [part for part in normalized.split("-") if part] + if len(parts) != 3: + return None + try: + year, month, day = (int(part) for part in parts) + return date(year, month, day) + except ValueError: + return None diff --git a/server/src/app/services/user_session_metrics.py b/server/src/app/services/user_session_metrics.py new file mode 100644 index 0000000..ea83ff7 --- /dev/null +++ b/server/src/app/services/user_session_metrics.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.user_session_metric import UserSessionMetric + +MAX_SESSION_DURATION_MS = 24 * 60 * 60 * 1000 + + +class UserSessionMetricService: + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_storage_ready(self) -> None: + Base.metadata.create_all(bind=self.db.get_bind(), tables=[UserSessionMetric.__table__]) + + def start_session( + self, + user: Any, + *, + event: dict[str, Any] | None = None, + ) -> UserSessionMetric: + self.ensure_storage_ready() + now = datetime.now(UTC) + username = str(getattr(user, "username", "") or getattr(user, "email", "") or "").strip() + display_name = str(getattr(user, "name", "") or username).strip() + session = UserSessionMetric( + session_id=str(uuid.uuid4()), + username=username, + display_name=display_name, + employee_no=str(getattr(user, "employee_no", "") or "").strip(), + email=str(getattr(user, "email", "") or username).strip(), + is_admin=bool(getattr(user, "is_admin", False)), + login_at=now, + last_activity_at=now, + status="active", + event_json=event or {}, + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + + def finish_session( + self, + *, + session_id: str, + reason: str = "manual", + last_activity_at: datetime | None = None, + activity_event_count: int = 0, + event: dict[str, Any] | None = None, + ) -> UserSessionMetric | None: + self.ensure_storage_ready() + normalized_session_id = str(session_id or "").strip() + if not normalized_session_id: + return None + + session = self.db.scalars( + select(UserSessionMetric).where(UserSessionMetric.session_id == normalized_session_id) + ).first() + if session is None: + return None + + if session.status == "closed": + return session + + logout_at = datetime.now(UTC) + session.logout_at = logout_at + session.last_activity_at = self._normalize_last_activity(last_activity_at, session.login_at, logout_at) + session.duration_ms = self._duration_ms(session.login_at, logout_at) + session.activity_event_count = max(0, int(activity_event_count or 0)) + session.logout_reason = str(reason or "manual").strip()[:40] or "manual" + session.status = "closed" + session.event_json = { + **(session.event_json or {}), + "finish": event or {}, + } + self.db.commit() + self.db.refresh(session) + return session + + def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int: + self.ensure_storage_ready() + normalized = {str(item or "").strip() for item in identifiers if str(item or "").strip()} + if not normalized: + return 0 + + stmt = select(UserSessionMetric).where( + UserSessionMetric.status == "closed", + or_(UserSessionMetric.login_at >= cutoff, UserSessionMetric.logout_at >= cutoff), + or_( + UserSessionMetric.username.in_(normalized), + UserSessionMetric.email.in_(normalized), + UserSessionMetric.employee_no.in_(normalized), + UserSessionMetric.display_name.in_(normalized), + ), + ) + return sum(max(0, int(item.duration_ms or 0)) for item in self.db.scalars(stmt).all()) + + @staticmethod + def _duration_ms(login_at: datetime | None, logout_at: datetime) -> int: + if login_at is None: + return 0 + if login_at.tzinfo is None and logout_at.tzinfo is not None: + logout_at = logout_at.replace(tzinfo=None) + elif login_at.tzinfo is not None and logout_at.tzinfo is None: + logout_at = logout_at.replace(tzinfo=login_at.tzinfo) + try: + duration_ms = int((logout_at - login_at).total_seconds() * 1000) + except TypeError: + return 0 + return max(0, min(duration_ms, MAX_SESSION_DURATION_MS)) + + @staticmethod + def _normalize_last_activity( + value: datetime | None, + login_at: datetime | None, + logout_at: datetime, + ) -> datetime: + if value is None: + return logout_at + try: + if login_at is not None and value < login_at: + return login_at + if value > logout_at: + return logout_at + return value + except TypeError: + return logout_at diff --git a/server/src/app/skills/domain/employee-behavior-profile-scanner/SKILL.md b/server/src/app/skills/domain/employee-behavior-profile-scanner/SKILL.md new file mode 100644 index 0000000..bf82d0f --- /dev/null +++ b/server/src/app/skills/domain/employee-behavior-profile-scanner/SKILL.md @@ -0,0 +1,49 @@ +--- +name: employee-behavior-profile-scanner +description: 用于更新员工行为画像,把费用行为、材料完整性、审批效率和智能协作记录沉淀为可解释画像基线。 +--- + +# 员工行为画像巡检 + +## 技能类型 + +- 当前类型:评估 +- 类型范围:积累、升级、整理、评估 + +## 使用场景 + +当任务要求分析员工费用行为、更新画像快照、识别流程压力、沉淀风险基线或支撑风险图谱评估时,使用该能力。 + +## 工作目标 + +- 汇总员工在指定窗口内的费用强度、材料完整性、审批效率、异常补件和智能协作行为。 +- 生成员工、部门和费用类型维度的画像快照,支撑风险图谱中的基线偏离判断。 +- 输出可解释标签,说明画像变化来自哪些单据、审批、材料或运行记录。 +- 将画像结论限制在风控和流程治理场景,不作为单独的人事评价结论。 + +## 处理步骤 + +1. 确认画像窗口,包括起止时间、员工范围、部门范围和是否只处理增量数据。 +2. 读取费用单据、审批节点、材料完整性、智能协作运行记录和历史画像快照。 +3. 计算画像维度,包括费用强度、申请节奏、差旅招待占比、材料完整性、流程压力、审批效率和智能协作质量。 +4. 生成画像标签,保留分数、置信度、触发样本和解释原因。 +5. 写入画像快照,并把可用于风险图谱的基线偏离结果输出给风险观察链路。 + +## 输出要求 + +输出应包含: + +- `summary`:本次画像更新概况。 +- `profile_window`:画像统计窗口。 +- `profile_snapshots`:员工画像快照。 +- `radar_dimensions`:画像雷达维度与分数。 +- `behavior_tags`:画像标签、置信度和来源样本。 +- `risk_baseline_refs`:可供风险图谱引用的基线偏离结果。 + +## 执行约束 + +- 不生成不可解释的人事结论。 +- 不把单次异常直接固化为长期画像标签。 +- 不展示无关员工的敏感长期画像。 +- 不覆盖人工复核后的画像说明。 +- 对员工、部门、单据和审批节点必须保留来源标识。 diff --git a/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md b/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md index 01248e1..df94bb2 100644 --- a/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md +++ b/server/src/app/skills/domain/finance-policy-knowledge-organizer/SKILL.md @@ -9,6 +9,11 @@ description: 用于整理公司财务知识制度,把制度文件、报销口 当任务要求整理公司财务制度、报销政策、审批口径、票据要求、预算规范或知识库资料时,使用该能力。 +## 技能类型 + +- 当前类型:整理 +- 类型范围:积累、升级、整理、评估 + ## 工作目标 - 读取指定范围内的财务制度、知识库文档和变更材料。 @@ -33,6 +38,7 @@ description: 用于整理公司财务知识制度,把制度文件、报销口 - `categories`:制度主题和费用类型分类。 - `knowledge_items`:可复核的知识条目。 - `source_refs`:来源文件、章节或页码。 +- `risk_policy_refs`:可被风险观察引用的制度条款编号,例如 `policy.travel.preapproval_absent`。 - `open_questions`:需要管理员确认的问题。 - `next_actions`:后续维护建议。 diff --git a/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md b/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md new file mode 100644 index 0000000..cb1a604 --- /dev/null +++ b/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md @@ -0,0 +1,50 @@ +--- +name: financial-risk-graph-scanner +description: 用于财务风险图谱巡检,把单据、票据、审批链、员工画像和规则命中结果汇总成可复核的风险观察。 +--- + +# 财务风险图谱巡检 + +## 技能类型 + +- 当前类型:评估 +- 类型范围:积累、升级、整理、评估 + +## 使用场景 + +当任务要求巡检财务风险、扫描异常报销、解释风险图谱、生成风险观察或沉淀风险证据链时,使用该能力。 + +## 工作目标 + +- 扫描新增或待复核的报销单、票据、审批链、员工画像、费用类型和规则命中结果。 +- 按统一本体口径归一费用类型、风险信号、人员、部门、供应商、商户和票据主体。 +- 构建本次任务范围内的局部风险图谱,避免把无关历史关系混入当前结论。 +- 将规则命中、画像偏离、图谱异常和制度依据汇总为统一风险观察。 +- 输出可点击、可追溯、可复核的证据链,供单据详情、工作记录详情和风险看板使用。 + +## 处理步骤 + +1. 确认扫描窗口,包括单据状态、更新时间、费用类型、部门范围和是否只处理增量内容。 +2. 读取单据、票据、审批、规则、画像和制度索引,并标准化为图谱节点与白名单边。 +3. 计算风险信号,包括重复报销、发票异常、金额偏离、审批链异常、商户/供应商关联异常和制度口径冲突。 +4. 对每个风险信号生成证据链,保留来源记录、规则编号、本体映射、置信度和降级原因。 +5. 写入风险观察,更新单据风险标记,并在工作记录中输出处理数量、风险数量和失败原因。 + +## 输出要求 + +输出应包含: + +- `summary`:本次巡检概况。 +- `scan_scope`:扫描范围、时间窗口和筛选条件。 +- `risk_observations`:风险观察列表,包含风险类型、等级、置信度和证据。 +- `graph_evidence`:局部图谱节点、边、来源和本体映射。 +- `decision_trace`:规则命中、画像偏离、图谱评分和降级路径。 +- `next_actions`:需要人工复核、补充制度或转候选规则的建议。 + +## 执行约束 + +- 不绕过规则中心发布状态,不自行创建正式风险规则。 +- 不把低置信度本体解析结果升级为强拦截,只能生成候选观察。 +- 不展示全量历史图谱,只输出本次任务相关的局部证据。 +- 不覆盖管理员手动复核结论。 +- 对金额、人员、供应商、票据号码和审批意见等关键字段必须保留来源。 diff --git a/server/src/app/skills/domain/risk-rule-discovery/SKILL.md b/server/src/app/skills/domain/risk-rule-discovery/SKILL.md new file mode 100644 index 0000000..c508b13 --- /dev/null +++ b/server/src/app/skills/domain/risk-rule-discovery/SKILL.md @@ -0,0 +1,34 @@ +--- +name: risk-rule-discovery +description: 用于根据风险观察、人工反馈和回放评测结果生成候选风险规则,不直接上线。 +--- + +# 风险规则候选发现 + +## 技能类型 + +- 当前类型:升级 +- 类型范围:积累、升级、整理、评估 + +## 工作目标 + +- 读取风险观察、人工反馈、误报复盘和算法回放结果。 +- 识别可以沉淀为规则候选的稳定风险模式。 +- 输出候选规则,不直接上线,不修改正式规则中心。 +- 每条候选规则必须包含证据、来源、置信度和待复核状态。 + +## 输出要求 + +- `candidate_rules`:候选规则列表。 +- `evidence`:关联风险观察、反馈、单据和制度引用。 +- `source`:候选来源,例如 `risk_observation_feedback`。 +- `confidence_score`:候选置信度。 +- `status`:固定为 `candidate_review` 或同等待复核状态。 +- `auto_publish`:必须为 `false`。 + +## 执行约束 + +- 不直接发布规则。 +- 不删除或覆盖正式规则。 +- 没有证据来源的候选不得输出。 +- 低置信度候选只能进入人工复核队列。 diff --git a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf differ diff --git a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf.meta.json b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf.meta.json new file mode 100644 index 0000000..9c75808 --- /dev/null +++ b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf.meta.json @@ -0,0 +1,88 @@ +{ + "file_name": "2月20_武汉-上海.pdf", + "storage_key": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-05-30T07:00:40.483034+00:00", + "previewable": true, + "preview_kind": "image", + "preview_storage_key": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png", + "preview_media_type": "image/png", + "preview_file_name": "2月20_武汉-上海.preview.png", + "analysis": { + "severity": "pass", + "label": "AI提示符合条件", + "headline": "AI提示:附件符合基础校验条件", + "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", + "points": [ + "票据类型:已识别为火车/高铁票。", + "附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。", + "金额字段:已识别到与当前明细接近的金额 354.00 元。" + ], + "rule_basis": [], + "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" + }, + "document_info": { + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + } + ] + }, + "requirement_check": { + "matches": true, + "current_expense_type": "train_ticket", + "current_expense_type_label": "火车票", + "allowed_scene_labels": [], + "allowed_document_type_labels": [], + "recognized_scene_code": "travel", + "recognized_scene_label": "差旅票据", + "recognized_document_type": "train_ticket", + "recognized_document_type_label": "火车/高铁票", + "mismatch_severity": "high", + "rule_code": "rule.expense.scene_submission_standard", + "rule_name": "报销场景提交与附件标准", + "message": "当前费用项目为火车票,已识别为火车/高铁票。" + }, + "ocr_status": "recognized", + "ocr_error": "", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "ocr_summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "ocr_warnings": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/meta.json new file mode 100644 index 0000000..687e687 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/meta.json @@ -0,0 +1,66 @@ +{ + "id": "29ec7c4c-abc0-46f0-8eae-3136c2d6fef7", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-05-30T07:00:12.286631+00:00", + "status": "unlinked", + "linked_claim_id": "", + "linked_claim_no": "", + "linked_item_id": "", + "linked_at": "", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/29ec7c4c-abc0-46f0-8eae-3136c2d6fef7/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/meta.json new file mode 100644 index 0000000..470182b --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/meta.json @@ -0,0 +1,66 @@ +{ + "id": "2fd4856f-e918-4d29-a0b2-340cc1fdec03", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-05-30T07:00:40.560540+00:00", + "status": "linked", + "linked_claim_id": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f", + "linked_claim_no": "RE-20260530065944-M94FAPB9", + "linked_item_id": "329f477a-d926-4101-8ec8-4c8a95150f22", + "linked_at": "2026-05-30T07:00:40.560540+00:00", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/2fd4856f-e918-4d29-a0b2-340cc1fdec03/preview.png differ diff --git a/server/tests/test_agent_feedback_service.py b/server/tests/test_agent_feedback_service.py new file mode 100644 index 0000000..3dbf9cc --- /dev/null +++ b/server/tests/test_agent_feedback_service.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.schemas.agent_feedback import AgentFeedbackCreate +from app.services.agent_feedback import AgentFeedbackService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_agent_feedback_service_records_rating_and_low_reason() -> None: + with build_session() as db: + service = AgentFeedbackService(db) + + feedback = service.create_feedback( + AgentFeedbackCreate( + run_id="run-feedback-001", + conversation_id="conv-feedback-001", + user_id="wenjing.li", + agent="user_agent", + source="user_message", + session_type="application", + operation_type="submit_application", + operation_status="succeeded", + rating=2, + reason="意图识别不准", + context_json={"route_reason": "model_route"}, + ) + ) + summary = service.summarize_feedback(agent="user_agent", session_type="application") + + assert feedback.feedback_id.startswith("fb_") + assert feedback.rating == 2 + assert feedback.reason == "意图识别不准" + assert summary.total_feedback == 1 + assert summary.average_rating == 2.0 + assert summary.low_rating_count == 1 + assert summary.rating_distribution["2"] == 1 + assert summary.recent_low_feedback[0]["run_id"] == "run-feedback-001" + + +def test_agent_feedback_summary_keeps_five_star_distribution() -> None: + with build_session() as db: + service = AgentFeedbackService(db) + for rating in (5, 4, 5): + service.create_feedback( + AgentFeedbackCreate( + run_id=f"run-rating-{rating}", + user_id="wenjing.li", + agent="user_agent", + session_type="expense", + operation_status="succeeded", + rating=rating, + ) + ) + + summary = service.summarize_feedback(session_type="expense") + + assert summary.total_feedback == 3 + assert summary.average_rating == 4.67 + assert summary.low_rating_count == 0 + assert summary.rating_distribution == { + "1": 0, + "2": 0, + "3": 0, + "4": 1, + "5": 2, + } diff --git a/server/tests/test_agent_runs_service.py b/server/tests/test_agent_runs_service.py index fd4e542..857035e 100644 --- a/server/tests/test_agent_runs_service.py +++ b/server/tests/test_agent_runs_service.py @@ -104,3 +104,43 @@ def test_agent_run_service_updates_existing_tool_call() -> None: assert len(fetched.tool_calls) == 1 assert fetched.tool_calls[0].status == "succeeded" assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"} + + +def test_agent_run_service_summarizes_model_and_tool_failures() -> None: + with build_session() as db: + service = AgentRunService(db) + run = service.create_run( + agent=AgentName.ORCHESTRATOR.value, + source=AgentRunSource.USER_MESSAGE.value, + status=AgentRunStatus.SUCCEEDED.value, + ontology_json={ + "parse_strategy": "rule_fallback", + "model_invocation_summary": { + "model_guardrail_reason": "model_conflicts_with_application_stage_signal" + }, + }, + ) + service.record_tool_call( + run_id=run.run_id, + tool_type=AgentToolType.LLM.value, + tool_name="semantic_ontology.main", + request_json={"stage": "semantic_parse"}, + response_json={"model_guardrail_reason": "model_conflicts_with_application_stage_signal"}, + status="failed", + duration_ms=18, + error_message="model_conflicts_with_application_stage_signal", + ) + + stats = service.summarize_runs(agent=AgentName.ORCHESTRATOR.value, limit=20) + + assert stats.total_runs >= 1 + assert stats.tool_call_count >= 1 + assert stats.failed_tool_call_count >= 1 + assert stats.llm_call_count >= 1 + assert stats.failed_llm_call_count >= 1 + assert stats.model_fallback_count >= 1 + assert stats.model_guardrail_count >= 1 + assert any( + item.get("tool_name") == "semantic_ontology.main" + for item in stats.recent_errors + ) diff --git a/server/tests/test_employee_behavior_profile_service.py b/server/tests/test_employee_behavior_profile_service.py index a6f6085..8005173 100644 --- a/server/tests/test_employee_behavior_profile_service.py +++ b/server/tests/test_employee_behavior_profile_service.py @@ -17,6 +17,7 @@ from app.models.employee import Employee from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit +from app.models.user_session_metric import UserSessionMetric from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService from app.services.hermes_scheduler import HermesScheduler @@ -167,6 +168,36 @@ def _build_claim( ) +def add_closed_user_session( + db: Session, + *, + session_id: str, + username: str, + display_name: str = "", + employee_no: str = "", + duration_ms: int = 30 * 60 * 1000, +) -> None: + logout_at = datetime.now(UTC) - timedelta(minutes=1) + login_at = logout_at - timedelta(milliseconds=duration_ms) + db.add( + UserSessionMetric( + session_id=session_id, + username=username, + display_name=display_name or username, + employee_no=employee_no, + email=username if "@" in username else "", + login_at=login_at, + logout_at=logout_at, + last_activity_at=logout_at, + duration_ms=duration_ms, + activity_event_count=8, + logout_reason="manual", + status="closed", + ) + ) + db.commit() + + def test_service_scans_snapshots_and_filters_approval_scene() -> None: session_factory = build_session_factory() with session_factory() as db: @@ -238,6 +269,18 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None: session_factory = build_session_factory() with session_factory() as db: seed_profile_data(db) + EmployeeBehaviorProfileService(db).refresh_employee_profiles( + employee_id="emp-main", + window_days=(90,), + expense_type_scope="overall", + ) + add_closed_user_session( + db, + session_id="session-employee-current", + username="zhangsan@example.com", + display_name="张三", + employee_no="E1001", + ) app = create_app() @@ -266,6 +309,9 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None: assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"} ai_profile = next(item for item in payload["profiles"] if item["profile_type"] == "ai_usage") assert ai_profile["metrics"]["ai_run_duration_ms"] == 120 + assert ai_profile["metrics"]["online_duration_ms"] == 30 * 60 * 1000 + assert ai_profile["metrics"]["usage_duration_ms"] == 30 * 60 * 1000 + assert ai_profile["metrics"]["usage_duration_mode"] == "online_session" assert payload["profile_tags"] assert payload["radar"]["dimensions"] @@ -336,6 +382,98 @@ def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None: assert payload["radar"]["dimensions"] +def test_current_admin_profile_endpoint_uses_online_session_without_agent_runs() -> None: + session_factory = build_session_factory() + with session_factory() as db: + add_closed_user_session( + db, + session_id="session-admin-online", + username="admin", + display_name="admin", + duration_ms=12 * 60 * 1000, + ) + + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + client = TestClient(app) + response = client.get( + "/api/v1/employee-profiles/me/latest", + params={ + "scene": "operations", + "window_days": 90, + "expense_type_scope": "overall", + }, + headers={"x-auth-username": "admin", "x-auth-name": "admin", "x-auth-is-admin": "true"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["employee_id"] == "admin" + assert payload["empty_reason"] == "" + metrics = payload["profiles"][0]["metrics"] + assert metrics["ai_run_count"] == 0 + assert metrics["online_duration_ms"] == 12 * 60 * 1000 + assert metrics["usage_duration_ms"] == 12 * 60 * 1000 + assert metrics["usage_duration_mode"] == "online_session" + + +def test_finish_session_endpoint_closes_active_session() -> None: + session_factory = build_session_factory() + login_at = datetime.now(UTC) - timedelta(minutes=9) + with session_factory() as db: + db.add( + UserSessionMetric( + session_id="session-active-finish", + username="zhangsan@example.com", + display_name="张三", + employee_no="E1001", + email="zhangsan@example.com", + login_at=login_at, + last_activity_at=login_at, + status="active", + ) + ) + db.commit() + + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + client = TestClient(app) + response = client.post( + "/api/v1/auth/sessions/session-active-finish/finish", + json={ + "reason": "manual", + "lastActivityAt": datetime.now(UTC).isoformat(), + "activityEventCount": 5, + "pagePath": "/workbench", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["sessionId"] == "session-active-finish" + assert payload["durationMs"] > 0 + with session_factory() as db: + session = db.query(UserSessionMetric).filter_by(session_id="session-active-finish").one() + assert session.status == "closed" + assert session.activity_event_count == 5 + + def test_hermes_scheduler_parses_weekly_profile_cron() -> None: scheduler = HermesScheduler() diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index df3b4df..692ffe5 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -2489,7 +2489,7 @@ def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> No assert claims[0].claim_no == "EXP-DUP-001" -def test_list_claims_allows_finance_to_view_all_records() -> None: +def test_list_claims_limits_finance_to_personal_records() -> None: current_user = CurrentUserContext( username="finance@example.com", name="财务", @@ -2501,8 +2501,8 @@ def test_list_claims_allows_finance_to_view_all_records() -> None: db.add_all( [ ExpenseClaim( - claim_no="EXP-FIN-101", - employee_name="甲", + claim_no="EXP-FIN-OWN", + employee_name="财务", department_name="A部", project_code="PRJ-A", expense_type="travel", @@ -2518,7 +2518,7 @@ def test_list_claims_allows_finance_to_view_all_records() -> None: risk_flags_json=[], ), ExpenseClaim( - claim_no="EXP-FIN-102", + claim_no="EXP-FIN-OTHER-DRAFT", employee_name="乙", department_name="B部", project_code="PRJ-B", @@ -2529,9 +2529,9 @@ def test_list_claims_allows_finance_to_view_all_records() -> None: currency="CNY", invoice_count=1, occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC), - submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC), - status="approved", - approval_stage="completed", + submitted_at=None, + status="draft", + approval_stage="待提交", risk_flags_json=[], ), ] @@ -2541,10 +2541,10 @@ def test_list_claims_allows_finance_to_view_all_records() -> None: claims = ExpenseClaimService(db).list_claims(current_user) assert len(claims) == 1 - assert claims[0].claim_no == "EXP-FIN-101" + assert claims[0].claim_no == "EXP-FIN-OWN" -def test_list_claims_allows_executive_to_view_all_records() -> None: +def test_list_claims_limits_executive_to_personal_records() -> None: current_user = CurrentUserContext( username="executive@example.com", name="高管", @@ -2556,8 +2556,8 @@ def test_list_claims_allows_executive_to_view_all_records() -> None: db.add_all( [ ExpenseClaim( - claim_no="EXP-EXE-101", - employee_name="甲", + claim_no="EXP-EXE-OWN", + employee_name="高管", department_name="A部", project_code="PRJ-A", expense_type="travel", @@ -2573,7 +2573,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None: risk_flags_json=[], ), ExpenseClaim( - claim_no="EXP-EXE-102", + claim_no="EXP-EXE-OTHER-DRAFT", employee_name="乙", department_name="B部", project_code="PRJ-B", @@ -2584,9 +2584,9 @@ def test_list_claims_allows_executive_to_view_all_records() -> None: currency="CNY", invoice_count=1, occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC), - submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC), - status="approved", - approval_stage="completed", + submitted_at=None, + status="draft", + approval_stage="待提交", risk_flags_json=[], ), ] @@ -2596,7 +2596,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None: claims = ExpenseClaimService(db).list_claims(current_user) assert len(claims) == 1 - assert claims[0].claim_no == "EXP-EXE-101" + assert claims[0].claim_no == "EXP-EXE-OWN" def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None: @@ -3411,7 +3411,20 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), status="submitted", approval_stage="直属领导审批", - risk_flags_json=[], + risk_flags_json=[ + { + "source": "application_detail", + "application_detail": { + "application_type": "差旅费用申请", + "time": "2026-05-25 至 2026-05-27", + "location": "上海", + "reason": "支撑国网服务器上线部署", + "days": "3 天", + "transport_mode": "高铁", + "amount": "12000.00", + }, + } + ], ) db.add(claim) db.commit() @@ -3475,6 +3488,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg and flag.get("source") == "application_handoff" and flag.get("event_type") == "expense_application_to_reimbursement_draft" and flag.get("application_claim_no") == "APP-20260525-APPROVE" + and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海" + and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署" + and flag.get("application_detail", {}).get("application_days") == "3 天" + and flag.get("application_detail", {}).get("application_transport_mode") == "高铁" and flag.get("leader_opinion") == "业务必要,同意申请。" and flag.get("budget_opinion") == "预算额度可承接,同意。" for flag in generated_draft.risk_flags_json @@ -3493,6 +3510,114 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg ) +def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None: + manager_user = CurrentUserContext( + username="manager-budget-monitor-application@example.com", + name="李预算经理", + role_codes=["manager", "budget_monitor", "executive"], + is_admin=False, + ) + + with build_session() as db: + budget_role = _seed_budget_monitor_role(db) + department = OrganizationUnit( + unit_code="DELIVERY-BUDGET-MERGED", + name="交付部", + unit_type="department", + ) + manager = Employee( + employee_no="E8112-MERGED", + name="李预算经理", + email="manager-budget-monitor-application@example.com", + grade="P8", + organization_unit=department, + roles=[budget_role], + ) + employee = Employee( + employee_no="E8113-MERGED", + name="张三", + email="zhangsan-budget-monitor-application@example.com", + manager=manager, + organization_unit=department, + ) + db.add_all([department, manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="APP-20260525-MERGED", + employee_id=employee.id, + employee_name="张三", + department_id=department.id, + department_name="交付部", + project_code="PRJ-A", + expense_type="travel_application", + reason="支撑国网服务器上线部署", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + approved = ExpenseClaimService(db).approve_claim( + claim_id, + manager_user, + opinion="业务必要且预算可承接,同意申请。", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == "审批完成" + assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 + assert not any( + isinstance(flag, dict) + and flag.get("next_approval_stage") == "预算管理者审批" + for flag in approved.risk_flags_json + ) + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("event_type") == "expense_application_approval" + and flag.get("label") == "领导及预算审核通过" + 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("budget_approval_merged") is True + and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_monitor" + for flag in approved.risk_flags_json + ) + generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() + assert generated_draft.status == "draft" + assert generated_draft.expense_type == "travel" + reviewer_claims = ExpenseClaimService(db).list_claims(manager_user) + assert all(claim.claim_no != generated_draft.claim_no for claim in reviewer_claims) + applicant_claims = ExpenseClaimService(db).list_claims( + CurrentUserContext( + username="zhangsan-budget-monitor-application@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + ) + assert any(claim.claim_no == generated_draft.claim_no for claim in applicant_claims) + assert any( + isinstance(flag, dict) + and flag.get("source") == "application_handoff" + and flag.get("event_type") == "expense_application_to_reimbursement_draft" + and flag.get("application_claim_no") == "APP-20260525-MERGED" + and flag.get("leader_opinion") == "业务必要且预算可承接,同意申请。" + and flag.get("budget_opinion") == "业务必要且预算可承接,同意申请。" + for flag in generated_draft.risk_flags_json + ) + + def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None: manager_user = CurrentUserContext( username="manager-application-return@example.com", diff --git a/server/tests/test_finance_dashboard_service.py b/server/tests/test_finance_dashboard_service.py new file mode 100644 index 0000000..8502e80 --- /dev/null +++ b/server/tests/test_finance_dashboard_service.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.budget import BudgetAllocation, BudgetTransaction +from app.models.financial_record import ExpenseClaim +from app.models.risk_observation import RiskObservation +from app.services.finance_dashboard import FinanceDashboardService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> None: + now = datetime.now(UTC) + + with build_session() as db: + db.add_all( + [ + ExpenseClaim( + claim_no="CLM-DASH-001", + employee_name="陈雨晴", + department_name="财务部", + expense_type="travel", + reason="项目差旅", + location="广州", + amount=Decimal("1200.00"), + invoice_count=2, + occurred_at=now - timedelta(hours=4), + submitted_at=now - timedelta(hours=3), + status="submitted", + approval_stage="finance_review", + risk_flags_json=[], + hermes_risk_flag=False, + created_at=now - timedelta(hours=4), + updated_at=now - timedelta(hours=3), + ), + ExpenseClaim( + claim_no="CLM-DASH-002", + employee_name="顾成宇", + department_name="研发中心", + expense_type="meal", + reason="客户招待", + location="深圳", + amount=Decimal("800.00"), + invoice_count=1, + occurred_at=now - timedelta(days=1, hours=2), + submitted_at=now - timedelta(days=1, hours=1), + status="paid", + approval_stage="payment", + risk_flags_json=[{"label": "招待费超标"}], + hermes_risk_flag=False, + created_at=now - timedelta(days=1, hours=2), + updated_at=now - timedelta(days=1), + ), + ExpenseClaim( + claim_no="CLM-DASH-003", + employee_name="李文静", + department_name="行政部", + expense_type="office", + reason="办公用品", + location="珠海", + amount=Decimal("5000.00"), + invoice_count=3, + occurred_at=now - timedelta(hours=1), + submitted_at=None, + status="draft", + approval_stage=None, + risk_flags_json=[], + hermes_risk_flag=False, + created_at=now - timedelta(hours=1), + updated_at=now - timedelta(hours=1), + ), + ] + ) + db.add( + RiskObservation( + observation_key="risk-dashboard-001", + subject_type="expense_claim", + subject_key="CLM-DASH-002", + subject_label="CLM-DASH-002", + claim_no="CLM-DASH-002", + risk_type="policy", + risk_signal="amount_outlier", + title="金额异常", + risk_level="high", + status="pending_review", + created_at=now - timedelta(hours=2), + updated_at=now - timedelta(hours=2), + ) + ) + allocation = BudgetAllocation( + budget_no="BUD-DASH-001", + fiscal_year=now.year, + period_type="year", + period_key=f"{now.year}", + department_name="财务部", + subject_code="travel", + subject_name="差旅费", + original_amount=Decimal("10000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="warn", + ) + db.add(allocation) + db.flush() + db.add( + BudgetTransaction( + transaction_no="BTX-DASH-001", + allocation_id=allocation.id, + source_type="expense_claim", + source_id="CLM-DASH-002", + source_no="CLM-DASH-002", + transaction_type="consume", + amount=Decimal("4000.00"), + before_available_amount=Decimal("10000.00"), + after_available_amount=Decimal("6000.00"), + operator="finance", + reason="测试消耗", + created_at=now - timedelta(hours=1), + ) + ) + db.commit() + + dashboard = FinanceDashboardService(db).build_dashboard( + range_key="近10日", + trend_range="近7天", + department_range="本月", + ) + + assert dashboard.has_real_data is True + assert dashboard.totals["pendingCount"] == 1 + assert dashboard.totals["pendingAmount"] == 1200.0 + assert dashboard.totals["riskCount"] == 1 + assert dashboard.trend["applications"][-1] >= 1 + assert dashboard.spend_by_category[0]["value"] == 1200.0 + assert dashboard.department_ranking[0]["name"] == "财务部" + assert dashboard.department_ranking[0]["amount"] == 1200.0 + assert dashboard.budget_summary["ratio"] == 40.0 + assert dashboard.budget_summary["used"] == "¥4,000" diff --git a/server/tests/test_financial_risk_graph_algorithm.py b/server/tests/test_financial_risk_graph_algorithm.py new file mode 100644 index 0000000..1907471 --- /dev/null +++ b/server/tests/test_financial_risk_graph_algorithm.py @@ -0,0 +1,818 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.algorithem.risk_graph import ( + RiskGraphClaimItemSnapshot, + RiskGraphClaimSnapshot, + RiskGraphEvaluationContext, + RiskHistoryStats, + evaluate_financial_risk_graph, + map_ontology_to_risk_graph, +) +from app.algorithem.risk_graph.anomaly_models import AnomalyPoint, MultiModelAnomalyDetector +from app.algorithem.risk_graph.control_effect import ControlEffectAnalyzer +from app.algorithem.risk_graph.counterfactual import CounterfactualRiskAdvisor +from app.algorithem.risk_graph.engine import _apply_evidence_source_gate +from app.algorithem.risk_graph.entity_resolution import ( + CanonicalEntityRegistry, + FinancialEntityResolver, +) +from app.algorithem.risk_graph.evaluation_cases import default_risk_evaluation_cases +from app.algorithem.risk_graph.features import HeterogeneousRiskGraphFeatureBuilder +from app.algorithem.risk_graph.lineage import RiskDataLineageBuilder +from app.algorithem.risk_graph.models import RiskEvidence, RiskGraphEdge +from app.algorithem.risk_graph.policy_knowledge_contract import ( + PolicyKnowledgeItem, + PolicyKnowledgeOrganizingReport, + PolicySourceRef, + build_policy_ref, +) +from app.algorithem.risk_graph.process_mining import ( + ConformanceRiskDetector, + ObjectCentricProcessMiner, +) +from app.algorithem.risk_graph.rule_discovery import CandidateRiskRuleDiscovery +from app.algorithem.risk_graph.temporal import TemporalRiskGraphMonitor +from app.db.base import Base +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog +from app.models.hermes_report import HermesRiskReport +from app.models.risk_observation import RiskObservation +from app.schemas.risk_observation import RiskObservationFeedbackCreate +from app.services.hermes_risk_scanner import HermesRiskScannerService +from app.services.risk_observations import RiskObservationService + + +def test_risk_graph_engine_combines_rule_anomaly_graph_policy_and_history() -> None: + target = _snapshot( + "c-risk", + "BX-001", + amount="12000", + risk_flags=[{"risk_signal": "preapproval_absent", "severity": "high"}], + items=[_item("i-risk", "hotel", "12000", invoice_id="INV-001")], + ) + duplicate = _snapshot( + "c-dup", + "BX-002", + amount="900", + employee_name="李四", + items=[_item("i-dup", "hotel", "900", invoice_id="INV-001")], + ) + peers = [ + _snapshot( + f"c-peer-{index}", + f"BX-10{index}", + amount=str(amount), + employee_name=f"同事{index}", + ) + for index, amount in enumerate([700, 800, 900, 1000], start=1) + ] + + result = evaluate_financial_risk_graph( + RiskGraphEvaluationContext( + claims=[target, duplicate, *peers], + target_claim_ids={"c-risk"}, + history_stats=[ + RiskHistoryStats( + risk_signal="duplicate_invoice", + expense_type="travel", + similar_case_count=10, + confirmed_count=7, + false_positive_count=1, + returned_count=2, + ) + ], + ) + ) + + assert len(result.observations) == 1 + observation = result.observations[0] + assert observation.risk_signal == "duplicate_invoice" + assert observation.risk_level == "high" + assert observation.risk_score >= 80 + assert observation.automation_mode == "semi_auto_review" + assert observation.contribution_scores["S_rule"] == 82 + assert observation.contribution_scores["S_anomaly"] >= 90 + assert observation.contribution_scores["S_graph"] >= 95 + assert observation.contribution_scores["S_policy"] > 0 + assert observation.contribution_scores["S_history"] > 0 + evidence_sources = {item.source for item in observation.evidence} + assert len(evidence_sources) >= 2 + assert observation.decision_trace["raw_risk_score"] >= observation.risk_score + assert observation.decision_trace["evidence_source_count"] >= 2 + assert observation.decision_trace["evidence_source_gate"] == "passed" + assert observation.decision_trace["algorithm_version"] == "financial_risk_graph.v1" + assert observation.decision_trace["decision_row"] == "high:70<=score<90" + assert observation.decision_trace["feature_contributions_json"][0]["feature"] == "S_rule" + assert observation.decision_trace["explanation_template_key"] == "risk.duplicate_invoice.high" + assert observation.decision_trace["sampling_strategy"]["strategy"] == "focused_review" + assert observation.decision_trace["sampling_strategy"]["replay_bucket"] == "high_risk" + assert "c-dup" in observation.similar_case_claim_ids or any( + "c-dup" in key["target_key"] for key in observation.graph_edge_keys + ) + node_payloads = [node.as_dict() for node in result.nodes] + assert all("canonical_id" in item for item in node_payloads) + assert all("ontology_parse_id" in item for item in node_payloads) + assert all("ontology_version" in item for item in node_payloads) + edge_payloads = [edge.as_dict() for edge in result.edges] + assert all(item["source"] for item in edge_payloads) + + +def test_high_risk_score_is_capped_when_only_one_evidence_source() -> None: + score, gate = _apply_evidence_source_gate( + 92, + [ + RiskEvidence( + code="rule_signal", + title="Rule signal", + detail="Only one evidence source is present.", + source="rule", + score=92, + ) + ], + ) + + assert score == 69 + assert gate == "capped_high_risk_single_source" + + +def test_heterogeneous_graph_feature_builder_outputs_core_features() -> None: + result = evaluate_financial_risk_graph( + RiskGraphEvaluationContext( + claims=[ + _snapshot( + "c-risk", + "BX-001", + amount="12000", + items=[_item("i-risk", "hotel", "12000", invoice_id="INV-001")], + ), + _snapshot( + "c-dup", + "BX-002", + amount="900", + employee_name="李四", + items=[_item("i-dup", "hotel", "900", invoice_id="INV-001")], + ), + ], + target_claim_ids={"c-risk"}, + ) + ) + + features = HeterogeneousRiskGraphFeatureBuilder().build( + result.nodes, + result.edges, + risk_node_keys={"claim:c-risk"}, + ) + + assert features.node_type_counts["claim"] == 2 + assert features.edge_type_counts["claim_duplicate_invoice"] >= 1 + assert features.meta_path_counts + assert features.clusters[0]["size"] >= 2 + assert features.neighbor_risk_density["claim:c-dup"] > 0 + + +def test_temporal_risk_graph_monitor_detects_edge_changes() -> None: + previous_edges = [ + RiskGraphEdge( + source_key="employee:e1", + target_key="claim:c1", + edge_type="employee_submits_claim", + ), + ] + current_edges = [ + RiskGraphEdge( + source_key="employee:e1", + target_key=f"claim:c{index}", + edge_type="employee_submits_claim", + ) + for index in range(2, 5) + ] + + diff = TemporalRiskGraphMonitor().monitor( + previous_edges, + current_edges, + risk_node_keys={"claim:c2"}, + ) + change_types = {item.change_type for item in diff.changes} + + assert "relationship_added" in change_types + assert "relationship_removed" in change_types + assert "relationship_surge" in change_types + assert "target_migration" in change_types + assert "risk_propagation" in change_types + assert diff.edge_type_delta["employee_submits_claim"] == 2 + + +def test_financial_entity_resolver_and_registry_merge_aliases() -> None: + resolver = FinancialEntityResolver() + registry = CanonicalEntityRegistry() + + first = resolver.resolve("supplier", " 上海 差旅-供应商 ", source="invoice") + second = resolver.resolve("merchant", "上海差旅供应商", source="receipt") + + assert first is not None + assert second is not None + assert first.canonical_id == second.canonical_id + saved = registry.upsert(first) + saved = registry.upsert(second) + confirmed = registry.confirm(saved.canonical_id, actor="auditor") + + assert len(registry.all()) == 1 + assert confirmed is not None + assert confirmed.confirmed_by == "auditor" + assert set(confirmed.aliases) == {"上海 差旅-供应商", "上海差旅供应商"} + + +def test_multi_model_anomaly_detector_combines_deterministic_signals() -> None: + points = [ + AnomalyPoint( + key=f"peer-{index}", + amount=Decimal(str(amount)), + occurred_at=occurred_at, + segment="travel", + ) + for index, (amount, occurred_at) in enumerate( + [ + (800, datetime(2026, 5, 4, tzinfo=UTC)), + (820, datetime(2026, 5, 11, tzinfo=UTC)), + (790, datetime(2026, 5, 12, tzinfo=UTC)), + (810, datetime(2026, 5, 13, tzinfo=UTC)), + (830, datetime(2026, 5, 14, tzinfo=UTC)), + ], + start=1, + ) + ] + points.append( + AnomalyPoint( + key="target", + amount=Decimal("3200"), + occurred_at=datetime(2026, 5, 18, tzinfo=UTC), + segment="travel", + ) + ) + + signals = MultiModelAnomalyDetector().detect(points, target_key="target") + methods = {item.method for item in signals} + + assert "robust_statistics" in methods + assert "isolation_forest_proxy" in methods + assert "local_outlier_factor_proxy" in methods + assert "temporal_jump" in methods + assert "periodic_deviation" in methods + assert max(item.score for item in signals) >= 90 + + +def test_object_centric_process_miner_builds_replayable_events() -> None: + claim = _snapshot( + "c-process", + "BX-PROCESS", + amount="1200", + risk_flags=[{"risk_signal": "preapproval_absent"}], + items=[_item("i-process", "hotel", "1200", invoice_id="INV-PROCESS")], + ) + + events = ObjectCentricProcessMiner().build_from_claims([claim]) + event_types = {item.event_type for item in events} + invoice_event = next(item for item in events if item.event_type == "invoice_attached") + + assert {"expense_occurred", "claim_submitted", "expense_item_recorded"} <= event_types + assert "invoice_attached" in event_types + assert "risk_flagged" in event_types + assert invoice_event.object_refs["claim"] == ["c-process"] + assert invoice_event.object_refs["invoice"] == ["INV-PROCESS"] + + +def test_conformance_risk_detector_finds_process_violations() -> None: + rows = [ + _event_row("e-payment", "payment_completed", "2026-05-01T09:00:00+00:00", "c-flow"), + _event_row("e-submit-1", "claim_submitted", "2026-05-02T09:00:00+00:00", "c-flow"), + _event_row("e-approve", "approval_approved", "2026-05-03T09:00:00+00:00", "c-flow"), + _event_row("e-return-1", "claim_returned", "2026-05-04T09:00:00+00:00", "c-flow"), + _event_row("e-submit-2", "claim_submitted", "2026-05-05T09:00:00+00:00", "c-flow"), + _event_row("e-return-2", "claim_returned", "2026-05-06T09:00:00+00:00", "c-flow"), + _event_row("e-approval-only", "approval_approved", "2026-05-01T09:00:00+00:00", "c-bypass"), + _event_row("e-invoice-only", "invoice_attached", "2026-05-01T09:00:00+00:00", "c-invoice"), + ] + + events = ObjectCentricProcessMiner().build_from_dicts(rows) + risks = ConformanceRiskDetector().detect(events) + risk_codes = {item.risk_code for item in risks} + + assert "payment_before_approval" in risk_codes + assert "rework_loop" in risk_codes + assert "approval_bypass" in risk_codes + assert "process_bypass" in risk_codes + + +def test_risk_data_lineage_builder_collects_source_assets() -> None: + lineage = RiskDataLineageBuilder().build_from_observation( + { + "observation_key": "risk:c1:duplicate_invoice", + "claim_id": "c1", + "run_id": "agent-run-1", + "algorithm_version": "financial_risk_graph.v1", + "ontology_json": {"ontology_version": "ontology.v1"}, + "evidence": [ + { + "source": "ocr", + "metadata": { + "document_id": "doc-1", + "ocr_job_id": "ocr-1", + "tool_call_id": "tool-1", + }, + }, + { + "source": "rule_center", + "metadata": {"rule_version": "rule.v2"}, + }, + ], + "decision_trace": { + "evidence_source_gate": "passed", + "data_quality_gate": "capped_missing_required_fields", + "sampling_strategy": {"strategy": "uncertainty_sample"}, + }, + }, + source_event_ids=["event-1"], + ) + + assert {"risk_observations", "expense_claims", "expense_claim_items"} <= set( + lineage.data_tables + ) + assert lineage.document_ids == ["doc-1"] + assert lineage.ocr_job_ids == ["ocr-1"] + assert lineage.agent_run_ids == ["agent-run-1"] + assert lineage.tool_call_ids == ["tool-1"] + assert lineage.rule_versions == ["rule.v2"] + assert lineage.ontology_version == "ontology.v1" + assert lineage.algorithm_version == "financial_risk_graph.v1" + assert lineage.source_event_ids == ["event-1"] + assert lineage.quality_gates == ["capped_missing_required_fields", "uncertainty_sample"] + + +def test_policy_knowledge_organizing_report_exposes_risk_policy_refs() -> None: + source = PolicySourceRef( + source_id="doc-travel-policy", + title="差旅报销风险管控制度", + location="第三章", + ) + report = PolicyKnowledgeOrganizingReport( + summary="整理差旅预审批制度。", + categories=["差旅", "事前申请"], + knowledge_items=[ + PolicyKnowledgeItem( + policy_ref=build_policy_ref("travel", "preapproval_absent"), + title="差旅事前申请", + summary="差旅报销需保留事前审批依据。", + expense_type="travel", + control_stage="reimbursement", + trigger_conditions=["preapproval_absent"], + source_refs=[source], + review_status="confirmed", + ) + ], + source_refs=[source], + ) + + payload = report.as_dict() + assert payload["risk_policy_refs"] == ["policy.travel.preapproval_absent"] + assert payload["knowledge_items"][0]["source_refs"][0]["source_id"] == "doc-travel-policy" + + +def test_counterfactual_risk_advisor_returns_actionable_reductions() -> None: + actions = CounterfactualRiskAdvisor().advise( + { + "contribution_scores": {"S_rule": 82, "S_anomaly": 90, "S_graph": 95}, + "evidence": [{"code": "duplicate_invoice_graph"}], + "decision_trace": {"data_quality_gate": "capped_missing_required_fields"}, + } + ) + action_keys = {item.action_key for item in actions} + + assert "complete_preapproval_or_required_attachment" in action_keys + assert "align_amount_with_peer_baseline" in action_keys + assert "replace_duplicate_or_conflicting_invoice" in action_keys + assert "supplement_missing_risk_data" in action_keys + assert all(item.expected_score_delta < 0 for item in actions) + + +def test_candidate_risk_rule_discovery_outputs_review_only_candidates() -> None: + candidates = CandidateRiskRuleDiscovery().discover_from_feedback( + observations=[ + { + "observation_key": "risk:c1:duplicate_invoice", + "risk_signal": "duplicate_invoice", + "confidence_score": 0.82, + "evidence": [{"code": "duplicate_invoice_graph", "source": "graph"}], + } + ], + feedback_items=[ + { + "observation_key": "risk:c1:duplicate_invoice", + "feedback_type": "comment", + "action": "rewrite", + "decision": "candidate_rule_rewrite", + "candidate_rule_source": "risk_observation_feedback", + "confidence_score": 0.77, + "comment": "建议沉淀重复票据候选规则。", + } + ], + ) + + assert len(candidates) == 1 + candidate = candidates[0] + assert candidate.rule_code == "candidate.risk.duplicate_invoice" + assert candidate.status == "candidate_review" + assert candidate.source == "risk_observation_feedback" + assert candidate.confidence_score == 0.77 + assert any(item["source"] == "graph" for item in candidate.evidence) + assert any(item["source"] == "risk_observation_feedback" for item in candidate.evidence) + + +def test_control_effect_analyzer_compares_before_and_after_windows() -> None: + summary = ControlEffectAnalyzer().compare( + before=[ + {"risk_score": 90, "risk_level": "critical", "feedback_status": "false_positive"}, + {"risk_score": 80, "risk_level": "high", "feedback_status": "confirmed"}, + ], + after=[ + {"risk_score": 62, "risk_level": "medium", "feedback_status": "confirmed"}, + {"risk_score": 55, "risk_level": "medium", "feedback_status": "confirmed"}, + ], + ) + + assert summary.before_count == 2 + assert summary.after_count == 2 + assert summary.average_score_delta < 0 + assert summary.high_rate_delta < 0 + assert summary.confirmation_rate_delta > 0 + assert summary.false_positive_rate_delta < 0 + + +def test_risk_data_quality_gate_caps_strong_conclusion_for_low_quality_claim() -> None: + target = _snapshot( + "c-low-quality", + "BX-005", + amount="12000", + employee_name="", + risk_flags=[{"risk_signal": "preapproval_absent", "severity": "critical"}], + items=[_item("i-low-quality", "hotel", "900", invoice_id="INV-LOW")], + ) + peers = [ + _snapshot( + f"c-peer-quality-{index}", + f"BX-30{index}", + amount=str(amount), + employee_name=f"同事{index}", + ) + for index, amount in enumerate([700, 800, 900, 1000], start=1) + ] + + result = evaluate_financial_risk_graph( + RiskGraphEvaluationContext( + claims=[target, *peers], + target_claim_ids={"c-low-quality"}, + ) + ) + + assert len(result.observations) == 1 + observation = result.observations[0] + assert observation.decision_trace["data_quality_gate"] == "capped_missing_required_fields" + assert observation.decision_trace["data_quality"]["passed"] is False + assert "employee" in observation.decision_trace["data_quality"]["missing_fields"] + assert observation.decision_trace["sampling_strategy"]["strategy"] == "uncertainty_sample" + assert observation.decision_trace["sampling_strategy"]["replay_bucket"] == "data_quality_gate" + assert "score_capped_by_gate" in observation.decision_trace["uncertainty_reasons_json"] + assert "data_quality_gate_not_passed" in observation.decision_trace["uncertainty_reasons_json"] + assert observation.risk_score == 69 + assert observation.risk_level == "medium" + + +def test_default_risk_evaluation_cases_cover_required_categories() -> None: + cases = default_risk_evaluation_cases() + categories = {item.category for item in cases} + + assert { + "positive", + "negative", + "counterfactual", + "noise", + "historical_false_positive", + } <= categories + assert all(item.case_id and item.description for item in cases) + + +def test_risk_graph_engine_avoids_false_risk_when_baseline_and_signals_are_missing() -> None: + result = evaluate_financial_risk_graph( + RiskGraphEvaluationContext( + claims=[_snapshot("c-clean", "BX-003", amount="300")], + target_claim_ids={"c-clean"}, + ) + ) + + assert result.observations == [] + assert any(node.key == "claim:c-clean" for node in result.nodes) + + +def test_risk_graph_engine_detects_multi_evidence_and_spatiotemporal_mismatch() -> None: + target = _snapshot( + "c-mismatch", + "BX-004", + amount="8000", + invoice_count=2, + items=[ + _item( + "i-mismatch", + "hotel", + "900", + invoice_id="INV-MISMATCH", + item_location="北京", + item_date=date(2026, 4, 1), + ) + ], + ) + peers = [ + _snapshot( + f"c-peer-mismatch-{index}", + f"BX-20{index}", + amount=str(amount), + employee_name=f"同事{index}", + ) + for index, amount in enumerate([700, 800, 900, 1000], start=1) + ] + + result = evaluate_financial_risk_graph( + RiskGraphEvaluationContext( + claims=[target, *peers], + target_claim_ids={"c-mismatch"}, + ) + ) + + assert len(result.observations) == 1 + observation = result.observations[0] + evidence_codes = {item.code for item in observation.evidence} + evidence_sources = {item.source for item in observation.evidence} + assert "document_amount_mismatch" in evidence_codes + assert "invoice_count_mismatch" in evidence_codes + assert "date_outside_claim_window" in evidence_codes + assert "location_mismatch_graph" in evidence_codes + assert {"multi_evidence", "spatiotemporal"} <= evidence_sources + assert observation.risk_signal in {"date_outside_trip", "document_expense_mismatch"} + + +def test_ontology_mapping_normalizes_signals_and_uses_confidence_gate() -> None: + mapping = map_ontology_to_risk_graph( + { + "run_id": "run-ontology-1", + "scenario": "expense", + "intent": "risk_check", + "confidence": 0.49, + "entities": [ + { + "type": "employee", + "value": "张三", + "normalized_value": "E001", + "role": "target", + "confidence": 0.8, + }, + { + "type": "expense_type", + "value": "差旅费", + "normalized_value": "travel", + "role": "filter", + "confidence": 0.9, + }, + ], + "constraints": [{"field": "amount", "operator": ">", "value": 5000}], + "risk_flags": ["city_mismatch"], + }, + ontology_version="ontology.test", + ) + + assert mapping.gate == "candidate_only" + assert mapping.canonical_subject_key == "employee:e001" + assert [item.code for item in mapping.risk_signals] == ["location_mismatch"] + node_payloads = [node.as_dict() for node in mapping.nodes] + assert all(item["canonical_id"] for item in node_payloads) + assert {item["ontology_parse_id"] for item in node_payloads} == {"run-ontology-1"} + assert {item["ontology_version"] for item in node_payloads} == {"ontology.test"} + assert {edge.edge_type for edge in mapping.edges} <= { + "ontology_extracts", + "ontology_constrains", + "ontology_signals", + } + assert {edge.as_dict()["source"] for edge in mapping.edges} == {"ontology"} + + +def test_hermes_risk_scanner_persists_algorithm_reports() -> None: + with _build_session() as db: + config = HermesTaskConfig( + task_type="global_risk_scan", + cron_expression="0 0 * * *", + is_enabled=True, + ) + db.add(config) + db.flush() + log = HermesTaskExecutionLog(config_id=config.id, status="running") + db.add(log) + + target = _claim_orm( + "c-risk", + "BX-001", + amount=Decimal("12000"), + risk_flags=[{"risk_signal": "preapproval_absent", "severity": "high"}], + ) + target.items.append( + _claim_item_orm("item-risk", "c-risk", Decimal("12000"), invoice_id="INV-001") + ) + duplicate = _claim_orm("c-dup", "BX-002", amount=Decimal("900"), employee_name="李四") + duplicate.items.append( + _claim_item_orm("item-dup", "c-dup", Decimal("900"), invoice_id="INV-001") + ) + peers = [ + _claim_orm( + f"c-peer-{index}", + f"BX-10{index}", + amount=Decimal(str(amount)), + employee_name=f"同事{index}", + ) + for index, amount in enumerate([700, 800, 900, 1000], start=1) + ] + historical = _claim_orm("c-history", "BX-HIST", amount=Decimal("1000")) + historical.status = "approved" + db.add_all([target, duplicate, *peers, historical]) + db.flush() + + observation_service = RiskObservationService(db) + observation_service.upsert_observation( + { + "observation_key": "risk:c-history:duplicate_invoice", + "subject_type": "expense_claim", + "subject_key": "claim:c-history", + "subject_label": "BX-HIST", + "claim_id": "c-history", + "claim_no": "BX-HIST", + "risk_type": "duplicate_invoice", + "risk_signal": "duplicate_invoice", + "title": "Historical duplicate invoice risk", + "description": "Confirmed historical duplicate invoice risk.", + "risk_score": 82, + "risk_level": "high", + "source": "financial_risk_graph", + "algorithm_version": "financial_risk_graph.test", + "contribution_scores": {"S_rule": 82}, + } + ) + observation_service.create_feedback( + "risk:c-history:duplicate_invoice", + RiskObservationFeedbackCreate(feedback_type="confirm", actor="auditor"), + ) + + summary = HermesRiskScannerService(db).scan_global_risks(log_id=log.id) + reports = list(db.scalars(select(HermesRiskReport)).all()) + observations = list(db.scalars(select(RiskObservation)).all()) + target_observation = next(item for item in observations if item.claim_id == "c-risk") + refreshed_target = db.get(ExpenseClaim, "c-risk") + + assert summary["risk_observation_count"] >= 1 + assert any(report.risk_type == "duplicate_invoice" for report in reports) + assert any(item.risk_signal == "duplicate_invoice" for item in observations) + assert target_observation.execution_log_id == log.id + assert target_observation.contribution_scores_json.get("S_history", 0) > 0 + assert refreshed_target is not None + assert refreshed_target.hermes_risk_flag is True + assert any( + isinstance(flag, dict) and flag.get("source") == "financial_risk_graph" + for flag in refreshed_target.risk_flags_json + ) + + +def _snapshot( + claim_id: str, + claim_no: str, + *, + amount: str, + employee_name: str = "张三", + department_name: str = "销售部", + employee_grade: str = "P7", + expense_type: str = "travel", + location: str = "上海", + invoice_count: int = 0, + occurred_at: datetime | None = None, + submitted_at: datetime | None = None, + risk_flags: list | None = None, + items: list[RiskGraphClaimItemSnapshot] | None = None, +) -> RiskGraphClaimSnapshot: + occurred = occurred_at or datetime(2026, 5, 20, tzinfo=UTC) + return RiskGraphClaimSnapshot( + claim_id=claim_id, + claim_no=claim_no, + employee_id=employee_name, + employee_name=employee_name, + department_id=department_name, + department_name=department_name, + employee_grade=employee_grade, + expense_type=expense_type, + amount=Decimal(amount), + invoice_count=invoice_count, + occurred_at=occurred, + submitted_at=submitted_at or occurred + timedelta(hours=1), + status="submitted", + location=location, + risk_flags=risk_flags or [], + items=items or [], + ) + + +def _item( + item_id: str, + item_type: str, + amount: str, + *, + invoice_id: str | None = None, + item_location: str = "上海", + item_date: date | None = None, +) -> RiskGraphClaimItemSnapshot: + return RiskGraphClaimItemSnapshot( + item_id=item_id, + item_type=item_type, + item_amount=Decimal(amount), + item_location=item_location, + item_date=item_date or date(2026, 5, 20), + invoice_id=invoice_id, + ) + + +def _event_row(event_id: str, event_type: str, occurred_at: str, claim_id: str) -> dict: + return { + "event_id": event_id, + "event_type": event_type, + "occurred_at": occurred_at, + "object_refs": {"claim": [claim_id]}, + "source": "test", + } + + +def _build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def _claim_orm( + claim_id: str, + claim_no: str, + *, + amount: Decimal, + employee_name: str = "张三", + risk_flags: list | None = None, +) -> ExpenseClaim: + now = datetime(2026, 5, 20, tzinfo=UTC) + return ExpenseClaim( + id=claim_id, + claim_no=claim_no, + employee_id=employee_name, + employee_name=employee_name, + department_id="sales", + department_name="销售部", + expense_type="travel", + reason="客户拜访", + location="上海", + amount=amount, + currency="CNY", + invoice_count=1, + occurred_at=now, + submitted_at=now + timedelta(hours=1), + status="submitted", + approval_stage="manager_review", + risk_flags_json=risk_flags or [], + ) + + +def _claim_item_orm( + item_id: str, + claim_id: str, + amount: Decimal, + *, + invoice_id: str, +) -> ExpenseClaimItem: + return ExpenseClaimItem( + id=item_id, + claim_id=claim_id, + item_date=date(2026, 5, 20), + item_type="hotel", + item_reason="客户拜访住宿", + item_location="上海", + item_amount=amount, + invoice_id=invoice_id, + ) diff --git a/server/tests/test_hermes_employee_profile_baselines.py b/server/tests/test_hermes_employee_profile_baselines.py new file mode 100644 index 0000000..92cfa76 --- /dev/null +++ b/server/tests/test_hermes_employee_profile_baselines.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.employee import Employee +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.organization import OrganizationUnit +from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService + + +def test_hermes_employee_profile_scan_returns_profile_baseline_summary() -> None: + session_factory = _build_session_factory() + with session_factory() as db: + _seed_scan_data(db) + + summary = HermesEmployeeProfileScannerService(db).scan_employee_profiles(log_id=None) + + assert summary["target_employee_count"] == 3 + assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 12 + baseline_summary = summary["baseline_summary"] + assert baseline_summary["dimension_counts"]["employee"] == 3 + assert baseline_summary["dimension_counts"]["department"] == 1 + assert baseline_summary["dimension_counts"]["supplier"] == 2 + assert baseline_summary["dimension_counts"]["expense_type"] == 2 + assert any( + bucket["dimension"] == "supplier" and bucket["key"] == "s-hotel" + for bucket in baseline_summary["buckets"] + ) + + +def _build_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def _seed_scan_data(db: Session) -> None: + org = OrganizationUnit( + id="dept-sales", + unit_code="SALES", + name="市场部", + unit_type="department", + ) + employees = [ + Employee( + id=f"emp-{index}", + employee_no=f"E10{index}", + name=f"员工{index}", + email=f"emp{index}@example.com", + position="客户经理", + grade="P5", + organization_unit=org, + ) + for index in range(1, 4) + ] + db.add(org) + db.add_all(employees) + now = datetime.now(UTC) + claims = [ + _claim("c1", employees[0], "travel", "600", "s-hotel", "Hotel A", now), + _claim("c2", employees[1], "travel", "900", "s-hotel", "Hotel A", now), + _claim("c3", employees[2], "meal", "300", "s-meal", "Meal B", now), + ] + db.add_all(claims) + db.commit() + + +def _claim( + claim_id: str, + employee: Employee, + expense_type: str, + amount: str, + supplier_id: str, + supplier_name: str, + now: datetime, +) -> ExpenseClaim: + return ExpenseClaim( + id=claim_id, + claim_no=f"EXP-{claim_id}", + employee_id=employee.id, + employee_name=employee.name, + department_id="dept-sales", + department_name="市场部", + project_code="PRJ-001", + expense_type=expense_type, + reason="客户拜访", + location="北京", + amount=Decimal(amount), + currency="CNY", + invoice_count=1, + occurred_at=now - timedelta(days=5), + submitted_at=now - timedelta(days=5), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[ + { + "supplier_id": supplier_id, + "supplier_name": supplier_name, + } + ], + items=[ + ExpenseClaimItem( + id=f"item-{claim_id}", + claim_id=claim_id, + item_date=date.today(), + item_type=expense_type, + item_reason="客户拜访", + item_location="北京", + item_amount=Decimal(amount), + invoice_id=f"invoice-{claim_id}", + ) + ], + ) diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index 57c00d7..e1bc3e4 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -8,10 +8,12 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus from app.api.deps import get_db from app.db.base import Base from app.schemas.ontology import OntologyParseRequest from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService +from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatResult def build_session_factory() -> sessionmaker[Session]: @@ -283,6 +285,61 @@ def test_semantic_ontology_service_extracts_budget_query_fields() -> None: assert {"available_amount", "reserved_amount"}.issubset(metric_names) +@pytest.mark.parametrize( + "query", + [ + "申请出差", + "申请差旅", + "去国网出差3天,协助仿生产环境部署", + "去北京出差3天,支撑国网仿生产环境部署", + "下周去上海出差支撑客户系统上线,预计3天", + "安排去深圳客户现场验收项目,出差两天", + "准备去国网现场做仿生产环境部署,差旅3天", + ], +) +def test_semantic_ontology_service_treats_apply_for_travel_as_expense_application(query: str) -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest", + ) + ) + + entity_map = {item.type: item.normalized_value for item in result.entities} + entity_types = {item.type for item in result.entities} + + assert result.scenario == "expense" + assert result.intent == "draft" + assert result.permission.level == "draft_write" + assert entity_map["document_type"] == "expense_application" + assert entity_map["workflow_stage"] == "pre_approval" + assert entity_map["expense_type"] == "travel" + assert "employee" not in entity_types + assert "amount" in result.missing_slots + assert "time_range" in result.missing_slots + + +def test_semantic_ontology_service_keeps_explicit_travel_reimbursement_as_reimbursement_draft() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我要报销去北京出差的费用", + user_id="pytest", + ) + ) + + entity_map = {item.type: item.normalized_value for item in result.entities} + + assert result.scenario == "expense" + assert result.intent == "draft" + assert entity_map["expense_type"] == "travel" + assert "document_type" not in entity_map + assert "workflow_stage" not in entity_map + + def test_semantic_ontology_service_extracts_budget_edit_fields() -> None: session_factory = build_session_factory() with session_factory() as db: @@ -438,20 +495,24 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session session_factory = build_session_factory() with session_factory() as db: service = SemanticOntologyService(db) - monkeypatch.setattr( - service, - "_parse_with_model", - lambda **kwargs: LlmOntologyParseResult( - scenario="expense", - intent="draft", - confidence=0.91, - clarification_required=True, - clarification_question="请补充招待对象和票据附件。", - missing_slots=["participants", "attachments"], - ambiguity=[], - entity_hints=[], - ), - ) + monkeypatch.setattr( + service, + "_parse_with_model", + lambda **kwargs: ( + LlmOntologyParseResult( + scenario="expense", + intent="draft", + confidence=0.91, + clarification_required=True, + clarification_question="请补充招待对象和票据附件。", + missing_slots=["participants", "attachments"], + ambiguity=[], + entity_hints=[], + ), + [], + None, + ), + ) result = service.parse( OntologyParseRequest( @@ -809,20 +870,33 @@ def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) session_factory = build_session_factory() with session_factory() as db: service = SemanticOntologyService(db) - monkeypatch.setattr( - service, - "_parse_with_model", - lambda **kwargs: LlmOntologyParseResult( - scenario="expense", - intent="draft", - confidence=0.91, - clarification_required=True, - clarification_question="请补充费用类型、金额和票据附件。", - missing_slots=["expense_type", "amount", "attachments"], - ambiguity=[], - entity_hints=[], - ), - ) + monkeypatch.setattr( + service, + "_parse_with_model", + lambda **kwargs: ( + LlmOntologyParseResult( + scenario="expense", + intent="draft", + confidence=0.91, + clarification_required=True, + clarification_question="请补充费用类型、金额和票据附件。", + missing_slots=["expense_type", "amount", "attachments"], + ambiguity=[], + entity_hints=[], + ), + [ + { + "slot": "main", + "provider": "MiniMax", + "model": "intent-model", + "attempt": 1, + "status": "succeeded", + "duration_ms": 8, + } + ], + None, + ), + ) result = service.parse( OntologyParseRequest( @@ -836,7 +910,103 @@ def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) assert result.parse_strategy == "llm_primary" assert result.clarification_required is True assert "expense_type" in result.missing_slots - assert result.clarification_question == "请补充费用类型、金额和票据附件。" + assert result.clarification_question == "请补充费用类型、金额和票据附件。" + + +def test_semantic_ontology_service_falls_back_when_model_conflicts_with_application_signal( + monkeypatch, +) -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = SemanticOntologyService(db) + + monkeypatch.setattr( + service.runtime_chat_service, + "complete_with_trace", + lambda *args, **kwargs: RuntimeChatResult( + text=( + '{"scenario":"knowledge","intent":"query","confidence":0.91,' + '"clarification_required":false,"missing_slots":[],' + '"ambiguity":[],"entity_hints":[]}' + ), + calls=[ + RuntimeChatCallTrace( + slot="main", + provider="MiniMax", + model="intent-model", + attempt=1, + status="succeeded", + duration_ms=11, + ) + ], + ), + ) + + result = service.parse( + OntologyParseRequest( + query="去国网出差3天,协助仿生产环境部署", + user_id="pytest", + ) + ) + fetched = service.run_service.get_run(result.run_id) + + entity_map = {item.type: item.normalized_value for item in result.entities} + + assert result.scenario == "expense" + assert result.intent == "draft" + assert result.parse_strategy == "rule_fallback" + assert entity_map["document_type"] == "expense_application" + assert fetched is not None + assert fetched.tool_calls[0].status == "failed" + assert fetched.tool_calls[0].error_message == "model_conflicts_with_application_stage_signal" + + +def test_semantic_ontology_service_records_model_call_errors_for_statistics(monkeypatch) -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = SemanticOntologyService(db) + run = service.run_service.create_run( + agent=AgentName.ORCHESTRATOR.value, + source=AgentRunSource.USER_MESSAGE.value, + status=AgentRunStatus.RUNNING.value, + ) + + monkeypatch.setattr( + service.runtime_chat_service, + "complete_with_trace", + lambda *args, **kwargs: RuntimeChatResult( + text=None, + calls=[ + RuntimeChatCallTrace( + slot="main", + provider="MiniMax", + model="intent-model", + attempt=1, + status="failed", + duration_ms=15, + error_message="incorrect api key", + ) + ], + ), + ) + + result = service.parse_for_run( + OntologyParseRequest( + query="去北京出差3天,支撑国网仿生产环境部署", + user_id="pytest", + ), + run_id=run.run_id, + ) + fetched = service.run_service.get_run(run.run_id) + stats = service.run_service.summarize_runs(limit=20) + + assert result.parse_strategy == "rule_fallback" + assert fetched is not None + assert len(fetched.tool_calls) == 1 + assert fetched.tool_calls[0].tool_name == "semantic_ontology.main" + assert fetched.tool_calls[0].status == "failed" + assert fetched.tool_calls[0].error_message == "incorrect api key" + assert stats.failed_llm_call_count >= 1 def test_parse_ontology_endpoint_returns_eight_fields_and_writes_trace() -> None: diff --git a/server/tests/test_openapi_schema.py b/server/tests/test_openapi_schema.py index 8a7e002..9bc987e 100644 --- a/server/tests/test_openapi_schema.py +++ b/server/tests/test_openapi_schema.py @@ -13,6 +13,8 @@ def test_openapi_schema_includes_documented_backend_routes() -> None: assert any(tag["name"] == "ocr" for tag in schema["tags"]) assert any(tag["name"] == "ontology" for tag in schema["tags"]) assert any(tag["name"] == "orchestrator" for tag in schema["tags"]) + assert any(tag["name"] == "agent-feedback" for tag in schema["tags"]) + assert any(tag["name"] == "analytics" for tag in schema["tags"]) agent_assets_post = schema["paths"]["/api/v1/agent-assets"]["post"] assert agent_assets_post["summary"] == "创建 Agent 资产" @@ -40,5 +42,12 @@ def test_openapi_schema_includes_documented_backend_routes() -> None: assert orchestrator_run_post["summary"] == "运行 Orchestrator 统一调度" assert "application/json" in orchestrator_run_post["requestBody"]["content"] + feedback_post = schema["paths"]["/api/v1/agent-feedback"]["post"] + assert feedback_post["summary"] == "记录 Agent 操作评价" + assert "application/json" in feedback_post["requestBody"]["content"] + + analytics_get = schema["paths"]["/api/v1/analytics/system-dashboard"]["get"] + assert analytics_get["summary"] == "查询系统看板真实指标" + root_get = schema["paths"]["/"]["get"] assert root_get["summary"] == "服务根检查" diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 8285f9b..e3b979c 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -9,6 +9,8 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base +from app.models.agent_asset import AgentAsset +from app.models.agent_run import AgentRun from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.ontology import OntologyParseResult, OntologyPermission @@ -35,6 +37,85 @@ def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None: ) +@pytest.mark.parametrize( + ("task_type", "code", "method_path", "summary", "expected_text"), + [ + ( + "global_risk_scan", + "task.hermes.global_risk_scan", + "app.services.hermes_risk_scanner.HermesRiskScannerService.scan_global_risks", + {"scanned_claim_count": 2, "risk_observation_count": 1}, + "生成 1 条风险观察", + ), + ( + "employee_behavior_profile_scan", + "task.hermes.employee_behavior_profile_scan", + "app.services.hermes_employee_profile_scanner.HermesEmployeeProfileScannerService.scan_employee_profiles", + { + "target_employee_count": 3, + "snapshot_count": 9, + "high_attention_employee_count": 1, + }, + "生成 9 条快照", + ), + ], +) +def test_schedule_digital_employee_task_runs_real_service( + monkeypatch, + task_type, + code, + method_path, + summary, + expected_text, +) -> None: + def parse_for_run(self, request, run_id): # noqa: ANN001 + return OntologyParseResult( + scenario="expense", + intent="risk_check", + entities=[], + permission=OntologyPermission(level="read", allowed=True, reason=""), + confidence=0.95, + missing_slots=[], + ambiguity=[], + clarification_required=False, + clarification_question=None, + run_id=run_id, + ) + + monkeypatch.setattr("app.services.ontology.SemanticOntologyService.parse_for_run", parse_for_run) + monkeypatch.setattr(method_path, lambda self, **kwargs: dict(summary)) + + session_factory = build_session_factory() + with session_factory() as db: + task = AgentAsset( + asset_type="task", + code=code, + name="数字员工任务", + description="", + domain="system", + scenario_json=["schedule"], + owner="pytest", + status="active", + current_version="v1.0.0", + working_version="v1.0.0", + published_version="v1.0.0", + config_json={"agent": "hermes", "task_type": task_type}, + ) + db.add(task) + db.commit() + + response = OrchestratorService(db).run( + OrchestratorRequest(source="schedule", task_id=task.id, message=task.name) + ) + + run = db.query(AgentRun).filter_by(run_id=response.run_id).one() + assert response.status == "succeeded" + assert response.result["report_type"] == task_type + assert expected_text in response.result["message"] + assert run.route_json["job_type"] == task_type + assert run.route_json["task_code"] == code + + def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload( monkeypatch, ) -> None: @@ -707,8 +788,8 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit( assert fourth.status == "succeeded" assert fourth.result["clarification_required"] is False assert fourth.result["missing_slots"] == [] - assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"] - assert "当前状态:陈硕审核中" in fourth.result["answer"] + assert "申请单据已生成,并已进入审批流程" in fourth.result["answer"] + assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in fourth.result["answer"] assert fourth.result["suggested_actions"] == [] application_claims = [ claim @@ -808,5 +889,5 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block( assert submitted.requires_confirmation is False assert "操作类请求需要人工审批确认" not in submitted.result["answer"] assert "当前仅返回确认摘要" not in submitted.result["answer"] - assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"] + assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"] assert submitted.result["draft_payload"]["status"] == "submitted" diff --git a/server/tests/test_receipt_folder_service.py b/server/tests/test_receipt_folder_service.py new file mode 100644 index 0000000..4d7e41d --- /dev/null +++ b/server/tests/test_receipt_folder_service.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from app.api.deps import CurrentUserContext +from app.core.config import get_settings +from app.schemas.ocr import OcrRecognizeDocumentRead +from app.services.receipt_folder import ReceiptFolderService + + +def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + get_settings.cache_clear() + try: + current_user = CurrentUserContext( + username="pytest", + name="Py Test", + role_codes=[], + is_admin=False, + ) + service = ReceiptFolderService() + receipt = service.save_receipt( + filename="2月23_上海-武汉.pdf", + content=b"%PDF-1.4 fake", + media_type="application/pdf", + current_user=current_user, + document=OcrRecognizeDocumentRead( + filename="2月23_上海-武汉.pdf", + media_type="application/pdf", + text=( + "电子发票(铁路电子客票)\n" + "发票号码:26319166100006175398\n" + "电子客票号:E1234567890123\n" + "开票日期:2026-02-18\n" + "上海虹桥站\n" + "武汉站\n" + "G456\n" + "二等座\n" + "06车01B号\n" + "2026-02-20 08:30开\n" + "票价:¥354.00\n" + "1101011990****1234\n" + "张三" + ), + summary="铁路电子客票,上海虹桥至武汉,票价 354 元。", + document_type="train_ticket", + document_type_label="火车/高铁票", + scene_code="travel", + scene_label="差旅票据", + ), + ) + + assert receipt.document_date == "2026-02-18" + assert receipt.merchant_name == "中国铁路" + assert receipt.amount == "354.00元" + + detail = service.get_receipt(receipt.id, current_user) + fields = {field.label: field.value for field in detail.fields} + assert fields["开票日期"] == "2026-02-18" + assert fields["乘车人"] == "张三" + assert fields["出发地点"] == "上海虹桥" + assert fields["到达地点"] == "武汉" + assert fields["车次"] == "G456" + assert fields["电子客票号"] == "E1234567890123" + assert fields["身份证号"] == "1101011990****1234" + assert fields["席别"] == "二等座" + assert fields["车厢"] == "06车" + assert fields["座位号"] == "01B" + assert fields["列车出发时间"] == "2026-02-20 08:30" + finally: + get_settings.cache_clear() diff --git a/server/tests/test_risk_graph_profile_baselines.py b/server/tests/test_risk_graph_profile_baselines.py new file mode 100644 index 0000000..1e26f91 --- /dev/null +++ b/server/tests/test_risk_graph_profile_baselines.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from decimal import Decimal + +from app.algorithem.risk_graph import RiskGraphClaimItemSnapshot, RiskGraphClaimSnapshot +from app.algorithem.risk_graph.profile_baselines import ProfileBaselineUpdater + + +def test_profile_baseline_updater_outputs_core_dimensions() -> None: + snapshot = ProfileBaselineUpdater().build_from_claims( + [ + _claim( + "c1", + employee_id="e1", + department_id="d1", + expense_type="travel", + amount="600", + supplier_id="s-hotel", + supplier_name="Hotel A", + ), + _claim( + "c2", + employee_id="e1", + department_id="d1", + expense_type="travel", + amount="900", + supplier_id="s-hotel", + supplier_name="Hotel A", + ), + _claim( + "c3", + employee_id="e2", + department_id="d2", + expense_type="meal", + amount="300", + supplier_id="s-meal", + supplier_name="Meal B", + ), + ] + ) + + assert snapshot.dimension_counts == { + "employee": 2, + "department": 2, + "supplier": 2, + "expense_type": 2, + } + hotel = next(bucket for bucket in snapshot.buckets if bucket.key == "s-hotel") + assert hotel.dimension == "supplier" + assert hotel.sample_size == 2 + assert hotel.claim_count == 2 + assert hotel.total_amount == Decimal("1500") + assert hotel.p75_amount == Decimal("825") + assert hotel.as_dict()["total_amount"] == "1500" + + +def _claim( + claim_id: str, + *, + employee_id: str, + department_id: str, + expense_type: str, + amount: str, + supplier_id: str, + supplier_name: str, +) -> RiskGraphClaimSnapshot: + return RiskGraphClaimSnapshot( + claim_id=claim_id, + claim_no=f"BX-{claim_id}", + employee_id=employee_id, + employee_name=employee_id, + department_id=department_id, + department_name=department_id, + expense_type=expense_type, + amount=Decimal(amount), + items=[ + RiskGraphClaimItemSnapshot( + item_id=f"item-{claim_id}", + item_type=expense_type, + item_amount=Decimal(amount), + metadata={ + "supplier_id": supplier_id, + "supplier_name": supplier_name, + }, + ) + ], + ) diff --git a/server/tests/test_risk_observations_service.py b/server/tests/test_risk_observations_service.py new file mode 100644 index 0000000..0d67915 --- /dev/null +++ b/server/tests/test_risk_observations_service.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime +from decimal import Decimal + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.api.v1.endpoints.risk_observations import router as risk_observations_router +from app.db.base import Base +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.risk_observation import RiskObservation +from app.schemas.risk_observation import RiskObservationFeedbackCreate +from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder +from app.services.risk_observations import RiskObservationService + + +def test_risk_observation_service_upserts_and_summarizes_dashboard() -> None: + with _build_session() as db: + db.add(_employee_orm()) + db.add_all([_claim_orm("c1", "BX-001"), _claim_orm("c2", "BX-002")]) + db.flush() + service = RiskObservationService(db) + service.upsert_observation(_observation_payload("risk:c1:duplicate_invoice")) + service.upsert_observation( + { + **_observation_payload("risk:c2:preapproval_absent"), + "claim_id": "c2", + "claim_no": "BX-002", + "risk_signal": "preapproval_absent", + "risk_type": "preapproval_absent", + "risk_score": 72, + "risk_level": "high", + } + ) + db.commit() + + feedback = service.create_feedback( + "risk:c1:duplicate_invoice", + RiskObservationFeedbackCreate(feedback_type="confirm", actor="auditor"), + ) + dashboard = service.summarize_dashboard(window_days=30) + history = service.build_history_stats(risk_signals={"duplicate_invoice"}) + refreshed = service.get_observation("risk:c1:duplicate_invoice") + + assert feedback.feedback_type == "confirm" + assert refreshed is not None + assert refreshed.status == "confirmed" + assert refreshed.source == "financial_risk_graph" + assert refreshed.algorithm_version == "financial_risk_graph.v1" + assert refreshed.sampling_strategy["strategy"] == "focused_review" + assert refreshed.evaluation_case_id == "case-duplicate-invoice" + assert refreshed.ontology_parse_id == "parse-1" + assert refreshed.ontology_version == "ontology.v1" + assert refreshed.domain == "expense" + assert refreshed.scenario == "reimbursement" + assert refreshed.intent == "risk_check" + assert refreshed.ontology_entities_json == [{"type": "claim", "value": "c1"}] + assert refreshed.risk_signals_json == [{"code": "duplicate_invoice"}] + assert refreshed.canonical_subject_key == "claim:c1" + assert dashboard.total_observations == 2 + assert dashboard.high_or_above_count == 2 + assert dashboard.confirmed_count == 1 + assert dashboard.total_amount == 2400.0 + assert dashboard.level_distribution["high"] == 2 + assert dashboard.signal_distribution["duplicate_invoice"] == 1 + assert dashboard.department_distribution["风控部"] == 2 + assert dashboard.expense_type_distribution["travel"] == 2 + assert dashboard.employee_grade_distribution["P6"] == 2 + assert dashboard.supplier_distribution["上海差旅供应商"] == 2 + assert dashboard.top_departments[0]["name"] == "风控部" + assert dashboard.top_departments[0]["amount"] == 2400.0 + assert dashboard.top_employees[0]["name"] == "风险员工" + assert dashboard.top_suppliers[0]["name"] == "上海差旅供应商" + assert dashboard.top_expense_types[0]["name"] == "travel" + assert dashboard.top_rules[0]["name"] == "policy.duplicate_invoice" + assert dashboard.top_risk_signals[0]["name"] in { + "duplicate_invoice", + "preapproval_absent", + } + assert dashboard.daily_trend + assert history[0].risk_signal == "duplicate_invoice" + assert history[0].confirmed_count == 1 + + +def test_platform_rule_flags_are_persisted_as_risk_observations() -> None: + with _build_session() as db: + claim = _claim_orm("c-platform", "BX-PLATFORM") + db.add(claim) + db.flush() + + observations = RiskObservationService(db).upsert_platform_risk_flags( + claim, + [ + { + "hit_source": "rule_center", + "rule_type": "risk", + "rule_code": "risk.invoice.duplicate_invoice", + "rule_version": "v1.2.0", + "severity": "critical", + "action": "block", + "label": "重复发票校验", + "message": "票据号码已在其他报销单中出现。", + "evidence": {"invoice_no": "INV-001"}, + } + ], + ) + db.commit() + + assert len(observations) == 1 + persisted = db.query(RiskObservation).filter_by(claim_id="c-platform").one() + assert persisted.risk_signal == "duplicate_invoice" + assert persisted.risk_level == "critical" + assert persisted.source == "rule_center" + assert persisted.algorithm_version == "v1.2.0" + assert persisted.contribution_scores_json == {"S_rule": 100} + + +def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() -> None: + client, session_factory = _build_client() + with session_factory() as db: + service = RiskObservationService(db) + service.upsert_observation( + _observation_payload("risk:c1:duplicate_invoice"), + execution_log_id="exec-1", + ) + db.commit() + + list_response = client.get("/api/v1/risk-observations", params={"risk_level": "high"}) + execution_log_response = client.get("/api/v1/risk-observations/execution-log/exec-1") + detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice") + dashboard_response = client.get("/api/v1/risk-observations/dashboard") + feedback_response = client.post( + "/api/v1/risk-observations/risk:c1:duplicate_invoice/feedback", + json={"feedback_type": "false_positive", "actor": "auditor", "comment": "误报"}, + ) + + assert list_response.status_code == 200 + assert list_response.json()["total"] == 1 + assert execution_log_response.status_code == 200 + assert len(execution_log_response.json()) == 1 + assert detail_response.status_code == 200 + assert detail_response.json()["risk_signal"] == "duplicate_invoice" + assert dashboard_response.status_code == 200 + assert dashboard_response.json()["total_observations"] == 1 + assert "top_departments" in dashboard_response.json() + assert feedback_response.status_code == 200 + assert feedback_response.json()["feedback_type"] == "false_positive" + + updated_detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice") + assert updated_detail_response.status_code == 200 + assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive" + + with session_factory() as db: + observation = db.query(RiskObservation).filter_by( + observation_key="risk:c1:duplicate_invoice" + ).one() + assert observation.status == "false_positive" + assert observation.feedback_status == "false_positive" + + +def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None: + with _build_session() as db: + service = RiskObservationService(db) + service.upsert_observation(_observation_payload("risk:c1:duplicate_invoice")) + db.commit() + + feedback = service.create_feedback( + "risk:c1:duplicate_invoice", + RiskObservationFeedbackCreate( + feedback_type="comment", + action="rewrite", + actor="auditor", + comment="建议生成候选规则", + payload_json={ + "decision": "candidate_rule_rewrite", + "candidate_rule_source": "risk_observation_feedback", + "confidence_score": 0.76, + "escalation_target": "finance_manager", + "supplement_required": True, + }, + ), + ) + observation = service.get_observation("risk:c1:duplicate_invoice") + assert observation is not None + + replay_set = AlgorithmReplaySetBuilder().build_from_observations( + "replay-set-1", + [ + { + "observation_key": observation.observation_key, + "claim_id": observation.claim_id, + "risk_signal": observation.risk_signal, + "risk_score": observation.risk_score, + "risk_level": observation.risk_level, + "algorithm_version": observation.algorithm_version, + "feedback_status": observation.feedback_status, + "ontology_json": observation.ontology_json, + "decision_trace": observation.decision_trace_json, + } + ], + created_at=datetime(2026, 5, 30, tzinfo=UTC), + ) + + assert feedback.decision == "candidate_rule_rewrite" + assert feedback.candidate_rule_source == "risk_observation_feedback" + assert feedback.confidence_score == 0.76 + assert feedback.escalation_target == "finance_manager" + assert feedback.supplement_required is True + assert replay_set.replay_set_id == "replay-set-1" + assert replay_set.cases[0].claim_id == "c1" + assert replay_set.cases[0].ontology_version == "ontology.v1" + assert replay_set.cases[0].algorithm_version == "financial_risk_graph.v1" + + +def _build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def _build_client() -> tuple[TestClient, sessionmaker[Session]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = FastAPI() + app.include_router(risk_observations_router, prefix="/api/v1") + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def _observation_payload(observation_key: str) -> dict: + return { + "observation_key": observation_key, + "subject_type": "expense_claim", + "subject_key": "claim:c1", + "subject_label": "BX-001", + "claim_id": "c1", + "claim_no": "BX-001", + "risk_type": "duplicate_invoice", + "risk_signal": "duplicate_invoice", + "title": "Duplicate invoice risk", + "description": "Same invoice appears in multiple claims.", + "risk_score": 86, + "risk_level": "high", + "confidence_score": "0.81", + "control_stage": "reimbursement", + "control_mode": "risk_observation", + "automation_mode": "semi_auto_review", + "source": "financial_risk_graph", + "algorithm_version": "financial_risk_graph.v1", + "contribution_scores": {"S_rule": 82, "S_graph": 95}, + "baseline": {"scope": "expense_type", "sample_size": 4}, + "evidence": [ + { + "code": "duplicate_invoice_graph", + "source": "graph", + "metadata": {"vendor_name": "上海差旅供应商"}, + } + ], + "graph_node_keys": ["claim:c1", "vendor:上海差旅供应商"], + "graph_edge_keys": [], + "policy_refs": ["policy.duplicate_invoice"], + "similar_case_claim_ids": ["c2"], + "ontology_json": { + "gate": "review", + "ontology_parse_id": "parse-1", + "ontology_version": "ontology.v1", + "domain": "expense", + "scenario": "reimbursement", + "intent": "risk_check", + "ontology_entities_json": [{"type": "claim", "value": "c1"}], + "risk_signals_json": [{"code": "duplicate_invoice"}], + "canonical_subject_key": "claim:c1", + }, + "decision_trace": { + "formula": "weighted", + "sampling_strategy": {"strategy": "focused_review", "threshold": 70}, + "evaluation_case_id": "case-duplicate-invoice", + }, + } + + +def _employee_orm() -> Employee: + return Employee( + id="emp-risk", + employee_no="E-RISK", + name="风险员工", + email="risk.employee@example.com", + position="高级专员", + grade="P6", + ) + + +def _claim_orm(claim_id: str, claim_no: str) -> ExpenseClaim: + now = datetime(2026, 5, 20, tzinfo=UTC) + return ExpenseClaim( + id=claim_id, + claim_no=claim_no, + employee_id="emp-risk", + employee_name="风险员工", + department_id="dept-risk", + department_name="风控部", + expense_type="travel", + reason="客户拜访", + location="上海", + amount=Decimal("1200"), + currency="CNY", + invoice_count=1, + occurred_at=now, + submitted_at=now, + status="submitted", + approval_stage="manager_review", + risk_flags_json=[], + ) diff --git a/server/tests/test_risk_rule_dsl_examples.py b/server/tests/test_risk_rule_dsl_examples.py new file mode 100644 index 0000000..295ebc8 --- /dev/null +++ b/server/tests/test_risk_rule_dsl_examples.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime +from decimal import Decimal + +import pytest + +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.services.risk_rule_dsl_examples import ( + get_risk_rule_dsl_example, + list_risk_rule_dsl_examples, +) +from app.services.risk_rule_dsl_validator import validate_risk_rule_draft +from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY +from app.services.risk_rule_generation_ontology import FIELD_ONTOLOGY +from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor + + +def test_dsl_examples_pass_validator() -> None: + examples = list_risk_rule_dsl_examples() + + assert {example["code"] for example in examples} == { + "travel_city_mismatch", + "lodging_date_outside_range", + "budget_threshold", + "duplicate_invoice", + "entertainment_per_capita_over_limit", + } + for example in examples: + manifest = example["manifest"] + normalized = validate_risk_rule_draft( + manifest["params"], + fields=list(FIELD_ONTOLOGY), + natural_language=example["natural_language"], + ) + + assert normalized["template_key"] == COMPOSITE_RULE_TEMPLATE_KEY + assert normalized["dsl_validation"]["status"] == "passed" + assert normalized["conditions"] + assert not normalized.get("keywords") + + +@pytest.mark.parametrize( + ("code", "hit_contexts", "pass_contexts"), + [ + ( + "travel_city_mismatch", + [{"document_info": {"route_cities": ["武汉", "上海"]}}], + [{"document_info": {"route_cities": ["武汉", "北京"]}}], + ), + ( + "lodging_date_outside_range", + [{"document_info": {"stay_start_date": "2026-05-08", "stay_end_date": "2026-05-13"}}], + [{"document_info": {"stay_start_date": "2026-05-10", "stay_end_date": "2026-05-12"}}], + ), + ( + "budget_threshold", + [{"budget_context": {"remaining_amount": "1000.00"}}], + [{"budget_context": {"remaining_amount": "2000.00"}}], + ), + ( + "duplicate_invoice", + [ + {"document_info": {"invoice_no": "INV-20260530-001"}}, + {"document_info": {"invoice_no": "INV-20260530-001"}}, + ], + [ + {"document_info": {"invoice_no": "INV-20260530-001"}}, + {"document_info": {"invoice_no": "INV-20260530-002"}}, + ], + ), + ( + "entertainment_per_capita_over_limit", + [], + [], + ), + ], +) +def test_dsl_examples_execute_hit_and_pass( + code: str, + hit_contexts: list[dict], + pass_contexts: list[dict], +) -> None: + example = get_risk_rule_dsl_example(code) + assert example is not None + executor = RiskRuleTemplateExecutor() + claim = _claim(amount=Decimal("1200.00")) + if code == "entertainment_per_capita_over_limit": + claim.expense_type = "业务招待费" + claim.reason = "客户接待餐费" + claim.attendee_count = 2 + claim.per_capita_amount = Decimal("600.00") + + hit_result = executor.evaluate(example["manifest"], claim=claim, contexts=hit_contexts) + if code == "entertainment_per_capita_over_limit": + claim.per_capita_amount = Decimal("400.00") + pass_result = executor.evaluate(example["manifest"], claim=claim, contexts=pass_contexts) + + assert hit_result is not None + assert pass_result is None + + +def test_duplicate_invoice_example_reports_duplicate_evidence() -> None: + example = get_risk_rule_dsl_example("duplicate_invoice") + assert example is not None + + result = RiskRuleTemplateExecutor().evaluate( + example["manifest"], + claim=_claim(), + contexts=[ + {"document_info": {"invoice_no": "INV-DUP-001"}}, + {"document_info": {"invoice_no": "INV-DUP-001"}}, + ], + ) + + assert result is not None + condition = result["evidence"]["conditions"][0] + assert condition["operator"] == "duplicate_value" + assert condition["duplicates"] == ["inv-dup-001"] + + +def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim: + claim = ExpenseClaim( + claim_no="TEST-RISK-RULE-DSL", + employee_name="测试员工", + department_name="测试部门", + expense_type="差旅费", + reason="北京出差项目支持", + location="北京", + amount=amount, + currency="CNY", + invoice_count=2, + occurred_at=datetime(2026, 5, 10, tzinfo=UTC), + status="draft", + ) + claim.trip_start_date = date(2026, 5, 10) + claim.trip_end_date = date(2026, 5, 12) + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 5, 10), + item_type="travel", + item_reason="北京出差", + item_location="北京", + item_amount=amount, + invoice_id="INV-ITEM-001", + ) + ] + return claim diff --git a/server/tests/test_risk_rule_dsl_validator.py b/server/tests/test_risk_rule_dsl_validator.py new file mode 100644 index 0000000..fe74b8f --- /dev/null +++ b/server/tests/test_risk_rule_dsl_validator.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from app.models.financial_record import ExpenseClaim +from app.services.risk_rule_dsl_validator import validate_risk_rule_draft +from app.services.risk_rule_generation_ontology import RiskRuleField +from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor + + +FIELDS = [ + RiskRuleField("claim.location", "申报地点", "text", "claim", ("目的地", "城市")), + RiskRuleField("attachment.hotel_city", "住宿城市", "text", "attachment", ("酒店城市",)), + RiskRuleField("attachment.route_cities", "行程城市", "list", "attachment", ("交通票城市",)), + RiskRuleField("claim.amount", "申报金额", "number", "claim", ("金额",)), + RiskRuleField("budget.remaining_amount", "预算可用余额", "number", "budget", ("预算余额",)), + RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由",)), + RiskRuleField("attachment.ocr_text", "票据全文", "text", "attachment", ("OCR",)), +] + + +def test_validator_rewrites_city_keyword_rule_to_structured_compare() -> None: + draft = { + "template_key": "keyword_match_v1", + "field_keys": ["attachment.hotel_city", "attachment.route_cities", "claim.location"], + "keywords": ["绕行", "跨城办事"], + "condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词", + } + + normalized = validate_risk_rule_draft( + draft, + fields=FIELDS, + natural_language="差旅报销时,住宿或交通票据城市必须与申报目的地一致,未说明绕行时进入复核。", + ) + + assert normalized["template_key"] == "field_compare_v1" + assert normalized["semantic_type"] == "travel_route_city_consistency" + assert normalized["keywords"] == [] + assert "city_rule_normalized_to_structured_compare" in normalized["dsl_validation"]["issues"] + + +def test_validator_rewrites_budget_keyword_rule_to_numeric_compare() -> None: + draft = { + "template_key": "keyword_match_v1", + "field_keys": ["claim.amount", "budget.remaining_amount", "claim.reason"], + "keywords": ["超预算"], + "condition_summary": "检查金额字段是否出现预算风险关键词", + } + + normalized = validate_risk_rule_draft( + draft, + fields=FIELDS, + natural_language="费用申请时,若申报金额超过预算可用余额,则提示风险并要求补充审批说明。", + ) + + assert normalized["template_key"] == "composite_rule_v1" + assert normalized["keywords"] == [] + assert normalized["conditions"][0]["operator"] == "numeric_compare" + assert normalized["conditions"][0]["left_fields"] == ["claim.amount"] + assert normalized["conditions"][0]["right_fields"] == ["budget.remaining_amount"] + assert "风险关键词" not in normalized["condition_summary"] + + +def test_validator_builds_numeric_condition_for_empty_composite_fallback() -> None: + normalized = validate_risk_rule_draft( + {"template_key": "composite_rule_v1", "field_keys": ["claim.amount", "budget.remaining_amount"]}, + fields=FIELDS, + natural_language="费用申请时,若申报金额超过预算可用余额,则提示风险。", + ) + + assert normalized["template_key"] == "composite_rule_v1" + assert normalized["conditions"][0]["operator"] == "numeric_compare" + assert normalized["hit_logic"] == {"all": ["amount_exceeds_budget"]} + assert "empty_composite_rule_built_from_structured_fields" in normalized["dsl_validation"]["issues"] + + +def test_numeric_compare_condition_executes_against_budget_context() -> None: + manifest = { + "template_key": "composite_rule_v1", + "params": { + "template_key": "composite_rule_v1", + "conditions": [ + { + "id": "amount_exceeds_budget", + "operator": "numeric_compare", + "left_fields": ["claim.amount"], + "right_fields": ["budget.remaining_amount"], + "compare": "gt", + } + ], + "hit_logic": {"all": ["amount_exceeds_budget"]}, + "message_template": "申报金额超过预算可用余额。", + }, + } + claim = ExpenseClaim( + claim_no="TEST-BUDGET-RISK", + employee_name="测试员工", + department_name="测试部门", + expense_type="差旅费", + reason="北京出差", + location="北京", + amount=Decimal("1200.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 30, tzinfo=UTC), + status="draft", + ) + + result = RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[{"budget_context": {"remaining_amount": "1000.00"}}], + ) + assert result is not None + assert result["message"] == "申报金额超过预算可用余额。" + assert result["evidence"]["condition_results"]["amount_exceeds_budget"] is True + + claim.amount = Decimal("800.00") + assert RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[{"budget_context": {"remaining_amount": "1000.00"}}], + ) is None diff --git a/server/tests/test_risk_rule_explainability.py b/server/tests/test_risk_rule_explainability.py new file mode 100644 index 0000000..1cc7996 --- /dev/null +++ b/server/tests/test_risk_rule_explainability.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.agent_enums import AgentAssetDomain +from app.db.base import Base +from app.models.agent_asset import AgentAsset +from app.schemas.agent_asset import ( + AgentAssetRiskRuleGenerateRequest, + AgentAssetRiskRuleSimulationRequest, +) +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.agent_assets import AgentAssetService +from app.services.risk_rule_generation import RiskRuleGenerationService + + +class NullRuntimeChatService: + def complete(self, *args, **kwargs) -> None: + return None + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_generated_risk_rule_contains_semantic_plan_and_flow_model(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + asset_id = RiskRuleGenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ).generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + expense_category="travel", + rule_title="差旅票据城市一致性校验", + natural_language=( + "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;" + "未说明绕行、跨城或改签原因时标记高风险。" + ), + requires_attachment=True, + ), + actor="pytest", + ) + asset = db.get(AgentAsset, asset_id) + assert asset is not None + payload = manager.read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=asset.config_json["rule_document"]["file_name"], + ) + + assert payload["semantic_plan"]["required_fields"] + assert payload["semantic_plan"]["risk_action"]["risk_level"] == payload["outcomes"]["fail"]["severity"] + assert payload["flow_model"]["source"] == "json_dsl" + assert payload["flow_model"]["nodes"][0]["id"] == "start" + assert any(node["type"] == "risk" for node in payload["flow_model"]["nodes"]) + assert payload["metadata"]["flow_model"]["nodes"] == payload["flow_model"]["nodes"] + assert payload["flow_diagram_svg"].startswith(" None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + asset_id = RiskRuleGenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ).generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + expense_category="travel", + rule_title="当前差旅票据城市一致性规则", + natural_language=( + "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;" + "未说明绕行、跨城或改签原因时标记高风险。" + ), + requires_attachment=True, + ), + actor="pytest", + ) + service = AgentAssetService(db) + service.rule_library_manager = manager + + simulation = service.simulate_risk_rule_message( + asset_id, + AgentAssetRiskRuleSimulationRequest( + message="去北京出差3天", + attachments=[ + { + "name": "train-ticket.pdf", + "content_type": "application/pdf", + "ocr_text": "武汉 到 上海", + "summary": "高铁票 武汉-上海", + "document_fields": [ + {"key": "route", "label": "行程路线", "value": "武汉-上海"} + ], + } + ], + ), + ) + + assert simulation.ready is True + assert simulation.hit is True + assert simulation.normalized_fields["claim.location"] == "北京" + assert simulation.trace["matched"] is True + assert "hit" in simulation.trace["path_node_ids"] + assert simulation.trace["steps"] diff --git a/server/tests/test_risk_rule_flow_diagram_model.py b/server/tests/test_risk_rule_flow_diagram_model.py new file mode 100644 index 0000000..ccc94ea --- /dev/null +++ b/server/tests/test_risk_rule_flow_diagram_model.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from app.services.risk_rule_flow_diagram import ( + RiskRuleFlowDiagramField, + RiskRuleFlowDiagramRenderer, + build_risk_rule_flow_diagram_spec, +) + + +FIELDS = ( + RiskRuleFlowDiagramField(key="claim.amount", label="申请金额"), + RiskRuleFlowDiagramField(key="budget.remaining_amount", label="可用预算"), + RiskRuleFlowDiagramField(key="claim.reason", label="申请事由"), +) + + +def test_flow_diagram_spec_prefers_flow_model_nodes() -> None: + spec = build_risk_rule_flow_diagram_spec( + {"name": "预算余额校验", "metadata": {"condition_summary": "legacy summary"}}, + fields=FIELDS, + domain_label="差旅费", + severity="high", + severity_label="高风险", + flow_model={ + "nodes": [ + {"id": "start", "type": "start", "title": "开始", "description": "费用申请提交"}, + { + "id": "evidence", + "type": "evidence", + "title": "字段事实", + "description": "读取申请金额与可用预算", + "fields": ["claim.amount", "budget.remaining_amount"], + }, + { + "id": "amount_exceeds_budget", + "type": "decision", + "title": "金额超过预算", + "description": "申请金额大于可用预算余额", + }, + {"id": "pass", "type": "pass", "description": "预算充足,继续流转"}, + {"id": "hit", "type": "risk", "description": "进入预算复核"}, + ], + "metadata": {"hit_logic": "amount_exceeds_budget"}, + }, + ) + + assert spec.start == "费用申请提交" + assert spec.fact_lines[:2] == ( + "A=申请金额[claim.amount]", + "B=可用预算[budget.remaining_amount]", + ) + assert spec.condition_lines == ("金额超过预算: 申请金额大于可用预算余额",) + assert spec.hit_logic == "amount_exceeds_budget" + svg = RiskRuleFlowDiagramRenderer().render(spec) + assert "金额超过预算" in svg + assert "#dc2626" in svg + + +def test_flow_diagram_spec_falls_back_to_dsl_when_flow_model_missing() -> None: + spec = build_risk_rule_flow_diagram_spec( + { + "name": "重复发票校验", + "params": { + "conditions": [ + { + "id": "same_invoice_no_repeated", + "operator": "duplicate_value", + "fields": ["claim.reason"], + } + ], + "hit_logic": {"all": ["same_invoice_no_repeated"]}, + }, + "metadata": {"condition_summary": "发票号重复时命中"}, + }, + fields=FIELDS, + domain_label="通用", + severity="medium", + severity_label="中风险", + flow_model={}, + ) + + assert spec.condition_lines == ("same_invoice_no_repeated: 申请事由 出现重复值",) + assert spec.hit_logic == "same_invoice_no_repeated" + assert "发票号重复时命中" in spec.basis + + +def test_flow_diagram_spec_compresses_too_many_decision_nodes() -> None: + nodes = [{"id": "start", "type": "start", "description": "提交单据"}] + nodes.extend( + { + "id": f"condition_{index}", + "type": "decision", + "title": f"判断{index}", + "description": f"第{index}个判断条件", + } + for index in range(1, 7) + ) + spec = build_risk_rule_flow_diagram_spec( + {"name": "复杂规则"}, + fields=FIELDS, + domain_label="通用", + severity="low", + severity_label="低风险", + flow_model={"nodes": nodes}, + ) + + assert len(spec.condition_lines) == 4 + assert "另有 2 个判断节点" in spec.condition_lines[-1] diff --git a/server/tests/test_risk_rule_revision_endpoints.py b/server/tests/test_risk_rule_revision_endpoints.py new file mode 100644 index 0000000..97e47ed --- /dev/null +++ b/server/tests/test_risk_rule_revision_endpoints.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus +from app.db.base import Base +from app.main import create_app +from app.models.agent_asset import AgentAsset +from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.risk_rule_generation import RiskRuleGenerationService + + +class NullRuntimeChatService: + def complete(self, *args, **kwargs) -> None: + return None + + +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def test_update_risk_rule_draft_endpoint_updates_unpublished_rule(tmp_path) -> None: + client, session_factory = build_client() + asset_id = _create_rule(session_factory, tmp_path) + + response = client.patch( + f"/api/v1/agent-assets/{asset_id}/risk-rules/draft", + headers=_finance_headers(), + json={ + "rule_title": "差旅绕行说明校验", + "expense_category": "travel", + "natural_language": "差旅报销存在绕行但未说明原因时,进入风险复核。", + "requires_attachment": True, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["name"] == "差旅绕行说明校验" + assert payload["description"] == "差旅报销存在绕行但未说明原因时,进入风险复核。" + assert payload["scenario_json"] == ["差旅费"] + assert payload["config_json"]["requires_attachment"] is True + assert payload["config_json"]["generation_status"] == "draft_updated" + assert payload["config_json"]["last_operation"]["action"] == "update_draft" + + +def test_update_published_risk_rule_draft_endpoint_is_blocked(tmp_path) -> None: + client, session_factory = build_client() + asset_id = _create_rule(session_factory, tmp_path) + _mark_rule_published(session_factory, asset_id) + + response = client.patch( + f"/api/v1/agent-assets/{asset_id}/risk-rules/draft", + headers=_finance_headers(), + json={"natural_language": "已上线规则不能被草稿接口直接覆盖。"}, + ) + + assert response.status_code == 400 + assert "未上线" in response.json()["detail"] + + +def test_create_risk_rule_revision_endpoint_keeps_active_version(tmp_path) -> None: + client, session_factory = build_client() + asset_id = _create_rule(session_factory, tmp_path) + _mark_rule_published(session_factory, asset_id) + + response = client.post( + f"/api/v1/agent-assets/{asset_id}/risk-rules/revisions", + headers=_finance_headers(), + json={ + "rule_title": "票据城市一致性复核", + "natural_language": "票据城市与申报目的地不一致时,要求补充说明。", + "requires_attachment": True, + "change_reason": "补充城市一致性判断。", + }, + ) + + assert response.status_code == 201 + payload = response.json() + revision = payload["config_json"]["revision_draft"] + assert payload["status"] == AgentAssetStatus.ACTIVE.value + assert payload["published_version"] == "v0.1.0" + assert payload["working_version"] == "v0.1.1" + assert revision["version"] == "v0.1.1" + assert revision["base_version"] == "v0.1.0" + assert revision["generation_request"]["natural_language"] == "票据城市与申报目的地不一致时,要求补充说明。" + assert payload["config_json"]["last_operation"]["action"] == "create_revision" + + +def _create_rule(session_factory: sessionmaker[Session], tmp_path) -> str: + with session_factory() as db: + return RiskRuleGenerationService( + db, + rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"), + runtime_chat_service=NullRuntimeChatService(), + ).generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + expense_category="travel", + rule_title="差旅规则草稿", + natural_language="差旅报销事由缺失时,提示补充说明。", + ), + actor="pytest", + ) + + +def _mark_rule_published(session_factory: sessionmaker[Session], asset_id: str) -> None: + with session_factory() as db: + asset = db.get(AgentAsset, asset_id) + assert asset is not None + asset.status = AgentAssetStatus.ACTIVE.value + asset.current_version = "v0.1.0" + asset.published_version = "v0.1.0" + asset.working_version = "v0.1.0" + db.add(asset) + db.commit() + + +def _finance_headers() -> dict[str, str]: + return { + "x-auth-username": "finance", + "x-auth-name": "finance", + "x-auth-role-codes": "finance", + "x-actor": "finance", + } diff --git a/server/tests/test_risk_rule_revision_service.py b/server/tests/test_risk_rule_revision_service.py new file mode 100644 index 0000000..3779cb7 --- /dev/null +++ b/server/tests/test_risk_rule_revision_service.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus +from app.db.base import Base +from app.models.agent_asset import AgentAsset, AgentAssetVersion +from app.schemas.agent_asset import ( + AgentAssetRiskRuleDraftUpdate, + AgentAssetRiskRuleGenerateRequest, + AgentAssetRiskRuleRevisionCreate, +) +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.risk_rule_generation import RiskRuleGenerationService +from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService + + +class NullRuntimeChatService: + def complete(self, *args, **kwargs) -> None: + return None + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False)() + + +def test_update_unpublished_risk_rule_draft_updates_business_fields(tmp_path) -> None: + with build_session() as db: + asset_id = _create_rule(db, tmp_path) + updated = AgentAssetRiskRuleRevisionService(db).update_unpublished_draft( + asset_id, + AgentAssetRiskRuleDraftUpdate( + rule_title="差旅绕行说明校验", + expense_category="travel", + natural_language="差旅报销存在绕行但未说明原因时,进入风险复核。", + requires_attachment=True, + ), + actor="finance", + ) + + assert updated.name == "差旅绕行说明校验" + assert updated.description == "差旅报销存在绕行但未说明原因时,进入风险复核。" + assert updated.scenario_json == ["差旅费"] + assert updated.config_json["requires_attachment"] is True + assert updated.config_json["generation_request"]["natural_language"] == updated.description + assert updated.config_json["last_operation"]["action"] == "update_draft" + + +def test_update_published_rule_requires_revision(tmp_path) -> None: + with build_session() as db: + asset_id = _create_rule(db, tmp_path) + asset = db.get(AgentAsset, asset_id) + assert asset is not None + asset.status = AgentAssetStatus.ACTIVE.value + asset.published_version = asset.current_version + db.add(asset) + db.flush() + + with pytest.raises(PermissionError): + AgentAssetRiskRuleRevisionService(db).update_unpublished_draft( + asset_id, + AgentAssetRiskRuleDraftUpdate(natural_language="已上线规则不能直接覆盖。"), + actor="finance", + ) + + +def test_create_revision_draft_for_published_rule_does_not_overwrite_active_version(tmp_path) -> None: + with build_session() as db: + asset_id = _create_rule(db, tmp_path) + asset = db.get(AgentAsset, asset_id) + assert asset is not None + asset.status = AgentAssetStatus.ACTIVE.value + asset.published_version = "v0.1.0" + asset.current_version = "v0.1.0" + asset.working_version = "v0.1.0" + db.add(asset) + db.flush() + + updated = AgentAssetRiskRuleRevisionService(db).create_revision_draft( + asset_id, + AgentAssetRiskRuleRevisionCreate( + rule_title="差旅票据城市复核", + natural_language="票据城市与申报目的地不一致时,要求补充说明。", + requires_attachment=True, + change_reason="补充城市一致性判断。", + ), + actor="manager", + ) + + revision = updated.config_json["revision_draft"] + assert updated.status == AgentAssetStatus.ACTIVE.value + assert updated.published_version == "v0.1.0" + assert updated.working_version == "v0.1.1" + assert revision["version"] == "v0.1.1" + assert revision["base_version"] == "v0.1.0" + assert revision["generation_request"]["natural_language"] == "票据城市与申报目的地不一致时,要求补充说明。" + assert updated.config_json["last_operation"]["action"] == "create_revision" + assert db.query(AgentAssetVersion).filter_by(asset_id=asset_id, version="v0.1.1").one() + + +def _create_rule(db: Session, tmp_path) -> str: + return RiskRuleGenerationService( + db, + rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"), + runtime_chat_service=NullRuntimeChatService(), + ).generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + expense_category="travel", + rule_title="差旅规则草稿", + natural_language="差旅报销事由缺失时,提示补充说明。", + ), + actor="pytest", + ) diff --git a/server/tests/test_risk_rule_semantic_plan_generation.py b/server/tests/test_risk_rule_semantic_plan_generation.py new file mode 100644 index 0000000..3a5444b --- /dev/null +++ b/server/tests/test_risk_rule_semantic_plan_generation.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import json + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.agent_enums import AgentAssetDomain +from app.db.base import Base +from app.models.agent_asset import AgentAsset +from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.risk_rule_generation import RiskRuleGenerationService +from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages +from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload + + +class SemanticPlanEnvelopeRuntimeChatService: + def complete(self, *args, **kwargs) -> str: + return json.dumps( + { + "semantic_plan": { + "rule_intent": "费用申请金额不得超过可用预算", + "judgment_steps": [ + "读取申请金额", + "读取可用预算余额", + "比较申请金额是否大于可用预算", + ], + }, + "dsl": { + "name": "预算余额超额校验", + "description": "申请金额超过当前可用预算时提示风险。", + "template_key": "composite_rule_v1", + "semantic_type": "budget_available_balance_check", + "field_keys": ["claim.amount", "budget.remaining_amount"], + "condition_summary": "claim.amount > budget.remaining_amount", + "conditions": [ + { + "id": "amount_exceeds_budget", + "operator": "numeric_compare", + "left_fields": ["claim.amount"], + "right_fields": ["budget.remaining_amount"], + "compare": "gt", + } + ], + "hit_logic": {"all": ["amount_exceeds_budget"]}, + "message_template": "申请金额超过当前可用预算余额。", + "keywords": [], + }, + }, + ensure_ascii=False, + ) + + +class SemanticPlanOnlyRuntimeChatService: + def complete(self, *args, **kwargs) -> str: + return json.dumps( + { + "semantic_plan": { + "rule_intent": "费用申请金额超过可用预算余额时提示风险", + "required_fields": [ + {"field": "claim.amount", "label": "申请金额"}, + {"field": "budget.remaining_amount", "label": "可用预算余额"}, + ], + "judgment_steps": [ + "读取申请金额 claim.amount", + "读取可用预算余额 budget.remaining_amount", + "若申请金额超过可用预算余额则命中预算风险", + ], + "risk_action": {"message": "要求补充预算审批说明"}, + } + }, + ensure_ascii=False, + ) + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False)() + + +def test_prompt_requires_semantic_plan_then_dsl() -> None: + messages = build_risk_rule_compiler_messages( + domain="expense", + domain_label="报销", + business_stage="expense_application", + business_stage_label="费用申请", + expense_category="travel", + expense_category_label="差旅费", + natural_language="申请金额超过预算余额时提示风险。", + available_fields=[{"key": "claim.amount", "label": "申请金额", "type": "number", "source": "claim"}], + ) + + request_payload = json.loads(messages[1]["content"]) + required_shape = request_payload["required_json_shape"] + assert "semantic_plan" in required_shape + assert "dsl" in required_shape + assert "semantic_plan 和 dsl" in messages[0]["content"] + + +def test_semantic_plan_envelope_is_unwrapped_and_persisted(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + service = RiskRuleGenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=SemanticPlanEnvelopeRuntimeChatService(), + ) + asset_id = service.generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + business_stage="expense_application", + expense_category="travel", + rule_title="预算余额超额校验", + natural_language="费用申请时,如果申请金额超过当前可用预算余额,则提示预算风险。", + ), + actor="pytest", + ) + + asset = db.get(AgentAsset, asset_id) + assert asset is not None + payload = manager.read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=asset.config_json["rule_document"]["file_name"], + ) + + assert payload["template_key"] == "composite_rule_v1" + assert payload["params"]["conditions"][0]["operator"] == "numeric_compare" + assert payload["metadata"]["model_semantic_plan"]["rule_intent"] == "费用申请金额不得超过可用预算" + assert payload["semantic_plan"]["judgment_steps"] + + +def test_semantic_plan_only_response_can_generate_standard_dsl(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + service = RiskRuleGenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=SemanticPlanOnlyRuntimeChatService(), + ) + asset_id = service.generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + business_stage="expense_application", + expense_category="travel", + rule_title="预算余额语义计划校验", + natural_language="费用申请金额超过可用预算余额时提示风险,并要求补充审批说明。", + ), + actor="pytest", + ) + + asset = db.get(AgentAsset, asset_id) + assert asset is not None + payload = manager.read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=asset.config_json["rule_document"]["file_name"], + ) + + assert payload["params"]["conditions"][0]["operator"] == "numeric_compare" + assert payload["params"]["conditions"][0]["left_fields"] == ["claim.amount"] + assert payload["params"]["conditions"][0]["right_fields"] == ["budget.remaining_amount"] + assert payload["metadata"]["model_semantic_plan"]["required_fields"] + + +def test_unwrap_semantic_plan_payload_keeps_legacy_payload_compatible() -> None: + legacy = {"template_key": "field_required_v1", "field_keys": ["claim.reason"]} + assert unwrap_semantic_plan_payload(legacy) == legacy + + wrapped = unwrap_semantic_plan_payload( + { + "semantic_plan": {"rule_intent": "预算校验"}, + "dsl": {"template_key": "composite_rule_v1", "field_keys": ["claim.amount"]}, + } + ) + assert wrapped["template_key"] == "composite_rule_v1" + assert wrapped["model_semantic_plan"]["rule_intent"] == "预算校验" + + plan_only = unwrap_semantic_plan_payload( + { + "semantic_plan": { + "rule_intent": "预算校验", + "required_fields": [{"field": "claim.amount"}], + "judgment_steps": ["申请金额超过预算余额"], + } + } + ) + assert plan_only["template_key"] == "composite_rule_v1" + assert plan_only["field_keys"] == ["claim.amount"] diff --git a/server/tests/test_runtime_chat_service.py b/server/tests/test_runtime_chat_service.py index fdf35fa..7d0ead8 100644 --- a/server/tests/test_runtime_chat_service.py +++ b/server/tests/test_runtime_chat_service.py @@ -55,6 +55,39 @@ def test_runtime_chat_fails_over_to_backup_before_retrying_main(monkeypatch) -> assert calls == ["main", "backup"] +def test_runtime_chat_complete_with_trace_records_slot_failover(monkeypatch) -> None: + _clear_runtime_chat_cooldown() + session_factory = build_session_factory() + with session_factory() as db: + service = RuntimeChatService(db) + + def fake_load_chat_slot(slot: str): + return { + "slot": slot, + "provider": "MiniMax" if slot == "main" else "GLM", + "endpoint": "https://example.com/v1", + "model": "main-model" if slot == "main" else "backup-model", + "apiKey": "secret", + } + + def fake_request_chat_completion(config, messages, *, max_tokens, temperature, timeout_seconds): + del messages, max_tokens, temperature, timeout_seconds + if config["slot"] == "main": + raise RuntimeError("incorrect api key") + return "backup answer" + + monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot) + monkeypatch.setattr(service, "_request_chat_completion", fake_request_chat_completion) + + result = service.complete_with_trace([{"role": "user", "content": "hello"}]) + + assert result.text == "backup answer" + assert [item.status for item in result.calls] == ["failed", "succeeded"] + assert result.calls[0].provider == "MiniMax" + assert result.calls[0].error_message == "incorrect api key" + assert result.calls_as_dicts()[1]["model"] == "backup-model" + + def test_runtime_chat_does_not_rehit_failed_slots_during_cooldown(monkeypatch) -> None: _clear_runtime_chat_cooldown() session_factory = build_session_factory() diff --git a/server/tests/test_system_dashboard_service.py b/server/tests/test_system_dashboard_service.py new file mode 100644 index 0000000..ad4d4c1 --- /dev/null +++ b/server/tests/test_system_dashboard_service.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.agent_feedback import AgentOperationFeedback +from app.models.agent_run import AgentRun, AgentToolCall +from app.models.user_session_metric import UserSessionMetric +from app.services.system_dashboard import SystemDashboardService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_system_dashboard_service_aggregates_real_runtime_metrics() -> None: + now = datetime.now(UTC) + + with build_session() as db: + db.add( + AgentRun( + run_id="run-dashboard-001", + agent="orchestrator", + source="user_message", + user_id="chen.yuqing@example.com", + status="succeeded", + started_at=now - timedelta(hours=3), + finished_at=now - timedelta(hours=3, minutes=-1), + tool_calls=[ + AgentToolCall( + run_id="run-dashboard-001", + tool_type="llm", + tool_name="expense_claim.review", + request_json={"prompt_tokens": 120}, + response_json={"completion_tokens": 80}, + status="succeeded", + duration_ms=1800, + created_at=now - timedelta(hours=3), + ), + AgentToolCall( + run_id="run-dashboard-001", + tool_type="ocr", + tool_name="invoice.ocr", + request_json={"image_count": 1}, + response_json={"message": "识别失败"}, + status="failed", + duration_ms=2600, + error_message="low confidence", + created_at=now - timedelta(hours=2), + ), + ], + ) + ) + db.add( + AgentRun( + run_id="run-dashboard-002", + agent="hermes", + source="schedule", + user_id="gu.chengyu@example.com", + status="failed", + started_at=now - timedelta(hours=1), + finished_at=now, + tool_calls=[ + AgentToolCall( + run_id="run-dashboard-002", + tool_type="knowledge", + tool_name="policy.rag", + request_json={"total_tokens": 90}, + response_json={}, + status="succeeded", + duration_ms=900, + created_at=now - timedelta(hours=1), + ), + ], + ) + ) + db.add_all( + [ + UserSessionMetric( + session_id="session-dashboard-001", + username="chen.yuqing@example.com", + display_name="陈雨晴", + email="chen.yuqing@example.com", + login_at=now - timedelta(hours=4), + logout_at=now - timedelta(hours=3), + duration_ms=60 * 60 * 1000, + activity_event_count=16, + status="closed", + ), + UserSessionMetric( + session_id="session-dashboard-002", + username="gu.chengyu@example.com", + display_name="顾成宇", + email="gu.chengyu@example.com", + login_at=now - timedelta(minutes=25), + last_activity_at=now - timedelta(minutes=5), + activity_event_count=9, + status="active", + ), + ] + ) + db.add_all( + [ + AgentOperationFeedback( + run_id="run-dashboard-001", + user_id="chen.yuqing@example.com", + agent="orchestrator", + rating=5, + created_at=now - timedelta(hours=2), + ), + AgentOperationFeedback( + run_id="run-dashboard-002", + user_id="gu.chengyu@example.com", + agent="hermes", + rating=2, + created_at=now - timedelta(hours=1), + ), + ] + ) + db.commit() + + dashboard = SystemDashboardService(db).build_dashboard(days=7) + + assert dashboard.has_real_data is True + assert dashboard.totals["toolCalls"] == 3 + assert dashboard.totals["modelTokens"] >= 290 + assert dashboard.totals["onlineUsers"] == 1 + assert dashboard.totals["executionSuccessRate"] == 50.0 + assert dashboard.totals["positiveFeedback"] == 1 + assert dashboard.totals["negativeFeedback"] == 1 + assert dashboard.user_token_usage[0]["tokens"] >= 200 + assert "陈雨晴" in {item["name"] for item in dashboard.user_token_usage} + assert dashboard.accuracy_comparison["correct"][ + dashboard.accuracy_comparison["categories"].index("报销预审") + ] == 1 + assert dashboard.accuracy_comparison["wrong"][ + dashboard.accuracy_comparison["categories"].index("异常诊断") + ] == 1 diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 7a3ff10..16e652b 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -209,7 +209,7 @@ def test_user_agent_application_context_uses_application_language() -> None: assert "费用申请" in response.answer assert "| 字段 | 内容 |" in response.answer - assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer + assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer assert "支持上海国网服务器部署" in response.answer assert "当前还需要补充:出行方式、用户预估费用" in response.answer assert "请先在下面选择报销场景" not in response.answer @@ -224,7 +224,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date() with session_factory() as db: response = build_application_user_agent_response(db, message) - assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer + assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer assert "| 地点 | 上海市 |" in response.answer assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer assert "当前还需要先补充:申请事由" not in response.answer @@ -250,7 +250,7 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None: yili_response = build_application_user_agent_response(db, yili_message) beijing_response = build_application_user_agent_response(db, beijing_message) - assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in yili_response.answer + assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer assert "| 地点 | 新疆,伊犁 |" in yili_response.answer assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer assert "伊犁出差" not in yili_response.answer @@ -328,10 +328,27 @@ def test_user_agent_application_missing_base_actions_prefill_composer() -> None: "地点:上海\n事由:支撑国网服务器部署\n天数:3天", ) - assert "当前还需要补充:发生时间、出行方式、用户预估费用" in response.answer + assert "当前还需要补充:出行方式、用户预估费用" in response.answer assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] assert response.suggested_actions[0].action_type == "prefill_composer" - assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n用户预估费用:" + assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:" + + +def test_user_agent_application_precomputes_time_from_today_and_days() -> None: + session_factory = build_session_factory() + with session_factory() as db: + response = build_application_user_agent_response( + db, + "去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元", + context_overrides={ + "client_now_iso": "2026-05-28T16:30:00.000Z", + "client_timezone_offset_minutes": -480, + }, + ) + + assert "这是模拟的费用申请结果" in response.answer + assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer + assert response.requires_confirmation is True def test_user_agent_application_builds_preview_when_amount_is_ready() -> None: @@ -397,9 +414,10 @@ def test_user_agent_application_submit_enters_leader_review() -> None: ], ) - assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer - assert "当前状态:陈硕审核中" in response.answer - assert "预算占用参考" in response.answer + assert "申请单据已生成,并已进入审批流程" in response.answer + assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in response.answer + assert "下方是简要单据信息" in response.answer + assert "申请信息:" not in response.answer assert re.search(r"AP-\d{14}-[A-HJ-NP-Z2-9]{8}", response.answer) assert response.suggested_actions == [] claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one() diff --git a/web/src/assets/styles/components/digital-employee-run-products.css b/web/src/assets/styles/components/digital-employee-run-products.css new file mode 100644 index 0000000..19a32b2 --- /dev/null +++ b/web/src/assets/styles/components/digital-employee-run-products.css @@ -0,0 +1,283 @@ +.run-products-card { + overflow: hidden; +} + +.run-product-state { + margin: 0 12px 12px; + min-height: 96px; + display: grid; + place-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + border: 1px dashed #d8e2ee; + border-radius: 4px; + background: #f8fafc; +} + +.run-product-state .mdi { + color: #2563eb; + font-size: 24px; +} + +.run-product-state.error { + color: #b91c1c; + border-color: #fecaca; + background: #fef2f2; +} + +.run-product-state.error .mdi { + color: #dc2626; +} + +.run-product-meta-grid { + padding: 0 12px 12px; +} + +.run-product-section { + margin: 0 12px 12px; + padding-top: 12px; + border-top: 1px solid #edf2f7; +} + +.run-product-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.run-product-section-head h4 { + margin: 0; + color: #0f172a; + font-size: 14px; + font-weight: 800; +} + +.run-product-section-head span { + flex: 0 0 auto; + color: #64748b; + font-size: 12px; +} + +.run-product-observation-list { + display: grid; + gap: 8px; +} + +.run-product-observation { + width: 100%; + padding: 10px 12px; + text-align: left; + border: 1px solid #e5edf6; + border-radius: 4px; + background: #fbfdff; + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.run-product-observation:hover, +.run-product-observation:focus-visible, +.run-product-observation.is-expanded { + border-color: rgba(37, 99, 235, 0.32); + background: #ffffff; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); + outline: none; +} + +.run-product-observation-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.run-product-observation-head strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 13px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-product-observation-head b { + color: #111827; + font-size: 14px; +} + +.run-product-observation p, +.run-product-copy { + margin: 8px 0 0; + color: #475569; + font-size: 13px; + line-height: 1.65; +} + +.run-product-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.run-product-tags span { + max-width: 100%; + padding: 4px 8px; + overflow: hidden; + color: #475569; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #ffffff; +} + +.run-product-observation-detail { + display: grid; + gap: 10px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e5edf6; +} + +.run-product-observation-detail section { + display: grid; + gap: 8px; +} + +.run-product-observation-detail section > span { + color: #334155; + font-size: 12px; + font-weight: 800; +} + +.run-product-score-list { + display: grid; + gap: 7px; +} + +.run-product-score-list div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 34px; + align-items: center; + gap: 8px; +} + +.run-product-score-list em { + overflow: hidden; + color: #64748b; + font-size: 12px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-product-score-list i { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: #e2e8f0; +} + +.run-product-score-list i b { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #2563eb, #22c55e); +} + +.run-product-score-list strong { + color: #0f172a; + font-size: 12px; + text-align: right; +} + +.run-product-evidence-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.run-product-evidence-list li { + padding: 8px 10px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #f8fafc; +} + +.run-product-evidence-list strong { + display: block; + color: #0f172a; + font-size: 12px; +} + +.run-product-evidence-list p { + margin: 4px 0 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.run-product-inline-empty { + margin: 0; + padding: 12px; + color: #94a3b8; + font-size: 13px; + border: 1px dashed #d8e2ee; + border-radius: 4px; + background: #f8fafc; +} + +.risk-level-pill { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 8px; + color: #475569; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.risk-level-pill.critical, +.risk-level-pill.high { + color: #b91c1c; + border-color: #fecaca; + background: #fef2f2; +} + +.risk-level-pill.medium { + color: #92400e; + border-color: #fed7aa; + background: #fff7ed; +} + +.risk-level-pill.low { + color: #166534; + border-color: #bbf7d0; + background: #f0fdf4; +} + +@media (max-width: 760px) { + .run-product-observation-head { + grid-template-columns: minmax(0, 1fr) auto; + } + + .run-product-score-list div { + grid-template-columns: 64px minmax(0, 1fr) 30px; + } + + .risk-level-pill { + grid-column: 1 / -1; + width: fit-content; + } +} diff --git a/web/src/assets/styles/components/personal-workbench-composer-date.css b/web/src/assets/styles/components/personal-workbench-composer-date.css new file mode 100644 index 0000000..201902a --- /dev/null +++ b/web/src/assets/styles/components/personal-workbench-composer-date.css @@ -0,0 +1,197 @@ +.workbench-date-anchor { + display: inline-flex; +} + +.composer-icon-button.active { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.42); + background: var(--workbench-primary-soft); + color: var(--workbench-primary-active); + box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12); +} + +.workbench-date-chip-row { + display: flex; + align-items: center; + min-height: 28px; +} + +.workbench-date-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + min-height: 26px; + padding: 0 8px; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3); + border-radius: 4px; + background: var(--workbench-primary-soft); + color: var(--workbench-primary-active); + font-size: 12px; + font-weight: 800; + line-height: 1; +} + +.workbench-date-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workbench-date-chip button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 18px; + height: 18px; + border-radius: 4px; + color: inherit; +} + +.workbench-date-chip button:hover { + background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12); +} + +.workbench-date-chip button:disabled { + opacity: 0.48; + cursor: not-allowed; +} + +.composer-date-popover { + position: absolute; + top: calc(100% + 8px); + left: 18px; + z-index: 60; + width: min(320px, calc(100% - 36px)); + max-width: calc(100vw - 32px); + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--workbench-line-strong); + border-radius: 4px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14), 0 4px 12px rgba(15, 23, 42, 0.06); +} + +.composer-date-mode-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 4px; + border-radius: 4px; + background: #f1f5f9; +} + +.composer-date-mode-btn { + min-height: 34px; + border-radius: 4px; + color: var(--workbench-muted); + font-size: 12px; + font-weight: 800; +} + +.composer-date-mode-btn.active { + background: #fff; + color: var(--workbench-ink); + box-shadow: 0 4px 10px rgba(148, 163, 184, 0.16); +} + +.composer-date-fields { + display: grid; + gap: 8px; +} + +.composer-date-fields-range { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: end; +} + +.composer-date-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.composer-date-field span { + color: var(--workbench-muted); + font-size: 11px; + font-weight: 800; +} + +.composer-date-field input { + width: 100%; + min-height: 36px; + padding: 0 10px; + border: 1px solid var(--workbench-line-strong); + border-radius: 4px; + background: #fff; + color: var(--workbench-ink); + font-size: 12px; + font-weight: 700; +} + +.composer-date-field input:focus { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.46); + box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); + outline: none; +} + +.composer-date-range-sep { + align-self: center; + color: #94a3b8; + font-size: 12px; + font-weight: 800; +} + +.composer-date-hint { + margin: 0; + color: #dc2626; + font-size: 11px; + line-height: 1.5; +} + +.composer-date-popover-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.composer-date-cancel-btn, +.composer-date-apply-btn { + min-height: 34px; + padding: 0 14px; + border-radius: 4px; + font-size: 12px; + font-weight: 800; +} + +.composer-date-cancel-btn { + border: 1px solid var(--workbench-line-strong); + background: #fff; + color: var(--workbench-muted); +} + +.composer-date-apply-btn { + background: var(--workbench-primary-active); + color: #fff; +} + +.composer-date-apply-btn:disabled { + opacity: 0.48; + cursor: not-allowed; +} + +@media (max-width: 480px) { + .composer-date-popover { + left: 12px; + width: calc(100% - 24px); + } + + .composer-date-fields-range { + grid-template-columns: minmax(0, 1fr); + } + + .composer-date-range-sep { + display: none; + } +} diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index 15cd9e1..967d89f 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -30,7 +30,6 @@ } .composer-icon-button, - .composer-related-button, .composer-send-button { height: 32px; } @@ -287,7 +286,6 @@ } .composer-icon-button, - .composer-related-button, .composer-send-button { height: 30px; font-size: 13px; @@ -297,11 +295,6 @@ width: 30px; } - .composer-related-button { - padding: 0 10px; - gap: 4px; - } - .composer-send-button { width: 46px; } diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 92b0330..4743917 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -127,6 +127,8 @@ .assistant-file-input { display: none; } .assistant-composer { + position: relative; + z-index: 5; display: grid; gap: 6px; min-height: var(--composer-min-height); @@ -160,6 +162,32 @@ .assistant-composer textarea:focus { outline: none; } +.assistant-composer textarea[readonly] { + color: color-mix(in srgb, var(--workbench-ink) 72%, #ffffff); + cursor: progress; +} + +.assistant-intent-status { + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + min-height: 28px; + gap: 8px; + padding: 0 10px; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22); + border-radius: 4px; + background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08); + color: var(--workbench-primary-active); + font-size: 12px; + font-weight: 750; + line-height: 1.35; +} + +.assistant-intent-status i { + font-size: 15px; +} + .composer-toolbar { display: flex; align-items: center; @@ -167,7 +195,6 @@ } .composer-icon-button, -.composer-related-button, .composer-send-button { display: inline-flex; align-items: center; @@ -185,16 +212,6 @@ font-size: 19px; } -.composer-related-button { - gap: 8px; - padding: 0 16px; - border: 1px solid var(--workbench-line); - background: var(--workbench-surface); - color: var(--workbench-text); - font-size: 14px; - font-weight: 700; -} - .composer-count { margin-left: auto; color: color-mix(in srgb, var(--workbench-muted) 75%, #ffffff); @@ -691,8 +708,7 @@ .todo-row:hover, .progress-row:hover, .quick-prompts button:hover, -.composer-icon-button:hover, -.composer-related-button:hover { +.composer-icon-button:hover { border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); color: var(--workbench-primary-active); } diff --git a/web/src/assets/styles/components/risk-observation-evidence-card.css b/web/src/assets/styles/components/risk-observation-evidence-card.css new file mode 100644 index 0000000..dac4ae9 --- /dev/null +++ b/web/src/assets/styles/components/risk-observation-evidence-card.css @@ -0,0 +1,334 @@ +.risk-observation-evidence-card { + display: grid; + gap: 14px; +} + +.risk-evidence-refresh { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 10px; + border: 1px solid #dbe5ef; + border-radius: 4px; + background: #fff; + color: #334155; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.risk-evidence-refresh:disabled { + cursor: not-allowed; + opacity: .72; +} + +.risk-evidence-state { + min-height: 112px; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.risk-evidence-state i { + color: #94a3b8; + font-size: 24px; +} + +.risk-evidence-state.error { + color: #b91c1c; +} + +.risk-evidence-current-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid #e6edf5; + border-radius: 4px; + background: #fff; +} + +.risk-evidence-current-head div { + min-width: 0; + display: grid; + gap: 3px; +} + +.risk-evidence-current-head span { + color: #64748b; + font-size: 12px; + font-weight: 850; +} + +.risk-evidence-current-head strong { + overflow: hidden; + color: #0f172a; + font-size: 14px; + font-weight: 900; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.risk-evidence-current-head em { + flex: 0 0 auto; + color: #64748b; + font-size: 12px; + font-style: normal; + font-weight: 850; +} + +.risk-evidence-detail-region { + display: grid; + gap: 12px; +} + +.risk-evidence-summary { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + gap: 14px; + align-items: stretch; + padding: 14px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #f8fafc; +} + +.risk-evidence-score { + display: grid; + place-content: center; + justify-items: center; + gap: 4px; + min-height: 82px; + border-radius: 4px; + background: #e2e8f0; + color: #475569; +} + +.risk-evidence-score.critical, +.risk-evidence-score.high { + background: rgba(239, 68, 68, .1); + color: #b91c1c; +} + +.risk-evidence-score.medium { + background: rgba(245, 158, 11, .12); + color: #b45309; +} + +.risk-evidence-score strong { + font-size: 28px; + font-weight: 900; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.risk-evidence-score span { + font-size: 12px; + font-weight: 900; +} + +.risk-evidence-copy { + min-width: 0; + display: grid; + gap: 8px; + align-content: center; +} + +.risk-evidence-copy h4 { + min-width: 0; + color: #0f172a; + font-size: 15px; + font-weight: 850; + line-height: 1.35; +} + +.risk-evidence-copy p { + color: #475569; + font-size: 13px; + font-weight: 600; + line-height: 1.55; +} + +.risk-evidence-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.risk-evidence-meta span, +.risk-chip-list span { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid #dbe5ef; + border-radius: 4px; + background: #fff; + color: #475569; + font-size: 12px; + font-weight: 800; +} + +.risk-evidence-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.risk-evidence-section { + min-width: 0; + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #fff; +} + +.risk-evidence-section-title { + color: #334155; + font-size: 12px; + font-weight: 900; +} + +.risk-score-list { + display: grid; + gap: 8px; +} + +.risk-score-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 34px; + align-items: center; + gap: 8px; +} + +.risk-score-row span { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.risk-score-row i { + height: 7px; + overflow: hidden; + border-radius: 999px; + background: #edf2f7; +} + +.risk-score-row b { + display: block; + height: 100%; + border-radius: inherit; + background: var(--theme-primary); +} + +.risk-score-row strong { + color: #0f172a; + font-size: 12px; + font-weight: 900; + text-align: right; +} + +.risk-evidence-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.risk-evidence-list li { + display: grid; + gap: 3px; +} + +.risk-evidence-list strong { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.risk-evidence-list span, +.risk-evidence-empty, +.risk-chip-list em { + color: #64748b; + font-size: 12px; + font-style: normal; + font-weight: 650; + line-height: 1.5; +} + +.risk-chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.risk-observation-list { + display: grid; + gap: 8px; +} + +.risk-observation-row { + display: grid; + grid-template-columns: 80px minmax(0, 1fr) 42px; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 8px 10px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #fff; + text-align: left; + cursor: pointer; +} + +.risk-observation-row.active, +.risk-observation-row:hover, +.risk-observation-row:focus-visible { + border-color: rgba(var(--theme-primary-rgb), .24); + background: #f8fafc; +} + +.risk-observation-row:focus-visible { + outline: 2px solid rgba(var(--theme-primary-rgb), .28); + outline-offset: 2px; +} + +.risk-observation-row span, +.risk-observation-row em { + color: #64748b; + font-size: 12px; + font-style: normal; + font-weight: 850; +} + +.risk-observation-row strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 13px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.risk-observation-row em { + color: #0f172a; + text-align: right; +} + +@media (max-width: 960px) { + .risk-evidence-summary, + .risk-evidence-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index 546e8da..bca06cc 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -124,6 +124,49 @@ position: relative; } +.dashboard-switch-wrap { + width: 190px; + flex: 0 0 190px; + height: 44px; + display: flex; + align-items: center; +} + +.dashboard-switch-select { + width: 100%; +} + +.dashboard-switch-select :deep(.el-select__wrapper) { + height: 44px; + min-height: 44px; + border-radius: 4px; + background: #f8fbfd; + box-shadow: + 0 0 0 2px rgba(var(--theme-primary-rgb, 58, 124, 165), .48) inset, + 0 1px 2px rgba(15, 23, 42, .05); +} + +.dashboard-switch-select :deep(.el-select__wrapper:hover) { + background: #f3f9fd; + box-shadow: + 0 0 0 2px rgba(var(--theme-primary-rgb, 58, 124, 165), .72) inset, + 0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), .08); +} + +.dashboard-switch-select :deep(.el-select__wrapper.is-focused) { + background: #ffffff; + box-shadow: + 0 0 0 2px var(--theme-primary) inset, + 0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), .14); +} + +.dashboard-switch-select :deep(.el-select__placeholder), +.dashboard-switch-select :deep(.el-select__selected-item) { + color: #1e293b; + font-size: 13px; + font-weight: 800; +} + .custom-range-btn { height: 42px; display: inline-flex; @@ -507,6 +550,11 @@ justify-content: center; } + .dashboard-switch-wrap { + width: 100%; + flex: 1 1 100%; + } + .calendar-popover { right: auto; left: 0; diff --git a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css index 339affb..549b833 100644 --- a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css +++ b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css @@ -24,7 +24,7 @@ grid-template-rows: auto minmax(0, 1fr); overflow: hidden; border: 1px solid rgba(189, 201, 214, 0.74); - border-radius: 16px; + border-radius: 4px; background: #ffffff; box-shadow: 0 14px 32px rgba(148, 163, 184, 0.16); opacity: 1; @@ -63,7 +63,7 @@ gap: 12px; padding: 16px; border-bottom: 1px solid #e2e8f0; - background: linear-gradient(180deg, #f8fbff, #ffffff); + background: #ffffff; } .insight-head h3 { @@ -96,7 +96,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: #eff6ff; color: #2563eb; font-size: 11px; @@ -111,7 +111,7 @@ gap: 10px; padding: 12px; border: 1px solid #e2e8f0; - border-radius: 12px; + border-radius: 4px; background: #ffffff; } @@ -126,7 +126,6 @@ .note-block p, .review-side-head p, .review-side-risk-summary, -.flow-step-tool, .flow-step-detail, .flow-step-card time { margin: 0; @@ -166,7 +165,7 @@ display: grid; gap: 6px; padding: 10px; - border-radius: 10px; + border-radius: 4px; background: #f8fbff; } @@ -187,7 +186,7 @@ display: inline-flex; align-items: center; padding: 0 8px; - border-radius: 999px; + border-radius: 4px; background: #eff6ff; color: #2563eb; font-size: 11px; @@ -202,7 +201,7 @@ .flow-step-card, .review-document-preview-card { border: 1px solid #e2e8f0; - border-radius: 10px; + border-radius: 4px; background: #ffffff; } @@ -229,7 +228,7 @@ height: 30px; display: grid; place-items: center; - border-radius: 8px; + border-radius: 4px; background: #eff6ff; color: #2563eb; font-size: 11px; @@ -255,7 +254,7 @@ height: 30px; display: grid; place-items: center; - border-radius: 8px; + border-radius: 4px; background: #eff6ff; color: var(--theme-primary, #3a7ca5); } @@ -295,7 +294,7 @@ .review-document-nav-btn, .review-side-save-pill { border: 1px solid #cbd5e1; - border-radius: 8px; + border-radius: 4px; background: #ffffff; color: #334155; } @@ -383,7 +382,7 @@ display: grid; gap: 7px; padding: 10px 11px; - border-radius: 8px; + border-radius: 4px; background: #f8fafc; box-shadow: none; transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; @@ -410,7 +409,6 @@ color: #ffffff; } -.flow-step-item.completed .flow-step-tool, .flow-step-item.completed .flow-step-detail, .flow-step-item.completed .flow-step-card time { color: #cbd5e1; @@ -447,7 +445,7 @@ gap: 8px; padding: 18px; border: 1px dashed #cbd5e1; - border-radius: 10px; + border-radius: 4px; background: #f8fbff; color: #64748b; text-align: center; @@ -459,7 +457,7 @@ width: 100%; min-height: 34px; border: 1px solid #cbd5e1; - border-radius: 8px; + border-radius: 4px; background: #ffffff; color: #0f172a; font: inherit; diff --git a/web/src/assets/styles/components/travel-reimbursement-message-application.css b/web/src/assets/styles/components/travel-reimbursement-message-application.css new file mode 100644 index 0000000..78f3848 --- /dev/null +++ b/web/src/assets/styles/components/travel-reimbursement-message-application.css @@ -0,0 +1,191 @@ +.application-preview-date-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1); + color: var(--theme-primary-active, #255b7d); + font-weight: 850; +} + +.application-draft-preview { + width: min(100%, 620px); + max-width: 620px; + gap: 12px; + background: #ffffff; +} + +.application-draft-preview .application-draft-head { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: start; + justify-content: initial; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid #e6edf5; +} + +.application-draft-icon { + width: 34px; + height: 34px; + display: inline-grid; + place-items: center; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22); + border-radius: 4px; + background: #f4f9fc; + color: var(--theme-primary-active, #255b7d); + font-size: 18px; +} + +.application-draft-title { + min-width: 0; + display: grid; + gap: 3px; +} + +.application-draft-title strong { + color: #102033; + font-size: 14px; + font-weight: 850; + line-height: 1.35; +} + +.application-draft-title small { + color: #667085; + font-size: 12px; + line-height: 1.45; +} + +.application-draft-status { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); + border-radius: 4px; + background: #f7fbff; + color: var(--theme-primary-active, #255b7d); + font-size: 11px; + font-weight: 850; + line-height: 1; +} + +.application-draft-brief { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + border: 1px solid #d7e4f2; + border-radius: 4px; + overflow: hidden; + background: #ffffff; +} + +.application-draft-brief-item { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 42px; + padding: 8px 12px; + border-top: 1px solid #edf2f7; + border-left: 1px solid #edf2f7; + font-size: 12px; +} + +.application-draft-brief-item:nth-child(even) { + border-left: 0; +} + +.application-draft-brief-item.is-primary { + grid-column: 1 / -1; + grid-template-columns: 42px minmax(0, 1fr); + min-height: 48px; + border-top: 0; + border-left: 0; + background: #f8fbff; +} + +.application-draft-brief-item span { + color: #64748b; + font-weight: 760; +} + +.application-draft-brief-item strong { + min-width: 0; + overflow-wrap: anywhere; + color: #0f172a; + font-weight: 850; +} + +.application-draft-brief-item.is-primary strong { + color: #102033; + font-size: 13px; +} + +.application-draft-footer { + display: block; +} + +.application-draft-footer p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.application-draft-detail-link { + display: inline; + margin: 0 1px; + padding: 0; + border: 0; + border-radius: 2px; + background: transparent; + color: var(--theme-primary-active, #255b7d); + font: inherit; + font-weight: 850; + line-height: inherit; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; + transition: color 0.18s ease, outline-color 0.18s ease; +} + +.application-draft-detail-link:hover:not(:disabled) { + color: var(--theme-primary, #3a7ca5); +} + +.application-draft-detail-link:focus-visible { + outline: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); + outline-offset: 2px; +} + +.application-draft-detail-link:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +@media (max-width: 640px) { + .application-draft-preview .application-draft-head { + grid-template-columns: 34px minmax(0, 1fr); + } + + .application-draft-status { + grid-column: 2; + justify-self: start; + } + + .application-draft-brief { + grid-template-columns: 1fr; + } + + .application-draft-brief-item { + border-left: 0; + border-top: 1px solid #edf2f7; + } + + .application-draft-brief-item.is-primary { + border-top: 0; + } + +} diff --git a/web/src/assets/styles/components/travel-reimbursement-message-item.css b/web/src/assets/styles/components/travel-reimbursement-message-item.css index 29684a7..4867d2d 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-item.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-item.css @@ -1,3 +1,5 @@ +@import "./travel-reimbursement-message-application.css"; + .message-row { display: grid; grid-template-columns: 38px minmax(0, 1fr); @@ -58,6 +60,13 @@ max-width: min(100%, 1080px); } +.message-feedback-bubble { + grid-column: 2; + justify-self: start; + max-width: min(100%, 420px); + margin-top: -2px; +} + .message-bubble-review-risk-low, .message-bubble-review-risk-medium, .message-bubble-review-risk-high { @@ -230,14 +239,6 @@ color: var(--theme-primary-active, #2f6d95); } -.message-meta-row { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 10px; -} - -.message-meta-chip, .message-risk-chip, .file-chip { min-height: 26px; diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css index 5752e04..c5cbe1c 100644 --- a/web/src/assets/styles/views/audit-view.css +++ b/web/src/assets/styles/views/audit-view.css @@ -456,6 +456,11 @@ td:first-child { text-align: left; } +.audit-asset-table th:first-child, +.audit-asset-table td:first-child { + text-align: left; +} + tbody tr:hover { background: #f8fbff; } @@ -474,6 +479,13 @@ tbody tr.is-disabled:hover { grid-template-columns: 38px minmax(0, 1fr); gap: 10px; align-items: center; + justify-items: start; + text-align: left; +} + +.skill-name-cell > div { + min-width: 0; + text-align: left; } .skill-avatar { diff --git a/web/src/assets/styles/views/chat-view.css b/web/src/assets/styles/views/chat-view.css index 2bb2f55..3c5e94c 100644 --- a/web/src/assets/styles/views/chat-view.css +++ b/web/src/assets/styles/views/chat-view.css @@ -71,8 +71,6 @@ .agent-answer-markdown :deep(th) { background: var(--theme-primary-light-9); color: #0f172a; font-weight: 800; } .agent-answer-markdown :deep(td) { color: #334155; font-weight: 650; } .agent-answer-markdown :deep(tbody tr:last-child td) { border-bottom: 0; } -.agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; } -.agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--theme-primary-light-9); color: var(--theme-primary-active); font-size: 12px; font-weight: 760; } .agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; } .agent-detail-block > strong { color: #0f172a; font-size: 12px; font-weight: 820; } .agent-citation-disclosure { overflow: hidden; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; } diff --git a/web/src/assets/styles/views/digital-employees-view.css b/web/src/assets/styles/views/digital-employees-view.css index c7f4884..7f37951 100644 --- a/web/src/assets/styles/views/digital-employees-view.css +++ b/web/src/assets/styles/views/digital-employees-view.css @@ -67,6 +67,41 @@ text-align: left; } +.digital-skill-cell { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 10px; + align-items: center; + justify-items: start; + text-align: left; +} + +.digital-skill-avatar { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 11px; + color: #fff; + font-size: 13px; + font-weight: 900; +} + +.digital-skill-avatar.primary { background: var(--theme-gradient-primary); } +.digital-skill-avatar.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); } +.digital-skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); } +.digital-skill-avatar.blue { + background: linear-gradient(135deg, #3b82f6, #2563eb); +} +.digital-skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); } + +.digital-skill-cell .doc-id { + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; +} + .digital-employees-table .col-skill { width: 22%; } .digital-employees-table .col-skill-type { width: 11%; } .digital-employees-table .col-owner { width: 11%; } diff --git a/web/src/assets/styles/views/overview-view.css b/web/src/assets/styles/views/overview-view.css index 5a3713f..3f9c5c5 100644 --- a/web/src/assets/styles/views/overview-view.css +++ b/web/src/assets/styles/views/overview-view.css @@ -143,7 +143,9 @@ .donut-panel, .bottleneck-panel, -.budget-panel { +.budget-panel, +.model-panel, +.feedback-panel { grid-column: span 3; } @@ -183,8 +185,228 @@ text-align: center; } +.card-subtitle { + margin: -8px 0 12px; + color: #64748b; + font-size: 12px; + font-weight: 550; + line-height: 1.55; +} + +.system-observability-grid { + display: grid; + grid-template-columns: minmax(0, 1.18fr) minmax(320px, .9fr) minmax(320px, .9fr); + grid-template-areas: + "agent agent agent" + "token token token" + "accuracy accuracy side" + "tools tools side"; + gap: 18px; +} + +.system-agent-ratio-panel { + grid-area: agent; +} + +.system-token-pie-panel { + grid-area: token; +} + +.system-token-panel-grid { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(380px, .95fr); + gap: 18px; + align-items: stretch; +} + +.system-accuracy-panel { + grid-area: accuracy; +} + +.system-tool-detail-panel { + grid-area: tools; +} + +.system-side-stack { + grid-area: side; + display: grid; + gap: 18px; + align-content: start; +} + +.system-side-stack .dashboard-card { + grid-column: auto; +} + +.system-side-card { + padding: 16px; +} + +.system-side-card .card-head { + margin-bottom: 12px; +} + +.system-side-card .card-subtitle { + margin: -6px 0 8px; + font-size: 11px; +} + +.duration-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.duration-summary strong { + display: block; + color: #0f172a; + font-size: 26px; + font-weight: 850; + line-height: 1; + letter-spacing: 0; +} + +.duration-summary span { + display: block; + margin-top: 5px; + color: #64748b; + font-size: 11px; + font-weight: 650; +} + +.duration-summary em { + padding: 2px 7px; + border-radius: 4px; + background: rgba(var(--success-rgb), 0.10); + color: var(--success); + font-size: 11px; + font-style: normal; + font-weight: 800; + white-space: nowrap; +} + +.duration-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.duration-meta span { + padding: 7px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fbfdff; + color: #475569; + font-size: 11px; + font-weight: 750; +} + +.duration-bars { + display: grid; + gap: 9px; +} + +.duration-bar-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 44px; + align-items: center; + gap: 8px; +} + +.duration-bar-row span, +.duration-bar-row strong { + color: #475569; + font-size: 11px; + font-weight: 750; + white-space: nowrap; +} + +.duration-bar-row strong { + color: #0f172a; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.duration-bar-row i { + height: 7px; + overflow: hidden; + border-radius: 4px; + background: #eef2f7; +} + +.duration-bar-row b { + display: block; + height: 100%; + border-radius: inherit; +} + +.system-tool-table { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 14px; +} + +.system-tool-row { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fbfdff; +} + +.system-tool-row-head, +.system-tool-row-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.system-tool-row-head strong { + min-width: 0; + color: #1e293b; + font-size: 13px; + font-weight: 750; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.system-tool-row-head span { + color: #0f172a; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.system-tool-meter { + height: 8px; + overflow: hidden; + border-radius: 4px; + background: #eef2f7; +} + +.system-tool-meter i { + display: block; + height: 100%; + border-radius: inherit; +} + +.system-tool-row-meta { + color: #64748b; + font-size: 11px; + flex-wrap: wrap; + justify-content: flex-start; +} + .bottleneck-panel, -.budget-panel { +.budget-panel, +.model-panel, +.feedback-panel { display: flex; flex-direction: column; } @@ -194,6 +416,67 @@ margin-top: auto; } +.feedback-list { + flex: 1; + display: grid; + gap: 12px; + align-content: center; +} + +.feedback-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid #f1f5f9; +} + +.feedback-row:last-child { + border-bottom: 0; +} + +.feedback-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 4px; + background: #eef2f7; + color: #64748b; + font-size: 16px; + flex: 0 0 auto; +} + +.feedback-row.success .feedback-icon { + background: rgba(var(--success-rgb), .10); + color: var(--success); +} + +.feedback-row.danger .feedback-icon { + background: rgba(239, 68, 68, .10); + color: #dc2626; +} + +.feedback-row.info .feedback-icon { + background: rgba(var(--theme-primary-rgb, 58, 124, 165), .10); + color: var(--theme-primary-active); +} + +.feedback-row strong { + display: block; + color: #1e293b; + font-size: 18px; + font-weight: 800; + line-height: 1; +} + +.feedback-row span:not(.feedback-icon) { + display: block; + margin-top: 4px; + color: #64748b; + font-size: 12px; +} + .bottleneck-list { flex: 1; display: grid; @@ -337,6 +620,15 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } + .system-observability-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: + "agent agent" + "token token" + "accuracy side" + "tools side"; + } + .trend-panel, .rank-panel { grid-column: span 12; @@ -344,7 +636,9 @@ .donut-panel, .bottleneck-panel, - .budget-panel { + .budget-panel, + .model-panel, + .feedback-panel { grid-column: span 6; } } @@ -381,11 +675,31 @@ grid-template-columns: 1fr; } + .system-observability-grid { + grid-template-columns: 1fr; + grid-template-areas: + "agent" + "token" + "accuracy" + "tools" + "side"; + } + + .system-tool-table { + grid-template-columns: 1fr; + } + + .system-token-panel-grid { + grid-template-columns: 1fr; + } + .trend-panel, .rank-panel, .donut-panel, .bottleneck-panel, - .budget-panel { + .budget-panel, + .model-panel, + .feedback-panel { grid-column: span 1; } diff --git a/web/src/assets/styles/views/receipt-folder-view.css b/web/src/assets/styles/views/receipt-folder-view.css index 78a9b37..b5a9860 100644 --- a/web/src/assets/styles/views/receipt-folder-view.css +++ b/web/src/assets/styles/views/receipt-folder-view.css @@ -11,26 +11,41 @@ .receipt-folder-detail { min-height: 0; overflow: hidden; - padding: 16px 18px; } .receipt-folder-list { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; + padding: 16px 18px; } -.receipt-detail-head, -.receipt-detail-foot, -.receipt-basic-panel header, -.receipt-preview-panel header, +.receipt-folder-list th:first-child, +.receipt-folder-list td:first-child { + text-align: left; +} + +.receipt-folder-list td:first-child .doc-id { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.col-file { width: 22%; } +.col-kind { width: 13%; } +.col-scene { width: 13%; } +.col-money { width: 10%; } +.col-date { width: 12%; } +.col-score { width: 10%; } +.col-status { width: 10%; } +.col-updated { width: 14%; } + .receipt-field-list-head { display: flex; align-items: center; } -.receipt-form-grid input, -.receipt-form-grid textarea, -.receipt-field-row input { +.receipt-key-grid input, +.receipt-edit-field-row input { width: 100%; border: 1px solid #d7e0ea; border-radius: 4px; @@ -40,31 +55,21 @@ transition: border-color 160ms ease, box-shadow 160ms ease; } -.receipt-form-grid input, -.receipt-field-row input { +.receipt-key-grid input, +.receipt-edit-field-row input { height: 36px; padding: 0 10px; } -.receipt-form-grid textarea { - resize: vertical; - min-height: 78px; - padding: 9px 10px; - line-height: 1.55; -} - -.receipt-form-grid input:focus, -.receipt-form-grid textarea:focus, -.receipt-field-row input:focus { +.receipt-key-grid input:focus, +.receipt-edit-field-row input:focus { border-color: var(--theme-primary); box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14); outline: none; } .apply-btn, -.ghost-btn, -.danger-btn, -.back-btn { +.ghost-btn { min-height: 36px; display: inline-flex; align-items: center; @@ -76,8 +81,7 @@ white-space: nowrap; } -.ghost-btn, -.back-btn { +.ghost-btn { padding: 0 13px; border: 1px solid #d7e0ea; background: #fff; @@ -91,68 +95,67 @@ color: #fff; } -.danger-btn { - padding: 0 14px; - border: 1px solid #dc2626; - background: #dc2626; - color: #fff; -} - .apply-btn:disabled, -.danger-btn:disabled { +.ghost-btn:disabled { opacity: .55; cursor: not-allowed; } .receipt-folder-detail { display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; + padding: 0; } -.receipt-detail-head { - gap: 14px; - padding-bottom: 14px; - border-bottom: 1px solid #dbe4ee; -} - -.receipt-detail-head h2 { - margin: 4px 0; - color: #0f172a; - font-size: 20px; - line-height: 1.25; -} - -.receipt-detail-head p { - margin: 0; - color: #64748b; - font-size: 13px; -} - -.assistant-badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 4px; - background: #eef6ff; - color: var(--theme-primary-active); - font-size: 12px; - font-weight: 850; -} - -.detail-loading { +.receipt-folder-detail :deep(.detail-scroll) { min-height: 0; display: grid; - place-items: center; + align-content: start; + gap: 16px; + padding-right: 4px; + overflow: auto; } -.receipt-detail-layout { +.receipt-folder-detail :deep(.detail-actions) { + margin-top: 10px; + padding-top: 10px; +} + +.receipt-folder-detail :deep(.detail-grid) { min-height: 0; display: grid; grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr); gap: 16px; - padding: 16px 0; - overflow: hidden; + align-items: stretch; + overflow: visible; +} + +.receipt-folder-detail :deep(.detail-main), +.receipt-folder-detail :deep(.detail-side) { + min-height: 0; + display: grid; +} + +.receipt-folder-detail :deep(.enterprise-detail-card .card-head) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.receipt-folder-detail :deep(.enterprise-detail-card .card-head h3) { + margin: 0; + color: #0f172a; + font-size: 15px; + font-weight: 850; +} + +.receipt-folder-detail :deep(.enterprise-detail-card .card-head p) { + margin: 4px 0 0; + color: #64748b; + font-size: 12px; } .receipt-basic-panel, @@ -165,67 +168,122 @@ } .receipt-basic-panel { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; + display: block; padding: 14px; overflow-y: auto; } -.receipt-basic-panel header, -.receipt-preview-panel header, .receipt-field-list-head { justify-content: space-between; gap: 12px; } -.receipt-basic-panel header strong, -.receipt-preview-panel header strong, .receipt-field-list-head strong { color: #0f172a; font-size: 15px; } -.receipt-form-grid { +.receipt-field-list-head small { + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.receipt-key-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 14px; } -.receipt-form-grid label { +.receipt-key-field, +.receipt-edit-field-row label { display: grid; gap: 6px; } -.receipt-form-grid label span { +.receipt-key-field span, +.receipt-edit-field-row label span { color: #64748b; font-size: 12px; font-weight: 750; } -.field-wide { - grid-column: 1 / -1; +.receipt-other-info { + margin-top: 18px; } -.receipt-field-list { - margin-top: 18px; +.receipt-other-collapse { + border-top: 1px solid #e5edf5; + border-bottom: 0; +} + +.receipt-other-collapse :deep(.el-collapse-item__header) { + min-height: 42px; + height: auto; + border-bottom: 1px solid #e5edf5; + background: #fff; + color: #0f172a; +} + +.receipt-other-collapse :deep(.el-collapse-item__wrap) { + border-bottom: 0; +} + +.receipt-other-collapse :deep(.el-collapse-item__content) { + padding: 12px 0 0; +} + +.receipt-collapse-title { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-right: 10px; +} + +.receipt-collapse-title strong { + color: #0f172a; + font-size: 15px; +} + +.receipt-collapse-title small { + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.receipt-other-scroll { + max-height: 320px; display: grid; gap: 10px; + overflow-y: auto; + padding-right: 4px; } -.receipt-field-row { +.receipt-edit-field-row { display: grid; - grid-template-columns: minmax(100px, .6fr) minmax(160px, 1fr) 30px; - gap: 8px; -} - -.receipt-field-row button { - display: grid; - place-items: center; - border: 1px solid #d7e0ea; + grid-template-columns: minmax(120px, .72fr) minmax(180px, 1.28fr); + gap: 10px; + padding: 10px; + border: 1px solid #e1e8f0; border-radius: 4px; - background: #fff; + background: #f8fafc; +} + +.receipt-field-empty { + min-height: 64px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px dashed #d7e0ea; + border-radius: 4px; + background: #f8fafc; color: #64748b; + font-size: 13px; + font-weight: 700; } .receipt-preview-panel { @@ -233,20 +291,6 @@ grid-template-rows: auto minmax(0, 1fr); } -.receipt-preview-panel header { - padding: 14px; - border-bottom: 1px solid #e5edf5; -} - -.preview-source-btn { - border: 0; - background: transparent; - color: var(--theme-primary-active); - font-size: 13px; - font-weight: 750; - text-decoration: none; -} - .receipt-preview-box { min-height: 0; display: grid; @@ -281,13 +325,6 @@ font-size: 34px; } -.receipt-detail-foot { - justify-content: space-between; - gap: 12px; - padding-top: 14px; - border-top: 1px solid #dbe4ee; -} - .associate-step { display: grid; gap: 12px; @@ -350,7 +387,7 @@ } @media (max-width: 1120px) { - .receipt-detail-layout { + .receipt-folder-detail :deep(.detail-grid) { grid-template-columns: 1fr; overflow-y: auto; } @@ -361,12 +398,12 @@ } @media (max-width: 760px) { - .receipt-folder-list, - .receipt-folder-detail { + .receipt-folder-list { padding: 12px; } - .receipt-form-grid { + .receipt-key-grid, + .receipt-edit-field-row { grid-template-columns: 1fr; } } diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part2.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part2.css index de0a6ed..9600e45 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part2.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part2.css @@ -27,7 +27,7 @@ min-height: 36px; padding: 0 10px; border: 1px solid rgba(203, 213, 225, 0.92); - border-radius: 10px; + border-radius: 4px; background: #fff; color: #0f172a; font-size: 12px; @@ -53,14 +53,14 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 4px; - border-radius: 12px; + border-radius: 4px; background: rgba(241, 245, 249, 0.92); } .composer-date-mode-btn { min-height: 34px; border: 0; - border-radius: 10px; + border-radius: 4px; background: transparent; color: #64748b; font-size: 12px; @@ -101,7 +101,7 @@ min-height: 36px; padding: 0 10px; border: 1px solid rgba(203, 213, 225, 0.92); - border-radius: 10px; + border-radius: 4px; background: #fff; color: #0f172a; font-size: 12px; @@ -132,7 +132,7 @@ .composer-date-apply-btn { min-height: 34px; padding: 0 14px; - border-radius: 10px; + border-radius: 4px; font-size: 12px; font-weight: 800; } @@ -158,7 +158,7 @@ min-width: 0; min-height: var(--composer-control-size, 44px); border: 1px solid rgba(214, 225, 234, 0.95); - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 10px 22px rgba(226, 232, 240, 0.24), @@ -181,7 +181,7 @@ max-width: min(100%, 320px); min-height: 28px; padding: 0 8px 0 10px; - border-radius: 999px; + border-radius: 4px; border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); background: linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08)); color: var(--theme-primary-active); @@ -209,7 +209,7 @@ place-items: center; padding: 0; border: 0; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.72); color: #3b82f6; flex: none; @@ -224,8 +224,8 @@ gap: 10px; padding: 14px; border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 18px; - background: linear-gradient(180deg, rgba(248, 251, 255, 0.92) 0%, rgba(242, 247, 251, 0.78) 100%); + border-radius: 4px; + background: rgba(248, 251, 255, 0.92); } .composer-files-head { @@ -299,7 +299,7 @@ place-items: center; padding: 0; border: 0; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.82); color: inherit; flex: none; @@ -323,7 +323,7 @@ justify-content: space-between; gap: 10px; padding: 10px 12px; - border-radius: 14px; + border-radius: 4px; border: 1px solid rgba(219, 230, 240, 0.92); background: rgba(255, 255, 255, 0.88); } @@ -350,7 +350,7 @@ display: grid; place-items: center; border: 0; - border-radius: 10px; + border-radius: 4px; background: rgba(248, 250, 252, 0.92); color: #64748b; } @@ -404,7 +404,7 @@ display: grid; place-items: center; border: 0; - border-radius: 999px; + border-radius: 4px; flex: none; } @@ -501,7 +501,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 11px; @@ -538,7 +538,7 @@ display: inline-flex; align-items: center; justify-content: center; - border-radius: 999px; + border-radius: 4px; border: 1px solid rgba(203, 213, 225, 0.92); background: rgba(248, 250, 252, 0.96); color: #94a3b8; @@ -606,7 +606,7 @@ display: inline-flex; align-items: center; padding: 0 13px; - border-radius: 999px; + border-radius: 4px; font-size: var(--wb-fs-chip); font-weight: 800; } @@ -654,7 +654,7 @@ .confidence-card { min-width: 92px; padding: 10px 12px; - border-radius: 14px; + border-radius: 4px; background: rgba(250, 252, 252, 0.9); border: 1px solid rgba(202, 213, 223, 0.9); box-shadow: 0 8px 18px rgba(203, 213, 225, 0.3); @@ -698,7 +698,7 @@ display: grid; gap: 10px; padding: 14px; - border-radius: 18px; + border-radius: 4px; border: 1px solid rgba(197, 209, 221, 0.88); background: rgba(249, 251, 251, 0.88); box-shadow: 0 10px 20px rgba(226, 232, 240, 0.3); @@ -745,7 +745,7 @@ gap: 8px; align-items: start; padding: 12px; - border-radius: 14px; + border-radius: 4px; border: 1px solid rgba(206, 216, 226, 0.88); background: rgba(251, 252, 252, 0.82); position: relative; @@ -776,7 +776,7 @@ height: 32px; display: grid; place-items: center; - border-radius: 8px; + border-radius: 4px; background: #f1f5f9; border: 1px solid transparent; color: #64748b; @@ -821,7 +821,7 @@ min-height: 34px; padding: 0 10px; border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2); - border-radius: 10px; + border-radius: 4px; background: rgba(255, 255, 255, 0.96); color: #0f172a; font-size: 12px; @@ -860,7 +860,7 @@ align-items: center; justify-content: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; border: 1px solid rgba(203, 213, 225, 0.92); background: rgba(255, 255, 255, 0.96); color: #475569; @@ -889,7 +889,7 @@ display: inline-flex; align-items: center; padding: 0 6px; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(226, 232, 240, 0.92); color: #94a3b8; @@ -953,7 +953,7 @@ gap: 8px; padding: 12px; min-height: 66px; - border-radius: 14px; + border-radius: 4px; border: 1px solid rgba(226, 232, 240, 0.94); background: rgba(255, 255, 255, 0.68); cursor: pointer; @@ -1005,7 +1005,7 @@ align-items: center; justify-content: center; padding: 0 12px; - border-radius: 999px; + border-radius: 4px; border: 1px solid rgba(203, 213, 225, 0.92); background: rgba(255, 255, 255, 0.94); color: #475569; @@ -1045,7 +1045,7 @@ min-height: 66px; padding: 10px; border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 14px; + border-radius: 4px; background: rgba(255, 255, 255, 0.76); color: #334155; text-align: left; @@ -1063,7 +1063,7 @@ height: 30px; display: grid; place-items: center; - border-radius: 10px; + border-radius: 4px; background: rgba(58, 124, 165, 0.12); color: #2f6d95; font-size: 16px; @@ -1136,7 +1136,7 @@ gap: 8px; padding: 14px; border: 1px dashed rgba(203, 213, 225, 0.92); - border-radius: 16px; + border-radius: 4px; background: rgba(255, 255, 255, 0.52); } @@ -1145,7 +1145,7 @@ height: 36px; display: grid; place-items: center; - border-radius: 12px; + border-radius: 4px; background: rgba(240, 244, 248, 0.96); color: #94a3b8; font-size: 18px; @@ -1174,7 +1174,7 @@ gap: 6px; padding: 0 14px; border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22); - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.94); color: var(--theme-primary-active); font-size: 12px; @@ -1208,7 +1208,7 @@ align-items: center; gap: 8px; padding: 4px; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(226, 232, 240, 0.92); white-space: nowrap; @@ -1226,7 +1226,7 @@ display: grid; place-items: center; border: 0; - border-radius: 999px; + border-radius: 4px; background: rgba(241, 245, 249, 0.96); color: #334155; } @@ -1269,7 +1269,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 11px; @@ -1287,7 +1287,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: rgba(248, 250, 252, 0.94); border: 1px solid rgba(226, 232, 240, 0.92); color: #475569; @@ -1313,9 +1313,9 @@ .review-document-preview-card { min-height: 168px; overflow: hidden; - border-radius: 16px; + border-radius: 4px; border: 1px solid rgba(226, 232, 240, 0.94); - background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); + background: #f8fafc; } .review-document-preview-card.clickable { @@ -1392,7 +1392,7 @@ .review-document-edit-field textarea { width: 100%; border: 1px solid rgba(219, 230, 240, 0.96); - border-radius: 14px; + border-radius: 4px; background: rgba(255, 255, 255, 0.96); color: #0f172a; font-size: 13px; @@ -1423,7 +1423,7 @@ gap: 8px; align-items: start; padding: 10px 12px; - border-radius: 14px; + border-radius: 4px; background: rgba(255, 247, 237, 0.92); border: 1px solid rgba(253, 186, 116, 0.6); color: #c2410c; @@ -1438,13 +1438,13 @@ .insight-card { padding: 16px; border: 1px solid #e7eef6; - border-radius: 20px; + border-radius: 4px; background: rgba(255, 255, 255, 0.95); box-shadow: 0 14px 24px rgba(241, 245, 249, 0.86); } .insight-card.primary { - background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); + background: #ffffff; } .card-head { @@ -1474,7 +1474,7 @@ gap: 10px; padding: 12px 14px; border: 1px solid rgba(226, 232, 240, 0.92); - border-radius: 16px; + border-radius: 4px; background: rgba(248, 250, 252, 0.86); color: #1e293b; text-align: left; @@ -1505,7 +1505,7 @@ display: inline-flex; align-items: center; justify-content: center; - border-radius: 999px; + border-radius: 4px; background: rgba(226, 232, 240, 0.9); color: #0f172a; font-size: 12px; diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css index d89c007..38f67a5 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part3.css @@ -17,7 +17,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; font-size: 12px; font-weight: 800; white-space: nowrap; @@ -50,7 +50,7 @@ .metric-item { padding: 12px 14px; - border-radius: 16px; + border-radius: 4px; background: #f8fafc; } @@ -129,7 +129,7 @@ gap: 12px; align-items: center; padding: 12px 14px; - border-radius: 16px; + border-radius: 4px; background: #f8fafc; } @@ -190,7 +190,7 @@ } .review-flow-panel .flow-step-card { - border-radius: 14px; + border-radius: 4px; box-shadow: none; } @@ -315,7 +315,7 @@ display: grid; gap: 10px; padding: 12px 14px; - border-radius: 16px; + border-radius: 4px; border: 1px solid rgba(226, 232, 240, 0.92); background: rgba(255, 255, 255, 0.76); } @@ -338,7 +338,7 @@ display: inline-flex; align-items: center; padding: 0 8px; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.92); border: 1px solid #e2e8f0; color: #475569; @@ -377,7 +377,7 @@ gap: 10px; align-items: center; padding: 11px 12px; - border-radius: 14px; + border-radius: 4px; background: rgba(255, 255, 255, 0.88); border: 1px solid rgba(226, 232, 240, 0.92); } @@ -404,7 +404,7 @@ height: 36px; display: grid; place-items: center; - border-radius: 10px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 16px; @@ -457,7 +457,7 @@ align-items: center; justify-content: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; font-size: 10px; font-weight: 800; white-space: nowrap; @@ -500,7 +500,7 @@ align-items: center; justify-content: center; padding: 0 14px; - border-radius: 12px; + border-radius: 4px; border: 1px solid #dbe6f0; background: rgba(255, 255, 255, 0.92); color: #334155; @@ -545,7 +545,7 @@ align-items: center; justify-content: center; padding: 0 16px; - border-radius: 999px; + border-radius: 4px; font-size: 12px; font-weight: 800; } @@ -605,7 +605,7 @@ display: grid; gap: 8px; padding: 14px 16px; - border-radius: 18px; + border-radius: 4px; border: 1px solid #dbeafe; background: linear-gradient(180deg, #f8fbff 0%, #f0f7ff 100%); } @@ -626,7 +626,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: rgba(255, 255, 255, 0.86); color: #0f172a; font-size: 12px; @@ -638,7 +638,7 @@ display: grid; gap: 10px; padding: 14px 16px; - border-radius: 18px; + border-radius: 4px; border: 1px solid #e2e8f0; background: rgba(255, 255, 255, 0.88); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.66); @@ -662,7 +662,7 @@ display: inline-flex; align-items: center; padding: 0 9px; - border-radius: 999px; + border-radius: 4px; background: #fff; color: #475569; font-size: 11px; @@ -693,7 +693,7 @@ display: inline-flex; align-items: center; padding: 0 12px; - border-radius: 999px; + border-radius: 4px; background: #fff; color: #0f172a; font-size: 12px; @@ -706,7 +706,7 @@ display: grid; gap: 4px; padding: 10px 12px; - border-radius: 14px; + border-radius: 4px; border: 1px solid #e2e8f0; background: #fff; } @@ -762,7 +762,7 @@ .review-claim-card, .review-document-card { border: 1px solid #e2e8f0; - border-radius: 16px; + border-radius: 4px; background: #f8fbff; } @@ -829,7 +829,7 @@ .review-slot-meta-item { padding: 9px 10px; - border-radius: 12px; + border-radius: 4px; background: rgba(255, 255, 255, 0.82); border: 1px solid rgba(226, 232, 240, 0.9); } @@ -886,8 +886,8 @@ .document-preview { min-height: 124px; overflow: hidden; - border-radius: 14px; - background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); + border-radius: 4px; + background: #f8fafc; border: 1px dashed #dbe3ec; } @@ -941,7 +941,7 @@ display: grid; gap: 8px; padding: 14px; - border-radius: 16px; + border-radius: 4px; background: #f8fafc; } @@ -978,15 +978,15 @@ display: grid; gap: 10px; padding: 16px 18px; - border-radius: 22px; + border-radius: 4px; border: 1px solid rgba(191, 219, 254, 0.9); - background: linear-gradient(180deg, #ffffff 0%, #f5fbff 100%); + background: #ffffff; box-shadow: 0 16px 28px rgba(241, 245, 249, 0.9); } .recognition-bubble.secondary { border-color: #e2e8f0; - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + background: #ffffff; } .recognition-bubble-label { @@ -1028,8 +1028,8 @@ gap: 14px; align-items: start; padding: 16px; - border-radius: 22px; - background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%); + border-radius: 4px; + background: #ffffff; border: 1px solid rgba(226, 232, 240, 0.95); box-shadow: 0 16px 28px rgba(241, 245, 249, 0.92); } @@ -1065,10 +1065,8 @@ .review-confirm-modal { width: min(720px, calc(100vw - 40px)); - border-radius: 24px; - background: - radial-gradient(circle at top right, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 28%), - linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); + border-radius: 4px; + background: #fbfdff; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22), 0 2px 12px rgba(15, 23, 42, 0.08); diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css index 88df86e..c38748d 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css @@ -12,10 +12,8 @@ display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; - border-radius: 24px; - background: - radial-gradient(circle at top right, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 28%), - linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); + border-radius: 4px; + background: #fbfdff; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22), 0 2px 12px rgba(15, 23, 42, 0.08); @@ -51,7 +49,7 @@ max-width: 100%; max-height: calc(92vh - 170px); display: block; - border-radius: 20px; + border-radius: 4px; object-fit: contain; box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26); } @@ -60,7 +58,7 @@ width: 100%; height: min(78vh, 820px); border: 0; - border-radius: 18px; + border-radius: 4px; background: #fff; } @@ -92,7 +90,7 @@ gap: 6px; padding: 0 14px; border: 1px solid #cbd5e1; - border-radius: 12px; + border-radius: 4px; background: #ffffff; color: #334155; font-size: var(--wb-fs-chip); @@ -131,7 +129,7 @@ .welcome-card { padding: 14px; - border-radius: 18px; + border-radius: 4px; background: #f8fafc; } @@ -329,7 +327,7 @@ .assistant-modal, .assistant-modal-stage { - border-radius: 10px; + border-radius: 4px; } .assistant-header { @@ -352,7 +350,7 @@ .close-btn { width: 40px; height: 40px; - border-radius: 14px; + border-radius: 4px; font-size: 16px; } diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 63fa483..9a0a4d7 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -24,7 +24,7 @@ display: flex; flex-direction: column; border: 0; - border-radius: 10px; + border-radius: 4px; background: transparent; box-shadow: none; overflow: hidden; @@ -142,7 +142,7 @@ background: transparent; box-shadow: none; border: 0; - border-radius: 10px; + border-radius: 4px; backdrop-filter: none; -webkit-backdrop-filter: none; overflow: hidden; @@ -179,9 +179,8 @@ display: grid; grid-template-rows: auto minmax(0, 1fr); transform: none; - border-radius: 10px; - background: - linear-gradient(180deg, #f8fbff 0%, #edf5ff 100%); + border-radius: 4px; + background: #f6f9fc; box-shadow: 0 28px 72px rgba(15, 23, 42, 0.22), 0 10px 28px rgba(15, 23, 42, 0.09), @@ -217,7 +216,7 @@ align-items: center; justify-content: center; padding: 0 14px; - border-radius: 999px; + border-radius: 4px; background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); color: #fff; font-size: var(--wb-fs-badge); @@ -290,7 +289,7 @@ place-items: center; padding: 0; border: 1px solid rgba(248, 113, 113, 0.28); - border-radius: 14px; + border-radius: 4px; flex: none; } @@ -342,7 +341,7 @@ padding: 0; flex: none; border: 1px solid rgba(193, 204, 216, 0.92); - border-radius: 14px; + border-radius: 4px; background: rgba(248, 251, 251, 0.94); color: #475569; font-size: 16px; @@ -375,7 +374,7 @@ align-items: center; justify-content: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; background: #f1f5f9; color: #64748b; font-size: 11px; @@ -405,7 +404,7 @@ place-items: center; padding: 0; border: 1px solid rgba(203, 213, 225, 0.86); - border-radius: 12px; + border-radius: 4px; background: rgba(255, 255, 255, 0.92); color: #475569; font-size: 16px; @@ -493,7 +492,7 @@ gap: 7px; padding: 13px 14px; border: 1px solid #e5edf5; - border-radius: 12px; + border-radius: 4px; background: #fff; box-shadow: 0 8px 22px rgba(226, 232, 240, 0.34); } @@ -531,7 +530,7 @@ display: inline-flex; align-items: center; padding: 0 9px; - border-radius: 999px; + border-radius: 4px; background: #f1f5f9; color: #64748b; font-size: 11px; @@ -563,7 +562,6 @@ font-variant-numeric: tabular-nums; } -.flow-step-tool, .flow-step-detail, .flow-step-error { margin: 0; @@ -632,7 +630,7 @@ min-width: 0; min-height: 0; border: 1px solid rgba(189, 201, 214, 0.74); - border-radius: 16px; + border-radius: 4px; background: #ffffff; box-shadow: 0 14px 32px rgba(148, 163, 184, 0.16), @@ -646,8 +644,7 @@ display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; - background: - linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + background: #ffffff; transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1); @@ -715,7 +712,7 @@ gap: 6px; padding: 0 13px; border: 1px solid rgba(219, 230, 240, 0.9); - border-radius: 8px; + border-radius: 4px; background: rgba(255, 255, 255, 0.95); color: #334155; font-size: 13px; @@ -812,7 +809,7 @@ max-width: min(100%, 720px); padding: 12px 14px; border: 1px solid rgba(210, 220, 230, 0.94); - border-radius: 20px; + border-radius: 4px; background: rgba(253, 254, 254, 0.94); color: #24324a; font-size: var(--wb-fs-bubble); @@ -940,7 +937,7 @@ margin-top: 12px; overflow: hidden; border: 1px solid #d7e4f2; - border-radius: 8px; + border-radius: 4px; background: #ffffff; color: #334155; font-size: var(--wb-fs-bubble); @@ -1113,7 +1110,7 @@ .message-answer-markdown :deep(blockquote) { padding: 8px 10px; border-left: 3px solid #cbd5e1; - border-radius: 0 10px 10px 0; + border-radius: 0 4px 4px 0; background: rgba(248, 250, 252, 0.84); color: #475569; } @@ -1123,7 +1120,7 @@ padding: 12px 14px; border: 1px solid #dbe4ee; border-left: 4px solid #2563eb; - border-radius: 8px; + border-radius: 4px; background: #f8fafc; color: #334155; } @@ -1152,7 +1149,7 @@ .message-answer-markdown :deep(code) { padding: 2px 6px; - border-radius: 6px; + border-radius: 4px; background: #e2e8f0; font-size: 12px; } @@ -1160,7 +1157,7 @@ .message-answer-markdown :deep(pre) { overflow-x: auto; padding: 12px; - border-radius: 14px; + border-radius: 4px; background: #0f172a; color: #e2e8f0; } @@ -1218,7 +1215,7 @@ margin: 8px 0 10px; overflow-x: auto; border: 1px solid #dbe4ee; - border-radius: 10px; + border-radius: 4px; background: #fff; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); } @@ -1262,13 +1259,6 @@ border-bottom: 0; } -.message-meta-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 10px; -} - .message-suggested-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1276,7 +1266,7 @@ margin-top: 14px; padding: 10px; border: 1px solid rgba(203, 213, 225, 0.72); - border-radius: 14px; + border-radius: 4px; background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); @@ -1291,7 +1281,7 @@ gap: 10px; padding: 12px 11px; border: 1px solid rgba(203, 213, 225, 0.8); - border-radius: 10px; + border-radius: 4px; background: rgba(255, 255, 255, 0.9); color: #0f172a; text-align: left; @@ -1309,7 +1299,7 @@ height: 34px; display: grid; place-items: center; - border-radius: 10px; + border-radius: 4px; background: #f1f5f9; color: var(--theme-primary-active); font-size: 18px; @@ -1396,7 +1386,6 @@ opacity: 1; } -.message-meta-chip, .capability-chip, .risk-chip, .message-risk-chip, @@ -1405,35 +1394,16 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; font-size: var(--wb-fs-chip); font-weight: 800; } -.message-meta-chip, .capability-chip { background: #eef6ff; color: #1d4ed8; } -.message-meta-chip.high { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; -} - -.message-meta-chip.medium { - background: #fffbeb; - color: #b45309; - border: 1px solid #fde68a; -} - -.message-meta-chip.low { - background: #eff6ff; - color: #1d4ed8; - border: 1px solid #bfdbfe; -} - .risk-chip, .message-risk-chip { background: #fff1f2; @@ -1460,7 +1430,7 @@ .message-citation-disclosure { overflow: hidden; border: 1px solid #dbe4ee; - border-radius: 16px; + border-radius: 4px; background: #fbfdff; } @@ -1531,7 +1501,7 @@ display: inline-flex; align-items: center; padding: 0 10px; - border-radius: 999px; + border-radius: 4px; font-size: 11px; font-weight: 800; background: #eef2ff; @@ -1571,7 +1541,7 @@ gap: 10px; padding: 10px 12px; border: 1px solid #dbe4ee; - border-radius: 14px; + border-radius: 4px; background: #fbfdff; cursor: pointer; font: inherit; @@ -1646,7 +1616,7 @@ display: inline-flex; align-items: center; padding: 0 8px; - border-radius: 999px; + border-radius: 4px; font-size: 10px; font-weight: 800; background: #f1f5f9; @@ -1698,7 +1668,7 @@ gap: 5px; padding: 0 8px; border: 1px solid #fecaca; - border-radius: 999px; + border-radius: 4px; background: #fff7ed; color: #9a3412; font: inherit; @@ -1763,7 +1733,7 @@ align-items: center; justify-content: center; border: 1px solid #dbe4ee; - border-radius: 999px; + border-radius: 4px; background: #fff; color: #475569; transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; @@ -1808,7 +1778,7 @@ gap: 10px; padding: 0 14px; border: 1px dashed #dbe4ee; - border-radius: 16px; + border-radius: 4px; color: #64748b; font-size: 12px; font-weight: 700; @@ -1845,7 +1815,7 @@ .action-card { padding: 12px 14px; border: 1px solid #e2e8f0; - border-radius: 16px; + border-radius: 4px; background: #f8fbff; } @@ -1887,8 +1857,8 @@ margin-top: 12px; padding: 12px 14px; border: 1px solid #dbe3ec; - border-radius: 16px; - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-radius: 4px; + background: #ffffff; } .draft-preview header { @@ -1931,7 +1901,7 @@ gap: 6px; padding: 0 10px; border: 0; - border-radius: 999px; + border-radius: 4px; background: #f1f5f9; color: #475569; font-size: 12px; @@ -1990,7 +1960,7 @@ gap: 12px; padding: 14px; border: 1px solid rgba(203, 213, 225, 0.92); - border-radius: 16px; + border-radius: 4px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16), @@ -2007,7 +1977,7 @@ gap: 12px; padding: 14px; border: 1px solid rgba(203, 213, 225, 0.92); - border-radius: 16px; + border-radius: 4px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16), diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 20dff57..1dd6894 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -653,6 +653,32 @@ font-weight: 850; } +.related-application-facts { + margin-top: 0; +} + +.related-application-empty { + display: grid; + gap: 6px; + padding: 13px 14px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.related-application-empty strong { + color: #334155; + font-size: 13px; + font-weight: 850; +} + +.related-application-empty p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + .detail-note-editor { display: grid; gap: 10px; diff --git a/web/src/components/audit/AuditAssetList.vue b/web/src/components/audit/AuditAssetList.vue index a5b00e9..c443cd8 100644 --- a/web/src/components/audit/AuditAssetList.vue +++ b/web/src/components/audit/AuditAssetList.vue @@ -160,7 +160,7 @@