feat: 增强规则资产管理与审计页面运行时调试
后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险 规则生成模板执行和员工数据模型字段,知识库 RAG 增强本 地回退和文档提取能力,清理旧风险规则文件统一由生成引擎 管理,前端审计页面增加运行时调试面板和规则资产编辑交互, 补充单元测试覆盖。
This commit is contained in:
1
.tmp/Yuxi
Submodule
1
.tmp/Yuxi
Submodule
Submodule .tmp/Yuxi added at fd6803e477
@@ -0,0 +1,41 @@
|
|||||||
|
# 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. 任务结束,更新执行日志,等待早晨财务主管查阅。
|
||||||
46
document/development/hermes_agent/02_database_design.md
Normal file
46
document/development/hermes_agent/02_database_design.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 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`)
|
||||||
32
document/development/hermes_agent/03_risk_scan_module.md
Normal file
32
document/development/hermes_agent/03_risk_scan_module.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 深度风险扫描模块设计 (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 中的判定容忍度。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 动态费控与洞察报告模块设计 (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. 商业价值落地
|
||||||
|
这项功能极大地解放了财务部的报表处理时间。通过提供前置的谈判线索(如发现某经济型酒店的高频住客其实都可以导流到协议酒店),可以给公司带来直接的差旅成本节约。
|
||||||
63
document/development/hermes_agent/05_deployment_and_cron.md
Normal file
63
document/development/hermes_agent/05_deployment_and_cron.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 部署与任务调度架构方案 (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 飞书/钉钉及时告警系统管理员。
|
||||||
24
fix_init.py
Normal file
24
fix_init.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
file_path = 'server/src/app/models/__init__.py'
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add imports
|
||||||
|
imports_to_add = "from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog\nfrom app.models.hermes_report import HermesRiskReport\n"
|
||||||
|
content = re.sub(
|
||||||
|
r'(from app\.models\.organization import OrganizationUnit)',
|
||||||
|
imports_to_add + r'\1',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to __all__
|
||||||
|
content = re.sub(
|
||||||
|
r'(\s*"OrganizationUnit",)',
|
||||||
|
r'\n "HermesTaskConfig",\n "HermesTaskExecutionLog",\n "HermesRiskReport",\1',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done.')
|
||||||
45
fix_knowledge.py
Normal file
45
fix_knowledge.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
file_path = 'server/src/app/services/user_agent_knowledge.py'
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'(def _build_fast_knowledge_answer\([\s\S]*?-> str \| None:\n)',
|
||||||
|
r'\1 return None\n',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'heading_text = f" > \{heading\}" if heading else ""',
|
||||||
|
r'if "表格行级检索线索" in heading:\n heading = heading.replace("表格行级检索线索", "").strip(" >")\n heading_text = f"({heading})" if heading else ""\n item_title = item.get("title") or title',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item\.get\(\'title\'\) or title\}》\{heading_text\}:\{summary\}\\n\{preview\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》** {heading_text}\\n {summary}\\n{preview}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item\.get\(\'title\'\) or title\}》\{heading_text\}:\\n\{rendered\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》** {heading_text}\\n{rendered}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item\.get\(\'title\'\) or title\}》\{heading_text\}:\{rendered\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》** {heading_text}\\n {rendered}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item_title\}》:\{excerpt\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》**:{excerpt}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done.')
|
||||||
15
fix_response.py
Normal file
15
fix_response.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
file_path = 'server/src/app/services/user_agent_response.py'
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'\s*"fallback_answer": fallback_answer,',
|
||||||
|
'',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done.')
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.expense.consecutive_transport_receipts",
|
|
||||||
"name": "连号交通票据",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "consecutive_receipts",
|
|
||||||
"ontology_signal": "consecutive_transport_receipts",
|
|
||||||
"evaluator": "consecutive_transport_receipts",
|
|
||||||
"applies_to": {
|
|
||||||
"expense_types": ["transport", "travel"],
|
|
||||||
"min_attachments": 2
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"invoice_no": "attachment.invoice_no"
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"min_consecutive_count": 3
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 三、车辆交通 / 连号票集中报销",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.expense.entertainment_missing_detail",
|
|
||||||
"name": "招待费事由不完整",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "entertainment_detail",
|
|
||||||
"ontology_signal": "entertainment_missing_detail",
|
|
||||||
"evaluator": "entertainment_reason_missing",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": ["meal"]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"reason": "claim.reason_corpus"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 三、餐费招待 / 业务招待无事由对象",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "2.0",
|
|
||||||
"rule_code": "risk.expense.generated_20260523010818",
|
|
||||||
"name": "住宿城市必须出现在本次差旅行程城市中风险规则",
|
|
||||||
"description": "当报销业务满足“住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "natural_language_rule",
|
|
||||||
"risk_category": "报销",
|
|
||||||
"ontology_signal": "natural_language_risk",
|
|
||||||
"evaluator": "template_rule",
|
|
||||||
"template_key": "field_compare_v1",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": [
|
|
||||||
"expense"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"key": "claim.reason",
|
|
||||||
"label": "报销事由",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "claim.location",
|
|
||||||
"label": "申报地点",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "attachment.hotel_city",
|
|
||||||
"label": "住宿城市",
|
|
||||||
"type": "text",
|
|
||||||
"source": "attachment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "attachment.route_cities",
|
|
||||||
"label": "行程城市",
|
|
||||||
"type": "list",
|
|
||||||
"source": "attachment"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"template_key": "field_compare_v1",
|
|
||||||
"field_keys": [
|
|
||||||
"claim.reason",
|
|
||||||
"claim.location",
|
|
||||||
"attachment.hotel_city",
|
|
||||||
"attachment.route_cities"
|
|
||||||
],
|
|
||||||
"condition_summary": "对比报销事由、申报地点、住宿城市之间是否一致或存在交集",
|
|
||||||
"natural_language": "住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。",
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"left": "claim.reason",
|
|
||||||
"operator": "overlap",
|
|
||||||
"right": "claim.location"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": {
|
|
||||||
"severity": "none",
|
|
||||||
"action": "continue"
|
|
||||||
},
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "WangMin",
|
|
||||||
"stability": "generated_draft",
|
|
||||||
"source_ref": "自然语言风险规则",
|
|
||||||
"created_at": "2026-05-23T01:08:18.310751+00:00",
|
|
||||||
"created_by": "WangMin",
|
|
||||||
"natural_language": "住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。",
|
|
||||||
"business_explanation": "当报销业务满足“住宿城市必须出现在本次差旅行程城市中,如果酒店发票城市与申报目的地或交通票行程城市都不一致,则判定为高风险,并要求补充差旅说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
|
|
||||||
"condition_summary": "对比报销事由、申报地点、住宿城市之间是否一致或存在交集",
|
|
||||||
"flow": {
|
|
||||||
"start": "报销单据提交",
|
|
||||||
"evidence": "读取报销事由、申报地点、住宿城市",
|
|
||||||
"decision": "对比报销事由、申报地点、住宿城市之间是否一致或存在交集",
|
|
||||||
"pass": "未命中风险,继续业务流转",
|
|
||||||
"fail": "命中高风险,提示复核"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "2.0",
|
|
||||||
"rule_code": "risk.expense.generated_20260523010846",
|
|
||||||
"name": "酒店发票城市必须与申报目的地或交通票风险规则",
|
|
||||||
"description": "当报销业务满足“酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "natural_language_rule",
|
|
||||||
"risk_category": "报销",
|
|
||||||
"ontology_signal": "natural_language_risk",
|
|
||||||
"evaluator": "template_rule",
|
|
||||||
"template_key": "field_compare_v1",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": [
|
|
||||||
"expense"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"key": "claim.reason",
|
|
||||||
"label": "报销事由",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "claim.location",
|
|
||||||
"label": "申报地点",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "claim.employee_name",
|
|
||||||
"label": "报销人",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "attachment.route_cities",
|
|
||||||
"label": "行程城市",
|
|
||||||
"type": "list",
|
|
||||||
"source": "attachment"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"template_key": "field_compare_v1",
|
|
||||||
"field_keys": [
|
|
||||||
"claim.reason",
|
|
||||||
"claim.location",
|
|
||||||
"claim.employee_name",
|
|
||||||
"attachment.route_cities"
|
|
||||||
],
|
|
||||||
"condition_summary": "对比报销事由、申报地点、报销人之间是否一致或存在交集",
|
|
||||||
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。",
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"left": "claim.reason",
|
|
||||||
"operator": "overlap",
|
|
||||||
"right": "claim.location"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": {
|
|
||||||
"severity": "none",
|
|
||||||
"action": "continue"
|
|
||||||
},
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "min.wang@xfinance.com",
|
|
||||||
"stability": "generated_draft",
|
|
||||||
"source_ref": "自然语言风险规则",
|
|
||||||
"created_at": "2026-05-23T01:08:46.286513+00:00",
|
|
||||||
"created_by": "min.wang@xfinance.com",
|
|
||||||
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。",
|
|
||||||
"business_explanation": "当报销业务满足“酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求报销人补充异常行程说明。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。",
|
|
||||||
"condition_summary": "对比报销事由、申报地点、报销人之间是否一致或存在交集",
|
|
||||||
"flow": {
|
|
||||||
"start": "报销单据提交",
|
|
||||||
"evidence": "读取报销事由、申报地点、报销人",
|
|
||||||
"decision": "对比报销事由、申报地点、报销人之间是否一致或存在交集",
|
|
||||||
"pass": "未命中风险,继续业务流转",
|
|
||||||
"fail": "命中高风险,提示复核"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "2.0",
|
|
||||||
"rule_code": "risk.expense.generated_20260523011139",
|
|
||||||
"name": "酒店发票城市一致性校验",
|
|
||||||
"description": "校验酒店发票城市是否与申报目的地或行程城市一致,不一致时标记为高风险并要求补充说明",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "natural_language_rule",
|
|
||||||
"risk_category": "报销",
|
|
||||||
"ontology_signal": "natural_language_risk",
|
|
||||||
"evaluator": "template_rule",
|
|
||||||
"template_key": "field_compare_v1",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": [
|
|
||||||
"expense"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"key": "attachment.route_cities",
|
|
||||||
"label": "行程城市",
|
|
||||||
"type": "list",
|
|
||||||
"source": "attachment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "claim.location",
|
|
||||||
"label": "申报地点",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "attachment.hotel_city",
|
|
||||||
"label": "住宿城市",
|
|
||||||
"type": "text",
|
|
||||||
"source": "attachment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "claim.reason",
|
|
||||||
"label": "报销事由",
|
|
||||||
"type": "text",
|
|
||||||
"source": "claim"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"template_key": "field_compare_v1",
|
|
||||||
"field_keys": [
|
|
||||||
"attachment.hotel_city",
|
|
||||||
"claim.location",
|
|
||||||
"attachment.route_cities",
|
|
||||||
"claim.reason"
|
|
||||||
],
|
|
||||||
"condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集",
|
|
||||||
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求补充异常行程说明。",
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"left": "attachment.hotel_city",
|
|
||||||
"operator": "overlap",
|
|
||||||
"right": "claim.location"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": {
|
|
||||||
"severity": "none",
|
|
||||||
"action": "continue"
|
|
||||||
},
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "WangMin",
|
|
||||||
"stability": "generated_draft",
|
|
||||||
"source_ref": "自然语言风险规则",
|
|
||||||
"created_at": "2026-05-23T01:11:39.165281+00:00",
|
|
||||||
"created_by": "WangMin",
|
|
||||||
"natural_language": "酒店发票城市必须与申报目的地或交通票行程城市一致,如果都不一致,则判定为高风险,并要求补充异常行程说明。",
|
|
||||||
"business_explanation": "校验酒店发票城市是否与申报目的地或行程城市一致,不一致时标记为高风险并要求补充说明",
|
|
||||||
"condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集",
|
|
||||||
"flow": {
|
|
||||||
"start": "提交酒店发票",
|
|
||||||
"evidence": "读取住宿城市、申报地点、行程城市",
|
|
||||||
"decision": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集",
|
|
||||||
"pass": "继续流转",
|
|
||||||
"fail": "提示高风险:酒店发票城市与申报目的地及行程城市均不一致,需补充异常行程说明"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.expense.meal_localized_as_travel",
|
|
||||||
"name": "同城餐饮混入差旅",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "meal_travel_mix",
|
|
||||||
"ontology_signal": "meal_as_travel",
|
|
||||||
"evaluator": "meal_as_travel_same_city",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": ["travel"]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"declared": "claim.location",
|
|
||||||
"meal_city": "attachment.cities"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 三、餐费招待 / 同城餐饮归集异地差旅",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.expense.reason_too_brief",
|
|
||||||
"name": "报销事由过短",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "reason_quality",
|
|
||||||
"ontology_signal": "reason_too_brief",
|
|
||||||
"evaluator": "reason_too_brief",
|
|
||||||
"applies_to": {},
|
|
||||||
"inputs": {
|
|
||||||
"reason": "claim.reason_corpus"
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"min_reason_length": 6
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 通用 / 事由不足以支撑真实性判断",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.invoice.claimant_buyer_name_match",
|
|
||||||
"name": "报销人与发票抬头一致",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "identity_consistency",
|
|
||||||
"ontology_signal": "buyer_name_mismatch",
|
|
||||||
"evaluator": "identity_consistency",
|
|
||||||
"applies_to": {
|
|
||||||
"min_attachments": 1
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"claimant": "claim.employee_name",
|
|
||||||
"buyer": "attachment.buyer_name"
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"allow_keywords": ["代报", "集团", "公司", "有限公司"]
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 二、发票类 / 抬头错误",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.invoice.cross_year_invoice",
|
|
||||||
"name": "跨年发票入账",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "cross_year_invoice",
|
|
||||||
"ontology_signal": "cross_year_invoice",
|
|
||||||
"evaluator": "cross_year_invoice",
|
|
||||||
"applies_to": {
|
|
||||||
"min_attachments": 1
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"invoice_date": "attachment.invoice_date",
|
|
||||||
"claim_date": ["claim.occurred_at", "item.item_date"]
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 二、发票类 / 跨年发票",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.invoice.document_expense_mismatch",
|
|
||||||
"name": "开票内容与报销场景不符",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "document_expense_mismatch",
|
|
||||||
"ontology_signal": "document_expense_mismatch",
|
|
||||||
"evaluator": "document_expense_mismatch",
|
|
||||||
"applies_to": {
|
|
||||||
"min_attachments": 1
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"document_type": "attachment.document_type",
|
|
||||||
"expense_type": ["claim.expense_type", "item.item_type"]
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 二、发票类 / 开票内容与业务不符",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.invoice.duplicate_invoice",
|
|
||||||
"name": "发票重复报销",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "duplicate_invoice",
|
|
||||||
"ontology_signal": "duplicate_invoice",
|
|
||||||
"evaluator": "duplicate_invoice",
|
|
||||||
"applies_to": {
|
|
||||||
"min_attachments": 1
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"invoice_no": "attachment.invoice_no"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "block"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 二、发票类 / 重复报销",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.invoice.vague_goods_description",
|
|
||||||
"name": "发票品名过于笼统",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "vague_goods_description",
|
|
||||||
"ontology_signal": "vague_goods_description",
|
|
||||||
"evaluator": "vague_goods_description",
|
|
||||||
"applies_to": {
|
|
||||||
"expense_types": ["office", "other"],
|
|
||||||
"min_attachments": 1
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"ocr": "attachment.ocr_text"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 二、发票类 / 品名笼统",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.invoice.void_or_red_invoice",
|
|
||||||
"name": "作废或红冲发票",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "void_or_red_invoice",
|
|
||||||
"ontology_signal": "void_or_red_invoice",
|
|
||||||
"evaluator": "invoice_void_or_red",
|
|
||||||
"applies_to": {
|
|
||||||
"min_attachments": 1
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"status": "attachment.invoice_status",
|
|
||||||
"ocr": "attachment.ocr_text"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "block"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 二、发票类 / 作废红冲发票",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.travel.base_location_overlap",
|
|
||||||
"name": "常驻地重合出差风险",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "base_location_overlap",
|
|
||||||
"ontology_signal": "base_location_overlap",
|
|
||||||
"evaluator": "base_location_overlap",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": ["travel"]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"employee_base": "employee.location",
|
|
||||||
"declared": "claim.location"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 一、出差类 / 两头在外",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.travel.destination_receipt_location",
|
|
||||||
"name": "申报地点与票据地点一致",
|
|
||||||
"risk_dimension": "location_consistency",
|
|
||||||
"ontology_signal": "location_mismatch",
|
|
||||||
"evaluator": "location_consistency",
|
|
||||||
"inputs": {
|
|
||||||
"declared": "claim.location",
|
|
||||||
"evidence": ["attachment.cities", "item.item_location"]
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"match_mode": "city_fuzzy",
|
|
||||||
"missing_evidence": "warn"
|
|
||||||
},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review",
|
|
||||||
"message_template": "申报地点 {declared} 与票据识别地点 {evidence} 不一致"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"updated_at": "2026-05-18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.travel.hotel_without_itinerary",
|
|
||||||
"name": "住宿城市与行程不一致",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "hotel_itinerary",
|
|
||||||
"ontology_signal": "hotel_itinerary_mismatch",
|
|
||||||
"evaluator": "hotel_without_itinerary",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": ["travel"],
|
|
||||||
"expense_types": ["hotel", "travel"]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"declared": "claim.location",
|
|
||||||
"hotel": "attachment.hotel_city",
|
|
||||||
"itinerary": "attachment.route_cities"
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 三、住宿费 / 夜间异地住宿、酒店连续多天",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.travel.intracity_travel_claim",
|
|
||||||
"name": "同城虚报差旅补贴",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "intracity_travel",
|
|
||||||
"ontology_signal": "intracity_travel",
|
|
||||||
"evaluator": "intracity_travel_claim",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": ["travel"]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"declared": "claim.location",
|
|
||||||
"evidence": ["attachment.route", "attachment.cities"]
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "high",
|
|
||||||
"action": "manual_review"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 一、出差类 / 同城虚报差旅",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"rule_code": "risk.travel.multi_city_reason_required",
|
|
||||||
"name": "多城市行程需说明",
|
|
||||||
"enabled": true,
|
|
||||||
"risk_dimension": "multi_city_itinerary",
|
|
||||||
"ontology_signal": "multi_city_itinerary",
|
|
||||||
"evaluator": "multi_city_reason_required",
|
|
||||||
"applies_to": {
|
|
||||||
"domains": ["travel"]
|
|
||||||
},
|
|
||||||
"inputs": {
|
|
||||||
"reason": "claim.reason_corpus",
|
|
||||||
"cities": ["attachment.cities", "item.item_location"]
|
|
||||||
},
|
|
||||||
"params": {},
|
|
||||||
"outcomes": {
|
|
||||||
"pass": { "severity": "none", "action": "continue" },
|
|
||||||
"fail": {
|
|
||||||
"severity": "medium",
|
|
||||||
"action": "warn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"owner": "风控与审计部",
|
|
||||||
"stability": "platform_builtin",
|
|
||||||
"source_ref": "常用risk.txt / 一、出差类 / 绕道出行、行程不符",
|
|
||||||
"updated_at": "2026-05-19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
server/scripts/start_hermes_daemon.py
Normal file
68
server/scripts/start_hermes_daemon.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Ensure src is in the python path
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
|
||||||
|
|
||||||
|
from app.core.logging import setup_logging
|
||||||
|
from app.db.session import get_session_factory
|
||||||
|
from app.models.hermes_config import HermesTaskConfig
|
||||||
|
from app.services.hermes_scheduler import hermes_scheduler
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes_daemon")
|
||||||
|
|
||||||
|
def init_default_config():
|
||||||
|
"""Ensure there is at least one active global_risk_scan task in the database."""
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
# 初始化 global_risk_scan
|
||||||
|
existing_risk = db.query(HermesTaskConfig).filter_by(task_type="global_risk_scan").first()
|
||||||
|
if not existing_risk:
|
||||||
|
logger.info("No global_risk_scan config found. Initializing default config.")
|
||||||
|
db.add(HermesTaskConfig(
|
||||||
|
task_type="global_risk_scan",
|
||||||
|
cron_expression="0 2 * * *",
|
||||||
|
is_enabled=True
|
||||||
|
))
|
||||||
|
|
||||||
|
# 初始化 weekly_expense_report
|
||||||
|
existing_report = db.query(HermesTaskConfig).filter_by(task_type="weekly_expense_report").first()
|
||||||
|
if not existing_report:
|
||||||
|
logger.info("No weekly_expense_report config found. Initializing default config.")
|
||||||
|
db.add(HermesTaskConfig(
|
||||||
|
task_type="weekly_expense_report",
|
||||||
|
cron_expression="0 9 * * 1", # 每周一早9点(在简化版中暂时代表周报频率)
|
||||||
|
is_enabled=True
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize default config: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
setup_logging()
|
||||||
|
logger.info("Initializing Hermes Background Daemon...")
|
||||||
|
|
||||||
|
# 注入默认配置
|
||||||
|
init_default_config()
|
||||||
|
|
||||||
|
# 启动调度器
|
||||||
|
hermes_scheduler.start()
|
||||||
|
|
||||||
|
logger.info("Hermes Daemon is running. Press Ctrl+C to stop.")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1) # 主线程保持存活
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Keyboard interrupt received. Shutting down...")
|
||||||
|
hermes_scheduler.shutdown()
|
||||||
|
logger.info("Shutdown complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -23,7 +23,16 @@ from app.schemas.agent_asset import (
|
|||||||
AgentAssetRead,
|
AgentAssetRead,
|
||||||
AgentAssetReviewCreate,
|
AgentAssetReviewCreate,
|
||||||
AgentAssetReviewRead,
|
AgentAssetReviewRead,
|
||||||
|
AgentAssetRiskRuleEnabledUpdate,
|
||||||
AgentAssetRiskRuleGenerateRequest,
|
AgentAssetRiskRuleGenerateRequest,
|
||||||
|
AgentAssetRiskRuleLatestTestSummary,
|
||||||
|
AgentAssetRiskRuleReportRequest,
|
||||||
|
AgentAssetRiskRuleReturnRequest,
|
||||||
|
AgentAssetRiskRuleSampleTestRequest,
|
||||||
|
AgentAssetRiskRuleScenarioTestRequest,
|
||||||
|
AgentAssetRiskRuleSimulationRead,
|
||||||
|
AgentAssetRiskRuleSimulationRequest,
|
||||||
|
AgentAssetRiskRuleTestRunRead,
|
||||||
AgentAssetRuleJsonRead,
|
AgentAssetRuleJsonRead,
|
||||||
AgentAssetRuleJsonWrite,
|
AgentAssetRuleJsonWrite,
|
||||||
AgentAssetSpreadsheetChangeRecordRead,
|
AgentAssetSpreadsheetChangeRecordRead,
|
||||||
@@ -131,6 +140,116 @@ def get_agent_asset_rule_json(
|
|||||||
_handle_asset_error(exc)
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/risk-rule-tests/latest",
|
||||||
|
response_model=AgentAssetRiskRuleLatestTestSummary,
|
||||||
|
summary="读取风险规则最近测试摘要",
|
||||||
|
description="返回当前风险规则工作版本最近一次样例测试、场景试运行和测试报告。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_risk_rule_latest_test(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> AgentAssetRiskRuleLatestTestSummary:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).get_latest_risk_rule_test_summary(asset_id)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/risk-rule-tests/simulate",
|
||||||
|
response_model=AgentAssetRiskRuleSimulationRead,
|
||||||
|
summary="执行风险规则对话仿真",
|
||||||
|
description="基于临时对话输入和附件元信息执行风险识别,不创建业务单据,不写入测试记录。",
|
||||||
|
)
|
||||||
|
def simulate_agent_asset_risk_rule_test(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRiskRuleSimulationRequest,
|
||||||
|
_: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> AgentAssetRiskRuleSimulationRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).simulate_risk_rule_message(asset_id, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/risk-rule-tests/sample",
|
||||||
|
response_model=AgentAssetRiskRuleTestRunRead,
|
||||||
|
summary="执行风险规则快速样例测试",
|
||||||
|
description="使用人工样例或系统默认样例执行当前 JSON 风险规则,不依赖大模型判断结果。",
|
||||||
|
)
|
||||||
|
def run_agent_asset_risk_rule_sample_test(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRiskRuleSampleTestRequest,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).run_risk_rule_sample_test(
|
||||||
|
asset_id,
|
||||||
|
payload,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/risk-rule-tests/scenario",
|
||||||
|
response_model=AgentAssetRiskRuleTestRunRead,
|
||||||
|
summary="执行风险规则真实场景试运行",
|
||||||
|
description="按测试意图读取真实业务样本并沙盒执行风险规则,不写回业务单据。",
|
||||||
|
)
|
||||||
|
def run_agent_asset_risk_rule_scenario_test(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRiskRuleScenarioTestRequest,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).run_risk_rule_scenario_test(
|
||||||
|
asset_id,
|
||||||
|
payload,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/risk-rule-tests/report",
|
||||||
|
response_model=AgentAssetRiskRuleTestRunRead,
|
||||||
|
summary="确认风险规则测试报告",
|
||||||
|
description="在样例测试和真实场景试运行通过后,保存当前版本测试通过记录。",
|
||||||
|
)
|
||||||
|
def confirm_agent_asset_risk_rule_test_report(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRiskRuleReportRequest,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).confirm_risk_rule_test_report(
|
||||||
|
asset_id,
|
||||||
|
payload,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{asset_id}/rule-json",
|
"/{asset_id}/rule-json",
|
||||||
response_model=AgentAssetRuleJsonRead,
|
response_model=AgentAssetRuleJsonRead,
|
||||||
@@ -586,6 +705,112 @@ def activate_agent_asset(
|
|||||||
_handle_asset_error(exc)
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/risk-rule-enabled",
|
||||||
|
response_model=AgentAssetRead,
|
||||||
|
summary="设置风险规则启用状态",
|
||||||
|
description=(
|
||||||
|
"高级管理人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def set_agent_asset_risk_rule_enabled(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRiskRuleEnabledUpdate,
|
||||||
|
current_user: RuleReviewerUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRead:
|
||||||
|
try:
|
||||||
|
asset = AgentAssetService(db).set_risk_rule_enabled(
|
||||||
|
asset_id,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
detail = AgentAssetService(db).get_asset(asset.id)
|
||||||
|
if detail is None:
|
||||||
|
raise LookupError("Asset not found")
|
||||||
|
return detail
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/return",
|
||||||
|
response_model=AgentAssetRiskRuleLatestTestSummary,
|
||||||
|
summary="回退待审核风险规则",
|
||||||
|
description="高级管理人员将待审核风险规则回退到草稿,并记录回退原因。",
|
||||||
|
)
|
||||||
|
def return_agent_asset_risk_rule(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRiskRuleReturnRequest,
|
||||||
|
current_user: RuleReviewerUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRiskRuleLatestTestSummary:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).return_risk_rule(
|
||||||
|
asset_id,
|
||||||
|
note=payload.note,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/publish",
|
||||||
|
response_model=AgentAssetRead,
|
||||||
|
summary="审核并发布风险规则",
|
||||||
|
description="高级管理人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
|
||||||
|
)
|
||||||
|
def publish_agent_asset_risk_rule(
|
||||||
|
asset_id: str,
|
||||||
|
current_user: RuleReviewerUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRead:
|
||||||
|
try:
|
||||||
|
asset = AgentAssetService(db).publish_risk_rule(
|
||||||
|
asset_id,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
detail = AgentAssetService(db).get_asset(asset.id)
|
||||||
|
if detail is None:
|
||||||
|
raise LookupError("Asset not found")
|
||||||
|
return detail
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{asset_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="删除未发布风险规则",
|
||||||
|
description="仅允许删除从未发布过的 JSON 风险规则,并同步删除规则 JSON 文件。",
|
||||||
|
)
|
||||||
|
def delete_agent_asset(
|
||||||
|
asset_id: str,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
AgentAssetService(db).delete_unpublished_asset(
|
||||||
|
asset_id,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{asset_id}/versions/{version}/restore",
|
"/{asset_id}/versions/{version}/restore",
|
||||||
response_model=AgentAssetRead,
|
response_model=AgentAssetRead,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun, AgentAssetVersion
|
||||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||||
from app.models.approval import ApprovalRecord
|
from app.models.approval import ApprovalRecord
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
@@ -27,6 +27,7 @@ __all__ = [
|
|||||||
"AgentConversationMessage",
|
"AgentConversationMessage",
|
||||||
"AgentAsset",
|
"AgentAsset",
|
||||||
"AgentAssetReview",
|
"AgentAssetReview",
|
||||||
|
"AgentAssetTestRun",
|
||||||
"AgentAssetVersion",
|
"AgentAssetVersion",
|
||||||
"AgentRun",
|
"AgentRun",
|
||||||
"AgentToolCall",
|
"AgentToolCall",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from app.models.financial_record import (
|
|||||||
ExpenseClaim,
|
ExpenseClaim,
|
||||||
ExpenseClaimItem,
|
ExpenseClaimItem,
|
||||||
)
|
)
|
||||||
|
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||||
|
from app.models.hermes_report import HermesRiskReport
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
@@ -34,6 +36,9 @@ __all__ = [
|
|||||||
"EmployeeChangeLog",
|
"EmployeeChangeLog",
|
||||||
"ExpenseClaim",
|
"ExpenseClaim",
|
||||||
"ExpenseClaimItem",
|
"ExpenseClaimItem",
|
||||||
|
"HermesTaskConfig",
|
||||||
|
"HermesTaskExecutionLog",
|
||||||
|
"HermesRiskReport",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"Role",
|
"Role",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
@@ -46,6 +46,12 @@ class AgentAsset(Base):
|
|||||||
order_by="desc(AgentAssetReview.created_at)",
|
order_by="desc(AgentAssetReview.created_at)",
|
||||||
)
|
)
|
||||||
scheduled_runs = relationship("AgentRun", back_populates="task_asset")
|
scheduled_runs = relationship("AgentRun", back_populates="task_asset")
|
||||||
|
test_runs = relationship(
|
||||||
|
"AgentAssetTestRun",
|
||||||
|
back_populates="asset",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="desc(AgentAssetTestRun.created_at)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetVersion(Base):
|
class AgentAssetVersion(Base):
|
||||||
@@ -79,3 +85,21 @@ class AgentAssetReview(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
asset = relationship("AgentAsset", back_populates="reviews")
|
asset = relationship("AgentAsset", back_populates="reviews")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetTestRun(Base):
|
||||||
|
__tablename__ = "agent_asset_test_runs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
|
||||||
|
version: Mapped[str] = mapped_column(String(30), index=True)
|
||||||
|
test_type: Mapped[str] = mapped_column(String(30), index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), index=True)
|
||||||
|
passed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
summary: Mapped[str] = mapped_column(Text(), default="")
|
||||||
|
input_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(100))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
asset = relationship("AgentAsset", back_populates="test_runs")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, String, Table, func
|
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Table, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
@@ -35,6 +35,7 @@ class Employee(Base):
|
|||||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
|
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
|
||||||
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
|
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
|
||||||
|
compliance_score: Mapped[int] = mapped_column(Integer, default=100)
|
||||||
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)
|
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
organization_unit_id: Mapped[str | None] = mapped_column(
|
organization_unit_id: Mapped[str | None] = mapped_column(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ class ExpenseClaim(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(30), index=True)
|
status: Mapped[str] = mapped_column(String(30), index=True)
|
||||||
approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
risk_flags_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
risk_flags_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
|
hermes_scanned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
hermes_risk_flag: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
|||||||
48
server/src/app/models/hermes_config.py
Normal file
48
server/src/app/models/hermes_config.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HermesTaskConfig(Base):
|
||||||
|
__tablename__ = "hermes_task_configs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
task_type: Mapped[str] = mapped_column(String(50), index=True)
|
||||||
|
cron_expression: Mapped[str] = mapped_column(String(100))
|
||||||
|
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
payload_template: 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
execution_logs = relationship(
|
||||||
|
"HermesTaskExecutionLog",
|
||||||
|
back_populates="config",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="desc(HermesTaskExecutionLog.started_at)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HermesTaskExecutionLog(Base):
|
||||||
|
__tablename__ = "hermes_task_execution_logs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
config_id: Mapped[str] = mapped_column(String(36), ForeignKey("hermes_task_configs.id"), index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), index=True)
|
||||||
|
result_summary: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
error_trace: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||||
|
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
config = relationship("HermesTaskConfig", back_populates="execution_logs")
|
||||||
34
server/src/app/models/hermes_report.py
Normal file
34
server/src/app/models/hermes_report.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HermesRiskReport(Base):
|
||||||
|
__tablename__ = "hermes_risk_reports"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
claim_id: Mapped[str] = mapped_column(ForeignKey("expense_claims.id"), index=True)
|
||||||
|
execution_log_id: Mapped[str] = mapped_column(ForeignKey("hermes_task_execution_logs.id"), index=True)
|
||||||
|
|
||||||
|
risk_level: Mapped[str] = mapped_column(String(20), index=True)
|
||||||
|
risk_type: Mapped[str] = mapped_column(String(50), index=True)
|
||||||
|
risk_description: Mapped[str] = mapped_column(Text())
|
||||||
|
|
||||||
|
related_claim_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="pending_review", index=True)
|
||||||
|
|
||||||
|
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])
|
||||||
|
execution_log = relationship("HermesTaskExecutionLog", foreign_keys=[execution_log_id])
|
||||||
@@ -3,7 +3,12 @@ from __future__ import annotations
|
|||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
from app.models.agent_asset import (
|
||||||
|
AgentAsset,
|
||||||
|
AgentAssetReview,
|
||||||
|
AgentAssetTestRun,
|
||||||
|
AgentAssetVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetRepository:
|
class AgentAssetRepository:
|
||||||
@@ -84,6 +89,17 @@ class AgentAssetRepository:
|
|||||||
stmt = stmt.limit(limit)
|
stmt = stmt.limit(limit)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def list_reviews_for_assets(self, asset_ids: list[str]) -> list[AgentAssetReview]:
|
||||||
|
if not asset_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(AgentAssetReview)
|
||||||
|
.where(AgentAssetReview.asset_id.in_(asset_ids))
|
||||||
|
.order_by(AgentAssetReview.asset_id, AgentAssetReview.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def get_review(
|
def get_review(
|
||||||
self, asset_id: str, version: str, review_status: str | None = None
|
self, asset_id: str, version: str, review_status: str | None = None
|
||||||
) -> AgentAssetReview | None:
|
) -> AgentAssetReview | None:
|
||||||
@@ -119,3 +135,54 @@ class AgentAssetRepository:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(review)
|
self.db.refresh(review)
|
||||||
return review
|
return review
|
||||||
|
|
||||||
|
def list_test_runs(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
*,
|
||||||
|
version: str | None = None,
|
||||||
|
test_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[AgentAssetTestRun]:
|
||||||
|
stmt = (
|
||||||
|
select(AgentAssetTestRun)
|
||||||
|
.where(AgentAssetTestRun.asset_id == asset_id)
|
||||||
|
.order_by(AgentAssetTestRun.created_at.desc())
|
||||||
|
)
|
||||||
|
if version:
|
||||||
|
stmt = stmt.where(AgentAssetTestRun.version == version)
|
||||||
|
if test_type:
|
||||||
|
stmt = stmt.where(AgentAssetTestRun.test_type == test_type)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(AgentAssetTestRun.status == status)
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def get_latest_test_run(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
*,
|
||||||
|
version: str | None = None,
|
||||||
|
test_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> AgentAssetTestRun | None:
|
||||||
|
items = self.list_test_runs(
|
||||||
|
asset_id,
|
||||||
|
version=version,
|
||||||
|
test_type=test_type,
|
||||||
|
status=status,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
def create_test_run(self, test_run: AgentAssetTestRun) -> AgentAssetTestRun:
|
||||||
|
self.db.add(test_run)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(test_run)
|
||||||
|
return test_run
|
||||||
|
|
||||||
|
def delete_asset(self, asset: AgentAsset) -> None:
|
||||||
|
self.db.delete(asset)
|
||||||
|
self.db.commit()
|
||||||
|
|||||||
@@ -112,8 +112,111 @@ class AgentAssetRuleJsonRead(BaseModel):
|
|||||||
|
|
||||||
class AgentAssetRiskRuleGenerateRequest(BaseModel):
|
class AgentAssetRiskRuleGenerateRequest(BaseModel):
|
||||||
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
|
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
|
||||||
|
expense_category: str | None = Field(default=None, max_length=40)
|
||||||
risk_level: str = Field(default="medium", pattern="^(low|medium|high)$")
|
risk_level: str = Field(default="medium", pattern="^(low|medium|high)$")
|
||||||
natural_language: str = Field(min_length=8, max_length=2000)
|
natural_language: str = Field(min_length=8, max_length=2000)
|
||||||
|
requires_attachment: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleSampleCase(BaseModel):
|
||||||
|
case_id: str | None = Field(default=None, max_length=60)
|
||||||
|
name: str = Field(default="测试样例", min_length=1, max_length=80)
|
||||||
|
values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
expected_hit: bool = True
|
||||||
|
expected_severity: str | None = Field(default=None, max_length=20)
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleSampleTestRequest(BaseModel):
|
||||||
|
version: str | None = Field(default=None, max_length=30)
|
||||||
|
cases: list[AgentAssetRiskRuleSampleCase] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleScenarioTestRequest(BaseModel):
|
||||||
|
version: str | None = Field(default=None, max_length=30)
|
||||||
|
intent: str = Field(default="", max_length=1000)
|
||||||
|
filters: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleReportRequest(BaseModel):
|
||||||
|
version: str | None = Field(default=None, max_length=30)
|
||||||
|
confirm_passed: bool = True
|
||||||
|
note: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleSimulationAttachment(BaseModel):
|
||||||
|
name: str = Field(default="", max_length=240)
|
||||||
|
content_type: str | None = Field(default=None, max_length=120)
|
||||||
|
size: int | None = Field(default=None, ge=0)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
ocr_text: str | None = Field(default=None, max_length=20000)
|
||||||
|
summary: str | None = Field(default=None, max_length=2000)
|
||||||
|
document_type: str | None = Field(default=None, max_length=80)
|
||||||
|
document_type_label: str | None = Field(default=None, max_length=120)
|
||||||
|
scene_code: str | None = Field(default=None, max_length=80)
|
||||||
|
scene_label: str | None = Field(default=None, max_length=120)
|
||||||
|
avg_score: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
recognition_status: str | None = Field(default=None, max_length=40)
|
||||||
|
document_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleSimulationRequest(BaseModel):
|
||||||
|
version: str | None = Field(default=None, max_length=30)
|
||||||
|
message: str = Field(default="", max_length=4000)
|
||||||
|
field_values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
attachments: list[AgentAssetRiskRuleSimulationAttachment] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleSimulationRead(BaseModel):
|
||||||
|
version: str
|
||||||
|
ready: bool = True
|
||||||
|
stage: str = "executed"
|
||||||
|
hit: bool
|
||||||
|
severity: str = "none"
|
||||||
|
severity_label: str = "未命中"
|
||||||
|
summary: str
|
||||||
|
blocking_reason: str = ""
|
||||||
|
message: str = ""
|
||||||
|
field_values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
evidence: 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)
|
||||||
|
recognition_summary: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
execution_mode: str = "risk_rule_simulation"
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleReturnRequest(BaseModel):
|
||||||
|
note: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleEnabledUpdate(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleTestRunRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
asset_id: str
|
||||||
|
version: str
|
||||||
|
test_type: str
|
||||||
|
status: str
|
||||||
|
passed: bool
|
||||||
|
summary: str
|
||||||
|
input_json: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
result_json: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleLatestTestSummary(BaseModel):
|
||||||
|
version: str = ""
|
||||||
|
sample: AgentAssetRiskRuleTestRunRead | None = None
|
||||||
|
scenario: AgentAssetRiskRuleTestRunRead | None = None
|
||||||
|
report: AgentAssetRiskRuleTestRunRead | None = None
|
||||||
|
test_passed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetVersionTimelineItemRead(BaseModel):
|
class AgentAssetVersionTimelineItemRead(BaseModel):
|
||||||
@@ -187,6 +290,8 @@ class AgentAssetListItem(BaseModel):
|
|||||||
config_json: dict[str, Any]
|
config_json: dict[str, Any]
|
||||||
change_count: int = 0
|
change_count: int = 0
|
||||||
modified_by: str | None = None
|
modified_by: str | None = None
|
||||||
|
published_by: str | None = None
|
||||||
|
published_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -197,3 +302,4 @@ class AgentAssetRead(AgentAssetListItem):
|
|||||||
current_version_change_note: str | None = None
|
current_version_change_note: str | None = None
|
||||||
recent_versions: list[AgentAssetVersionRead] = Field(default_factory=list)
|
recent_versions: list[AgentAssetVersionRead] = Field(default_factory=list)
|
||||||
latest_review: AgentAssetReviewRead | None = None
|
latest_review: AgentAssetReviewRead | None = None
|
||||||
|
latest_test_summary: AgentAssetRiskRuleLatestTestSummary | None = None
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ class SettingsRead(BaseModel):
|
|||||||
companyForm: SettingsCompanyForm
|
companyForm: SettingsCompanyForm
|
||||||
adminForm: SettingsAdminForm
|
adminForm: SettingsAdminForm
|
||||||
sessionForm: SettingsSessionForm
|
sessionForm: SettingsSessionForm
|
||||||
|
hermesForm: dict
|
||||||
llmForm: SettingsLlmForm
|
llmForm: SettingsLlmForm
|
||||||
renderForm: SettingsRenderForm
|
renderForm: SettingsRenderForm
|
||||||
logForm: SettingsLogForm
|
logForm: SettingsLogForm
|
||||||
@@ -174,6 +175,7 @@ class SettingsWrite(BaseModel):
|
|||||||
companyForm: SettingsCompanyForm
|
companyForm: SettingsCompanyForm
|
||||||
adminForm: SettingsAdminForm
|
adminForm: SettingsAdminForm
|
||||||
sessionForm: SettingsSessionForm
|
sessionForm: SettingsSessionForm
|
||||||
|
hermesForm: dict
|
||||||
llmForm: SettingsLlmForm
|
llmForm: SettingsLlmForm
|
||||||
renderForm: SettingsRenderForm
|
renderForm: SettingsRenderForm
|
||||||
logForm: SettingsLogForm
|
logForm: SettingsLogForm
|
||||||
|
|||||||
552
server/src/app/services/agent_asset_risk_rule_simulation.py
Normal file
552
server/src/app/services/agent_asset_risk_rule_simulation.py
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.schemas.agent_asset import (
|
||||||
|
AgentAssetRiskRuleSimulationAttachment,
|
||||||
|
AgentAssetRiskRuleSimulationRead,
|
||||||
|
AgentAssetRiskRuleSimulationRequest,
|
||||||
|
)
|
||||||
|
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleSimulationMixin:
|
||||||
|
def simulate_risk_rule_message(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
body: AgentAssetRiskRuleSimulationRequest,
|
||||||
|
) -> AgentAssetRiskRuleSimulationRead:
|
||||||
|
_, version, manifest = self._load_risk_rule_for_test(asset_id, body.version)
|
||||||
|
attachments = self._normalize_simulation_attachments(body.attachments)
|
||||||
|
field_values, source_map, recognized_fields = self._build_simulation_field_values(
|
||||||
|
manifest,
|
||||||
|
message=body.message,
|
||||||
|
explicit_values=body.field_values,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
recognition_summary = self._build_recognition_summary(attachments)
|
||||||
|
required_keys = self._extract_execution_field_keys(manifest)
|
||||||
|
missing_fields = self._build_missing_fields(
|
||||||
|
manifest,
|
||||||
|
field_values=field_values,
|
||||||
|
source_map=source_map,
|
||||||
|
required_keys=required_keys,
|
||||||
|
)
|
||||||
|
block = self._resolve_simulation_block(
|
||||||
|
manifest,
|
||||||
|
message=body.message,
|
||||||
|
attachments=attachments,
|
||||||
|
missing_fields=missing_fields,
|
||||||
|
)
|
||||||
|
if block:
|
||||||
|
return AgentAssetRiskRuleSimulationRead(
|
||||||
|
version=version,
|
||||||
|
ready=False,
|
||||||
|
stage=block["stage"],
|
||||||
|
hit=False,
|
||||||
|
severity="none",
|
||||||
|
severity_label="待补充",
|
||||||
|
summary=block["summary"],
|
||||||
|
blocking_reason=block["reason"],
|
||||||
|
field_values=field_values,
|
||||||
|
attachments=attachments,
|
||||||
|
recognized_fields=recognized_fields,
|
||||||
|
missing_fields=missing_fields,
|
||||||
|
recognition_summary=recognition_summary,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
claim, contexts = self._build_synthetic_claim(field_values, manifest)
|
||||||
|
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts)
|
||||||
|
hit = result is not None
|
||||||
|
severity = (
|
||||||
|
str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "medium")
|
||||||
|
if hit
|
||||||
|
else "none"
|
||||||
|
)
|
||||||
|
severity_label = self._risk_severity_label(severity)
|
||||||
|
message = str(result.get("message") or "") if isinstance(result, dict) else ""
|
||||||
|
summary = (
|
||||||
|
f"本次仿真命中{severity_label},仅生成风险识别结果,不创建业务单据。"
|
||||||
|
if hit
|
||||||
|
else "本次仿真未命中风险,仅完成规则识别,不创建业务单据。"
|
||||||
|
)
|
||||||
|
evidence = result.get("evidence") if isinstance(result, dict) else {}
|
||||||
|
return AgentAssetRiskRuleSimulationRead(
|
||||||
|
version=version,
|
||||||
|
ready=True,
|
||||||
|
stage="executed",
|
||||||
|
hit=hit,
|
||||||
|
severity=severity,
|
||||||
|
severity_label=severity_label,
|
||||||
|
summary=summary,
|
||||||
|
message=message,
|
||||||
|
field_values=field_values,
|
||||||
|
evidence=evidence if isinstance(evidence, dict) else {},
|
||||||
|
attachments=attachments,
|
||||||
|
recognized_fields=recognized_fields,
|
||||||
|
missing_fields=[],
|
||||||
|
recognition_summary=recognition_summary,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_simulation_field_values(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
explicit_values: dict[str, Any],
|
||||||
|
attachments: list[dict[str, Any]],
|
||||||
|
) -> tuple[dict[str, Any], dict[str, str], list[dict[str, Any]]]:
|
||||||
|
fields = self._extract_manifest_fields(manifest)
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
source_map: dict[str, str] = {}
|
||||||
|
safe_explicit_values = explicit_values if isinstance(explicit_values, dict) else {}
|
||||||
|
corpus = self._build_simulation_corpus(message, attachments)
|
||||||
|
city_mentions = self._extract_city_mentions(corpus)
|
||||||
|
|
||||||
|
for field in fields:
|
||||||
|
key = field["key"]
|
||||||
|
explicit_value = safe_explicit_values.get(key)
|
||||||
|
if self._has_meaningful_value(explicit_value):
|
||||||
|
values[key] = explicit_value
|
||||||
|
source_map[key] = "manual"
|
||||||
|
continue
|
||||||
|
attachment_value = self._find_attachment_field_value(
|
||||||
|
key,
|
||||||
|
field.get("label") or key,
|
||||||
|
attachments,
|
||||||
|
)
|
||||||
|
if self._has_meaningful_value(attachment_value):
|
||||||
|
values[key] = attachment_value
|
||||||
|
source_map[key] = "ocr"
|
||||||
|
continue
|
||||||
|
inferred = self._infer_simulation_value(
|
||||||
|
key,
|
||||||
|
field.get("label") or key,
|
||||||
|
corpus=corpus,
|
||||||
|
city_mentions=city_mentions,
|
||||||
|
)
|
||||||
|
if self._has_meaningful_value(inferred):
|
||||||
|
values[key] = inferred
|
||||||
|
source_map[key] = "inferred"
|
||||||
|
|
||||||
|
self._apply_compare_city_hints(manifest, values, source_map, city_mentions)
|
||||||
|
recognized_fields = self._build_recognized_fields(fields, values, source_map)
|
||||||
|
return values, source_map, recognized_fields
|
||||||
|
|
||||||
|
def _infer_simulation_value(
|
||||||
|
self,
|
||||||
|
field_key: str,
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
corpus: str,
|
||||||
|
city_mentions: list[str],
|
||||||
|
) -> Any:
|
||||||
|
key_text = f"{field_key} {label}".lower()
|
||||||
|
if field_key.endswith("route_cities"):
|
||||||
|
return city_mentions or []
|
||||||
|
if "city" in field_key or "location" in field_key:
|
||||||
|
if any(
|
||||||
|
token in key_text
|
||||||
|
for token in ("hotel", "invoice", "attachment", "发票", "酒店", "住宿")
|
||||||
|
):
|
||||||
|
return city_mentions[0] if city_mentions else ""
|
||||||
|
if any(token in key_text for token in ("route", "trip", "目的", "行程", "申报")):
|
||||||
|
return (
|
||||||
|
city_mentions[1]
|
||||||
|
if len(city_mentions) > 1
|
||||||
|
else (city_mentions[0] if city_mentions else "")
|
||||||
|
)
|
||||||
|
return city_mentions[0] if city_mentions else ""
|
||||||
|
if field_key.endswith("amount"):
|
||||||
|
return self._extract_amount(corpus)
|
||||||
|
if field_key.endswith("issue_date") or field_key.endswith("item_date"):
|
||||||
|
return self._extract_iso_date(corpus)
|
||||||
|
if field_key.endswith("invoice_no"):
|
||||||
|
return self._extract_invoice_no(corpus)
|
||||||
|
if field_key.endswith("ocr_text"):
|
||||||
|
return corpus
|
||||||
|
if field_key.endswith("goods_name"):
|
||||||
|
return self._infer_goods_name(corpus)
|
||||||
|
if field_key.endswith("item_type"):
|
||||||
|
return self._infer_item_type(corpus)
|
||||||
|
if field_key.endswith("reason") or field_key.endswith("item_reason"):
|
||||||
|
return corpus or "仿真测试报销事由"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _apply_compare_city_hints(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
values: dict[str, Any],
|
||||||
|
source_map: dict[str, str],
|
||||||
|
city_mentions: list[str],
|
||||||
|
) -> None:
|
||||||
|
if len(city_mentions) < 2:
|
||||||
|
return
|
||||||
|
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||||
|
conditions = params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||||||
|
for condition in conditions:
|
||||||
|
if not isinstance(condition, dict):
|
||||||
|
continue
|
||||||
|
left = str(condition.get("left") or "").strip()
|
||||||
|
right = str(condition.get("right") or "").strip()
|
||||||
|
if not left or not right:
|
||||||
|
continue
|
||||||
|
if self._looks_like_city_field(left):
|
||||||
|
values[left] = city_mentions[0]
|
||||||
|
source_map[left] = source_map.get(left) or "inferred"
|
||||||
|
if self._looks_like_city_field(right):
|
||||||
|
values[right] = city_mentions[1]
|
||||||
|
source_map[right] = source_map.get(right) or "inferred"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_simulation_attachments(
|
||||||
|
attachments: list[AgentAssetRiskRuleSimulationAttachment],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for item in list(attachments or [])[:12]:
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"name": str(item.name or "").strip(),
|
||||||
|
"content_type": str(item.content_type or "").strip(),
|
||||||
|
"size": item.size or 0,
|
||||||
|
"note": str(item.note or "").strip(),
|
||||||
|
"ocr_text": str(item.ocr_text or "").strip(),
|
||||||
|
"summary": str(item.summary or "").strip(),
|
||||||
|
"document_type": str(item.document_type or "").strip(),
|
||||||
|
"document_type_label": str(item.document_type_label or "").strip(),
|
||||||
|
"scene_code": str(item.scene_code or "").strip(),
|
||||||
|
"scene_label": str(item.scene_label or "").strip(),
|
||||||
|
"avg_score": float(item.avg_score or 0.0),
|
||||||
|
"recognition_status": str(item.recognition_status or "").strip(),
|
||||||
|
"document_fields": AgentAssetRiskRuleSimulationMixin._normalize_document_fields(
|
||||||
|
item.document_fields
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_simulation_corpus(message: str, attachments: list[dict[str, Any]]) -> str:
|
||||||
|
parts = [str(message or "").strip()]
|
||||||
|
for item in attachments:
|
||||||
|
parts.append(str(item.get("name") or "").strip())
|
||||||
|
parts.append(str(item.get("note") or "").strip())
|
||||||
|
parts.append(str(item.get("summary") or "").strip())
|
||||||
|
parts.append(str(item.get("ocr_text") or "").strip())
|
||||||
|
for field in list(item.get("document_fields") or []):
|
||||||
|
if isinstance(field, dict):
|
||||||
|
parts.append(str(field.get("value") or "").strip())
|
||||||
|
return "\n".join(part for part in parts if part)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_document_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for field in list(fields or [])[:30]:
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
continue
|
||||||
|
key = str(field.get("key") or "").strip()
|
||||||
|
label = str(field.get("label") or "").strip()
|
||||||
|
value = field.get("value")
|
||||||
|
if key and label and AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||||
|
normalized.append({"key": key, "label": label, "value": value})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _find_attachment_field_value(
|
||||||
|
self,
|
||||||
|
field_key: str,
|
||||||
|
label: str,
|
||||||
|
attachments: list[dict[str, Any]],
|
||||||
|
) -> Any:
|
||||||
|
short_key = field_key.removeprefix("attachment.")
|
||||||
|
for attachment in attachments:
|
||||||
|
if short_key == "ocr_text":
|
||||||
|
value = attachment.get("ocr_text") or attachment.get("summary")
|
||||||
|
if self._has_meaningful_value(value):
|
||||||
|
return value
|
||||||
|
for field in list(attachment.get("document_fields") or []):
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
continue
|
||||||
|
candidate_key = str(field.get("key") or "").strip().lower()
|
||||||
|
candidate_label = str(field.get("label") or "").strip()
|
||||||
|
if self._field_matches_simulation_key(
|
||||||
|
candidate_key, candidate_label, short_key, label
|
||||||
|
):
|
||||||
|
return field.get("value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _field_matches_simulation_key(
|
||||||
|
candidate_key: str,
|
||||||
|
candidate_label: str,
|
||||||
|
short_key: str,
|
||||||
|
target_label: str,
|
||||||
|
) -> bool:
|
||||||
|
compact_candidate = candidate_key.replace("_", "")
|
||||||
|
compact_target = short_key.replace("_", "").lower()
|
||||||
|
if compact_target and compact_target in compact_candidate:
|
||||||
|
return True
|
||||||
|
label_text = f"{candidate_label} {target_label}"
|
||||||
|
label_map = {
|
||||||
|
"invoice_no": ("发票号", "发票号码", "票号"),
|
||||||
|
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
|
||||||
|
"route_cities": ("行程", "路线", "目的地", "出差城市"),
|
||||||
|
"goods_name": ("品名", "商品", "服务名称"),
|
||||||
|
"amount": ("金额", "价税合计", "合计"),
|
||||||
|
"issue_date": ("日期", "开票日期", "发票日期"),
|
||||||
|
}
|
||||||
|
return any(token in label_text for token in label_map.get(short_key, ()))
|
||||||
|
|
||||||
|
def _extract_execution_field_keys(self, manifest: dict[str, Any]) -> list[str]:
|
||||||
|
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||||
|
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
|
||||||
|
keys: list[str] = []
|
||||||
|
if template_key == "field_compare_v1":
|
||||||
|
conditions = (
|
||||||
|
params.get("conditions") if isinstance(params.get("conditions"), list) else []
|
||||||
|
)
|
||||||
|
for condition in conditions:
|
||||||
|
if not isinstance(condition, dict):
|
||||||
|
continue
|
||||||
|
for side in ("left", "right"):
|
||||||
|
key = str(condition.get(side) or "").strip()
|
||||||
|
if key and key not in keys:
|
||||||
|
keys.append(key)
|
||||||
|
elif template_key == "keyword_match_v1":
|
||||||
|
for key in self._read_string_list(
|
||||||
|
params.get("search_fields") or params.get("field_keys")
|
||||||
|
):
|
||||||
|
if key not in keys:
|
||||||
|
keys.append(key)
|
||||||
|
elif template_key == "field_required_v1":
|
||||||
|
return []
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def _build_missing_fields(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
*,
|
||||||
|
field_values: dict[str, Any],
|
||||||
|
source_map: dict[str, str],
|
||||||
|
required_keys: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
labels = {field["key"]: field["label"] for field in self._extract_manifest_fields(manifest)}
|
||||||
|
missing: list[dict[str, Any]] = []
|
||||||
|
for key in required_keys:
|
||||||
|
value = field_values.get(key)
|
||||||
|
if key not in source_map or not self._has_meaningful_value(value):
|
||||||
|
missing.append({"key": key, "label": labels.get(key, key)})
|
||||||
|
return missing
|
||||||
|
|
||||||
|
def _resolve_simulation_block(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
attachments: list[dict[str, Any]],
|
||||||
|
missing_fields: list[dict[str, Any]],
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
has_attachment = bool(attachments)
|
||||||
|
requires_attachment = self._rule_requires_attachment(manifest)
|
||||||
|
has_recognition = any(
|
||||||
|
self._has_meaningful_value(item.get("ocr_text"))
|
||||||
|
or self._has_meaningful_value(item.get("summary"))
|
||||||
|
or self._has_meaningful_value(item.get("document_fields"))
|
||||||
|
for item in attachments
|
||||||
|
)
|
||||||
|
has_user_evidence = self._has_meaningful_user_message(message)
|
||||||
|
if requires_attachment and not has_attachment:
|
||||||
|
return {
|
||||||
|
"stage": "needs_attachment",
|
||||||
|
"summary": "当前规则要求上传附件,暂不能仅凭文字执行风险判断。",
|
||||||
|
"reason": "请上传测试单据,并填写本次测试意图后再执行仿真。",
|
||||||
|
}
|
||||||
|
if requires_attachment and not has_user_evidence:
|
||||||
|
return {
|
||||||
|
"stage": "needs_test_intent",
|
||||||
|
"summary": "当前规则要求附件和测试说明一起进入仿真判断。",
|
||||||
|
"reason": "请补充本次测试意图或关键业务事实,再执行风险识别。",
|
||||||
|
}
|
||||||
|
if has_attachment and not has_recognition and not has_user_evidence:
|
||||||
|
return {
|
||||||
|
"stage": "needs_recognition",
|
||||||
|
"summary": "单据尚未完成识别,暂不能执行风险规则。",
|
||||||
|
"reason": "请先完成 OCR 识别,或在对话中补充票据城市、金额、发票号等关键信息。",
|
||||||
|
}
|
||||||
|
template_key = str(
|
||||||
|
manifest.get("template_key") or (manifest.get("params") or {}).get("template_key") or ""
|
||||||
|
).strip()
|
||||||
|
if template_key != "field_required_v1" and missing_fields:
|
||||||
|
labels = "、".join(
|
||||||
|
str(item.get("label") or item.get("key")) for item in missing_fields[:4]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"stage": "needs_field_confirmation",
|
||||||
|
"summary": f"还缺少规则执行所需字段:{labels},暂不能判断是否命中。",
|
||||||
|
"reason": "请补充缺失字段,或上传可识别出这些字段的票据后再执行。",
|
||||||
|
}
|
||||||
|
if not has_attachment and not has_user_evidence:
|
||||||
|
return {
|
||||||
|
"stage": "needs_input",
|
||||||
|
"summary": "请先描述测试单据或上传票据,再执行风险识别。",
|
||||||
|
"reason": "当前没有可用于规则判断的业务事实。",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rule_requires_attachment(manifest: dict[str, Any]) -> bool:
|
||||||
|
if bool(manifest.get("requires_attachment")):
|
||||||
|
return True
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
return bool(metadata.get("requires_attachment"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_meaningful_user_message(message: str) -> bool:
|
||||||
|
text = str(message or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
generic_prompts = (
|
||||||
|
"请识别我上传的临时单据是否命中这条风险规则",
|
||||||
|
"请识别上传单据是否命中风险规则",
|
||||||
|
)
|
||||||
|
return not any(prompt in text for prompt in generic_prompts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_recognized_fields(
|
||||||
|
fields: list[dict[str, str]],
|
||||||
|
values: dict[str, Any],
|
||||||
|
source_map: dict[str, str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
labels = {field["key"]: field["label"] for field in fields}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": labels.get(key, key),
|
||||||
|
"value": value,
|
||||||
|
"source": source_map.get(key, ""),
|
||||||
|
}
|
||||||
|
for key, value in values.items()
|
||||||
|
if source_map.get(key)
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": item.get("name") or "",
|
||||||
|
"status": item.get("recognition_status")
|
||||||
|
or (
|
||||||
|
"recognized"
|
||||||
|
if item.get("ocr_text") or item.get("document_fields")
|
||||||
|
else "pending"
|
||||||
|
),
|
||||||
|
"document_type_label": item.get("document_type_label") or "",
|
||||||
|
"scene_label": item.get("scene_label") or "",
|
||||||
|
"summary": item.get("summary") or "",
|
||||||
|
"field_count": len(list(item.get("document_fields") or [])),
|
||||||
|
"avg_score": item.get("avg_score") or 0.0,
|
||||||
|
}
|
||||||
|
for item in attachments
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_city_mentions(text: str) -> list[str]:
|
||||||
|
city_names = [
|
||||||
|
"北京",
|
||||||
|
"上海",
|
||||||
|
"广州",
|
||||||
|
"深圳",
|
||||||
|
"杭州",
|
||||||
|
"南京",
|
||||||
|
"成都",
|
||||||
|
"武汉",
|
||||||
|
"重庆",
|
||||||
|
"天津",
|
||||||
|
"苏州",
|
||||||
|
"西安",
|
||||||
|
]
|
||||||
|
pattern = "|".join(re.escape(city) for city in city_names)
|
||||||
|
found: list[str] = []
|
||||||
|
for match in re.finditer(pattern, text):
|
||||||
|
city = match.group(0)
|
||||||
|
if city not in found:
|
||||||
|
found.append(city)
|
||||||
|
return found
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_amount(text: str) -> str:
|
||||||
|
match = re.search(r"(\d{2,8}(?:\.\d{1,2})?)\s*(?:元|块|人民币|CNY)?", text, re.IGNORECASE)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_iso_date(text: str) -> str:
|
||||||
|
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
year, month, day = (int(part) for part in match.groups())
|
||||||
|
try:
|
||||||
|
return date(year, month, day).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_invoice_no(text: str) -> str:
|
||||||
|
match = re.search(r"(?:发票号|发票号码|票号)[::\s]*([A-Z0-9-]{6,32})", text, re.IGNORECASE)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_item_type(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
|
||||||
|
return "住宿费"
|
||||||
|
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
|
||||||
|
return "交通费"
|
||||||
|
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
|
||||||
|
return "餐饮费"
|
||||||
|
return "差旅费"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_goods_name(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if any(keyword in text for keyword in ("酒店", "住宿", "宾馆")):
|
||||||
|
return "住宿服务"
|
||||||
|
if any(keyword in text for keyword in ("机票", "航班", "火车", "高铁", "打车")):
|
||||||
|
return "交通服务"
|
||||||
|
if any(keyword in text for keyword in ("餐饮", "餐费", "招待")):
|
||||||
|
return "餐饮服务"
|
||||||
|
return "报销服务"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _looks_like_city_field(field_key: str) -> bool:
|
||||||
|
lowered = field_key.lower()
|
||||||
|
return "city" in lowered or "location" in lowered or lowered.endswith("route_cities")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_meaningful_value(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, str):
|
||||||
|
return bool(value.strip())
|
||||||
|
if isinstance(value, (list, tuple, set, dict)):
|
||||||
|
return bool(value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _risk_severity_label(severity: str) -> str:
|
||||||
|
return {
|
||||||
|
"low": "低风险",
|
||||||
|
"medium": "中风险",
|
||||||
|
"high": "高风险",
|
||||||
|
"none": "未命中",
|
||||||
|
}.get(str(severity or "").strip().lower(), "风险")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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()]
|
||||||
723
server/src/app/services/agent_asset_risk_rule_testing.py
Normal file
723
server/src/app/services/agent_asset_risk_rule_testing.py
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
|
||||||
|
from app.core.agent_enums import (
|
||||||
|
AgentAssetDomain,
|
||||||
|
AgentAssetStatus,
|
||||||
|
AgentAssetType,
|
||||||
|
AgentReviewStatus,
|
||||||
|
)
|
||||||
|
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.schemas.agent_asset import (
|
||||||
|
AgentAssetRiskRuleLatestTestSummary,
|
||||||
|
AgentAssetRiskRuleReportRequest,
|
||||||
|
AgentAssetRiskRuleSampleCase,
|
||||||
|
AgentAssetRiskRuleSampleTestRequest,
|
||||||
|
AgentAssetRiskRuleScenarioTestRequest,
|
||||||
|
AgentAssetRiskRuleTestRunRead,
|
||||||
|
)
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRiskRuleTestingMixin:
|
||||||
|
def get_latest_risk_rule_test_summary(
|
||||||
|
self,
|
||||||
|
asset_or_id: AgentAsset | str,
|
||||||
|
*,
|
||||||
|
version: str | None = None,
|
||||||
|
) -> AgentAssetRiskRuleLatestTestSummary:
|
||||||
|
asset = self._resolve_asset(asset_or_id)
|
||||||
|
target_version = self._resolve_target_version(asset, version)
|
||||||
|
sample = self.repository.get_latest_test_run(
|
||||||
|
asset.id, version=target_version, test_type="sample"
|
||||||
|
)
|
||||||
|
scenario = self.repository.get_latest_test_run(
|
||||||
|
asset.id, version=target_version, test_type="scenario"
|
||||||
|
)
|
||||||
|
report = self.repository.get_latest_test_run(
|
||||||
|
asset.id, version=target_version, test_type="report", status="passed"
|
||||||
|
)
|
||||||
|
return AgentAssetRiskRuleLatestTestSummary(
|
||||||
|
version=target_version,
|
||||||
|
sample=self._serialize_test_run(sample),
|
||||||
|
scenario=self._serialize_test_run(scenario),
|
||||||
|
report=self._serialize_test_run(report),
|
||||||
|
test_passed=bool(report and report.passed),
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_risk_rule_sample_test(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
body: AgentAssetRiskRuleSampleTestRequest,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
asset, version, manifest = self._load_risk_rule_for_test(asset_id, body.version)
|
||||||
|
cases = body.cases or self._build_default_sample_cases(manifest)
|
||||||
|
results = [self._run_sample_case(manifest, case) for case in cases]
|
||||||
|
passed = bool(results) and all(item["passed"] for item in results)
|
||||||
|
summary = f"快速样例测试 {'通过' if passed else '未通过'},共 {len(results)} 条。"
|
||||||
|
return self._create_test_run(
|
||||||
|
asset,
|
||||||
|
version=version,
|
||||||
|
test_type="sample",
|
||||||
|
passed=passed,
|
||||||
|
summary=summary,
|
||||||
|
input_json={"cases": [case.model_dump() for case in cases]},
|
||||||
|
result_json={"cases": results, "case_count": len(results)},
|
||||||
|
actor=actor,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_risk_rule_scenario_test(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
body: AgentAssetRiskRuleScenarioTestRequest,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
asset, version, manifest = self._load_risk_rule_for_test(asset_id, body.version)
|
||||||
|
if asset.domain != AgentAssetDomain.EXPENSE.value:
|
||||||
|
raise ValueError("一期真实场景试运行仅支持报销业务域。")
|
||||||
|
|
||||||
|
parsed_scope = self._parse_scenario_scope(body.intent, body.filters)
|
||||||
|
claims = self._query_expense_claim_samples(parsed_scope)
|
||||||
|
claim_results = [self._run_claim_scenario(manifest, claim) for claim in claims]
|
||||||
|
hit_items = [item for item in claim_results if item["hit"]]
|
||||||
|
severity_counts: dict[str, int] = {}
|
||||||
|
for item in hit_items:
|
||||||
|
severity = str(item.get("severity") or "unknown")
|
||||||
|
severity_counts[severity] = severity_counts.get(severity, 0) + 1
|
||||||
|
|
||||||
|
passed = bool(claim_results)
|
||||||
|
summary = (
|
||||||
|
f"真实场景试运行完成,样本 {len(claim_results)} 条,命中 {len(hit_items)} 条。"
|
||||||
|
if passed
|
||||||
|
else "真实场景试运行未找到可测样本。"
|
||||||
|
)
|
||||||
|
return self._create_test_run(
|
||||||
|
asset,
|
||||||
|
version=version,
|
||||||
|
test_type="scenario",
|
||||||
|
passed=passed,
|
||||||
|
summary=summary,
|
||||||
|
input_json={
|
||||||
|
"intent": body.intent,
|
||||||
|
"filters": body.filters,
|
||||||
|
"parsed_scope": parsed_scope,
|
||||||
|
},
|
||||||
|
result_json={
|
||||||
|
"total_count": len(claim_results),
|
||||||
|
"hit_count": len(hit_items),
|
||||||
|
"severity_counts": severity_counts,
|
||||||
|
"items": claim_results[:50],
|
||||||
|
},
|
||||||
|
actor=actor,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def confirm_risk_rule_test_report(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
body: AgentAssetRiskRuleReportRequest,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
asset, version, _ = self._load_risk_rule_for_test(asset_id, body.version)
|
||||||
|
sample = self.repository.get_latest_test_run(
|
||||||
|
asset.id, version=version, test_type="sample", status="passed"
|
||||||
|
)
|
||||||
|
scenario = self.repository.get_latest_test_run(
|
||||||
|
asset.id, version=version, test_type="scenario"
|
||||||
|
)
|
||||||
|
if sample is None:
|
||||||
|
raise ValueError("提交审核前必须先完成快速样例测试。")
|
||||||
|
if not body.confirm_passed:
|
||||||
|
raise ValueError("请确认测试通过后再保存测试报告。")
|
||||||
|
|
||||||
|
summary = "测试报告已确认,当前版本可提交审核。"
|
||||||
|
if scenario is None:
|
||||||
|
summary = "快速样例测试已确认通过,真实场景试运行未执行。"
|
||||||
|
elif not scenario.passed:
|
||||||
|
summary = "快速样例测试已确认通过,真实场景试运行未找到可测样本。"
|
||||||
|
return self._create_test_run(
|
||||||
|
asset,
|
||||||
|
version=version,
|
||||||
|
test_type="report",
|
||||||
|
passed=True,
|
||||||
|
summary=summary,
|
||||||
|
input_json={"confirm_passed": True, "note": body.note or ""},
|
||||||
|
result_json={
|
||||||
|
"sample_test_run_id": sample.id,
|
||||||
|
"scenario_test_run_id": scenario.id,
|
||||||
|
"sample_summary": sample.summary,
|
||||||
|
"scenario_summary": scenario.summary,
|
||||||
|
},
|
||||||
|
actor=actor,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_unpublished_asset(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
asset = self._resolve_asset(asset_id)
|
||||||
|
self._require_json_risk_asset(asset)
|
||||||
|
if str(asset.published_version or "").strip():
|
||||||
|
raise PermissionError("已发布过的风险规则不能删除。")
|
||||||
|
|
||||||
|
before = self._asset_snapshot(asset)
|
||||||
|
self._delete_risk_rule_json_file(asset)
|
||||||
|
self.repository.delete_asset(asset)
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=actor,
|
||||||
|
action="delete_agent_asset",
|
||||||
|
resource_type=AgentAssetType.RULE.value,
|
||||||
|
resource_id=asset_id,
|
||||||
|
before_json=before,
|
||||||
|
after_json={"deleted": True},
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def return_risk_rule(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
*,
|
||||||
|
note: str,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAssetRiskRuleLatestTestSummary:
|
||||||
|
asset = self._resolve_asset(asset_id)
|
||||||
|
self._require_json_risk_asset(asset)
|
||||||
|
version = self._resolve_target_version(asset, None)
|
||||||
|
if asset.status != AgentAssetStatus.REVIEW.value:
|
||||||
|
raise ValueError("只有待审核风险规则可以回退。")
|
||||||
|
|
||||||
|
before = self._asset_snapshot(asset)
|
||||||
|
review = AgentAssetReview(
|
||||||
|
asset_id=asset.id,
|
||||||
|
version=version,
|
||||||
|
reviewer=actor,
|
||||||
|
review_status=AgentReviewStatus.REJECTED.value,
|
||||||
|
review_note=str(note or "审核回退").strip() or "审核回退",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
self.db.add(review)
|
||||||
|
asset.reviewer = actor
|
||||||
|
asset.status = AgentAssetStatus.DRAFT.value
|
||||||
|
self.db.add(asset)
|
||||||
|
self.db.commit()
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=actor,
|
||||||
|
action="return_agent_asset",
|
||||||
|
resource_type=AgentAssetType.RULE.value,
|
||||||
|
resource_id=asset.id,
|
||||||
|
before_json=before,
|
||||||
|
after_json={"version": version, "status": asset.status, "note": note},
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
return self.get_latest_risk_rule_test_summary(asset)
|
||||||
|
|
||||||
|
def publish_risk_rule(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAsset:
|
||||||
|
asset = self._resolve_asset(asset_id)
|
||||||
|
self._require_json_risk_asset(asset)
|
||||||
|
version = self._resolve_target_version(asset, None)
|
||||||
|
if asset.status != AgentAssetStatus.REVIEW.value:
|
||||||
|
raise ValueError("只有待审核风险规则可以发布上线。")
|
||||||
|
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
|
||||||
|
raise PermissionError("当前规则版本尚未完成测试通过确认,不能发布。")
|
||||||
|
|
||||||
|
before = self._asset_snapshot(asset)
|
||||||
|
approved_review = self.repository.get_review(
|
||||||
|
asset.id, version, AgentReviewStatus.APPROVED.value
|
||||||
|
)
|
||||||
|
if approved_review is None:
|
||||||
|
self.db.add(
|
||||||
|
AgentAssetReview(
|
||||||
|
asset_id=asset.id,
|
||||||
|
version=version,
|
||||||
|
reviewer=actor,
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="发布上线前审核通过。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
asset.reviewer = actor
|
||||||
|
asset.published_version = version
|
||||||
|
asset.status = AgentAssetStatus.ACTIVE.value
|
||||||
|
self.db.add(asset)
|
||||||
|
self.db.commit()
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=actor,
|
||||||
|
action="publish_agent_asset",
|
||||||
|
resource_type=AgentAssetType.RULE.value,
|
||||||
|
resource_id=asset.id,
|
||||||
|
before_json=before,
|
||||||
|
after_json=self._asset_snapshot(asset),
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
refreshed = self.repository.get(asset.id)
|
||||||
|
if refreshed is None:
|
||||||
|
raise LookupError("Asset not found")
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
def set_risk_rule_enabled(
|
||||||
|
self,
|
||||||
|
asset_id: str,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> AgentAsset:
|
||||||
|
asset = self._resolve_asset(asset_id)
|
||||||
|
self._require_json_risk_asset(asset)
|
||||||
|
before = self._asset_snapshot(asset)
|
||||||
|
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
|
||||||
|
manifest = self.rule_library_manager.read_rule_library_json(
|
||||||
|
library=rule_library,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
manifest["enabled"] = bool(enabled)
|
||||||
|
self.rule_library_manager.write_rule_library_json(
|
||||||
|
library=rule_library,
|
||||||
|
file_name=file_name,
|
||||||
|
payload=manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
config_json = dict(asset.config_json or {})
|
||||||
|
config_json["enabled"] = bool(enabled)
|
||||||
|
asset.config_json = config_json
|
||||||
|
updated = self.repository.save_asset(asset)
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=actor,
|
||||||
|
action="set_risk_rule_enabled",
|
||||||
|
resource_type=AgentAssetType.RULE.value,
|
||||||
|
resource_id=asset.id,
|
||||||
|
before_json=before,
|
||||||
|
after_json=self._asset_snapshot(updated),
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def _load_risk_rule_for_test(
|
||||||
|
self, asset_id: str, version: str | None
|
||||||
|
) -> tuple[AgentAsset, str, dict[str, Any]]:
|
||||||
|
asset = self._resolve_asset(asset_id)
|
||||||
|
self._require_json_risk_asset(asset)
|
||||||
|
target_version = self._resolve_target_version(asset, version)
|
||||||
|
if self.repository.get_version(asset.id, target_version) is None:
|
||||||
|
raise LookupError(f"版本 {target_version} 不存在")
|
||||||
|
|
||||||
|
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
|
||||||
|
manifest = self.rule_library_manager.read_rule_library_json(
|
||||||
|
library=rule_library,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
return asset, target_version, manifest
|
||||||
|
|
||||||
|
def _create_test_run(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
test_type: str,
|
||||||
|
passed: bool,
|
||||||
|
summary: str,
|
||||||
|
input_json: dict[str, Any],
|
||||||
|
result_json: dict[str, Any],
|
||||||
|
actor: str,
|
||||||
|
request_id: str | None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead:
|
||||||
|
status = "passed" if passed else "failed"
|
||||||
|
created = self.repository.create_test_run(
|
||||||
|
AgentAssetTestRun(
|
||||||
|
asset_id=asset.id,
|
||||||
|
version=version,
|
||||||
|
test_type=test_type,
|
||||||
|
status=status,
|
||||||
|
passed=passed,
|
||||||
|
summary=summary,
|
||||||
|
input_json=input_json,
|
||||||
|
result_json=result_json,
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.audit_service.log_action(
|
||||||
|
actor=actor,
|
||||||
|
action=f"risk_rule_test_{test_type}",
|
||||||
|
resource_type=AgentAssetType.RULE.value,
|
||||||
|
resource_id=asset.id,
|
||||||
|
before_json=None,
|
||||||
|
after_json={"version": version, "status": status, "summary": summary},
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
return AgentAssetRiskRuleTestRunRead.model_validate(created)
|
||||||
|
|
||||||
|
def _run_sample_case(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
case: AgentAssetRiskRuleSampleCase,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
claim, contexts = self._build_synthetic_claim(case.values, manifest)
|
||||||
|
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts)
|
||||||
|
actual_hit = result is not None
|
||||||
|
actual_severity = (
|
||||||
|
str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "").strip()
|
||||||
|
if actual_hit
|
||||||
|
else "none"
|
||||||
|
)
|
||||||
|
expected_severity = str(case.expected_severity or "").strip()
|
||||||
|
severity_passed = (
|
||||||
|
not actual_hit or not expected_severity or expected_severity == actual_severity
|
||||||
|
)
|
||||||
|
passed = actual_hit == case.expected_hit and severity_passed
|
||||||
|
return {
|
||||||
|
"case_id": case.case_id or "",
|
||||||
|
"name": case.name,
|
||||||
|
"values": case.values,
|
||||||
|
"expected_hit": case.expected_hit,
|
||||||
|
"expected_severity": expected_severity,
|
||||||
|
"actual_hit": actual_hit,
|
||||||
|
"actual_severity": actual_severity,
|
||||||
|
"passed": passed,
|
||||||
|
"message": str(result.get("message") or "") if isinstance(result, dict) else "",
|
||||||
|
"evidence": result.get("evidence") if isinstance(result, 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)
|
||||||
|
hit = result is not None
|
||||||
|
return {
|
||||||
|
"claim_id": claim.id,
|
||||||
|
"claim_no": claim.claim_no,
|
||||||
|
"employee_name": claim.employee_name,
|
||||||
|
"department_name": claim.department_name,
|
||||||
|
"expense_type": claim.expense_type,
|
||||||
|
"amount": float(claim.amount or 0),
|
||||||
|
"status": claim.status,
|
||||||
|
"occurred_at": claim.occurred_at.isoformat() if claim.occurred_at else "",
|
||||||
|
"hit": hit,
|
||||||
|
"severity": str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "")
|
||||||
|
if hit
|
||||||
|
else "none",
|
||||||
|
"message": str(result.get("message") or "") if isinstance(result, dict) else "",
|
||||||
|
"evidence": result.get("evidence") if isinstance(result, dict) else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_synthetic_claim(
|
||||||
|
self,
|
||||||
|
values: dict[str, Any],
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
) -> tuple[ExpenseClaim, list[dict[str, Any]]]:
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="TEST-RISK-RULE",
|
||||||
|
employee_name=str(values.get("claim.employee_name") or "测试员工"),
|
||||||
|
department_name=str(values.get("claim.department_name") or "测试部门"),
|
||||||
|
expense_type=str(values.get("item.item_type") or "差旅费"),
|
||||||
|
reason=str(values.get("claim.reason") or "测试报销事由"),
|
||||||
|
location=str(values.get("claim.location") or "北京"),
|
||||||
|
amount=self._to_decimal(values.get("claim.amount")),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
item = ExpenseClaimItem(
|
||||||
|
item_date=date.today(),
|
||||||
|
item_type=str(values.get("item.item_type") or "住宿费"),
|
||||||
|
item_reason=str(values.get("item.item_reason") or claim.reason),
|
||||||
|
item_location=str(values.get("item.item_location") or claim.location),
|
||||||
|
item_amount=self._to_decimal(values.get("item.item_amount") or claim.amount),
|
||||||
|
)
|
||||||
|
claim.items = [item]
|
||||||
|
|
||||||
|
attachment_fields = []
|
||||||
|
document_info: dict[str, Any] = {"fields": attachment_fields}
|
||||||
|
for field in self._extract_manifest_fields(manifest):
|
||||||
|
key = field["key"]
|
||||||
|
if key not in values:
|
||||||
|
continue
|
||||||
|
value = self._coerce_sample_value(key, values.get(key))
|
||||||
|
if key.startswith("claim."):
|
||||||
|
setattr(claim, key.removeprefix("claim."), value)
|
||||||
|
elif key.startswith("item."):
|
||||||
|
setattr(item, key.removeprefix("item."), value)
|
||||||
|
elif key.startswith("attachment."):
|
||||||
|
short_key = key.removeprefix("attachment.")
|
||||||
|
document_info[short_key] = value
|
||||||
|
attachment_fields.append(
|
||||||
|
{"key": short_key, "label": field["label"], "value": value}
|
||||||
|
)
|
||||||
|
return claim, [
|
||||||
|
{
|
||||||
|
"document_info": document_info,
|
||||||
|
"ocr_text": document_info.get("ocr_text", ""),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def _build_default_sample_cases(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
) -> list[AgentAssetRiskRuleSampleCase]:
|
||||||
|
fields = self._extract_manifest_fields(manifest)
|
||||||
|
severity = str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "")
|
||||||
|
template_key = str(manifest.get("template_key") or "").strip()
|
||||||
|
hit_values = self._find_case_values_for_expected(manifest, fields, expected_hit=True)
|
||||||
|
pass_values = self._find_case_values_for_expected(manifest, fields, expected_hit=False)
|
||||||
|
cases = [
|
||||||
|
AgentAssetRiskRuleSampleCase(
|
||||||
|
case_id="hit",
|
||||||
|
name="应该命中风险",
|
||||||
|
values=hit_values,
|
||||||
|
expected_hit=True,
|
||||||
|
expected_severity=severity,
|
||||||
|
note="验证规则能识别异常样本。",
|
||||||
|
),
|
||||||
|
AgentAssetRiskRuleSampleCase(
|
||||||
|
case_id="pass",
|
||||||
|
name="应该不命中",
|
||||||
|
values=pass_values,
|
||||||
|
expected_hit=False,
|
||||||
|
expected_severity="none",
|
||||||
|
note="验证正常样本不会误触发。",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if template_key == "field_required_v1":
|
||||||
|
cases.append(
|
||||||
|
AgentAssetRiskRuleSampleCase(
|
||||||
|
case_id="missing",
|
||||||
|
name="关键字段缺失",
|
||||||
|
values={key: "" for key in hit_values},
|
||||||
|
expected_hit=True,
|
||||||
|
expected_severity=severity,
|
||||||
|
note="验证缺字段时会进入复核。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return cases
|
||||||
|
|
||||||
|
def _find_case_values_for_expected(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
fields: list[dict[str, str]],
|
||||||
|
*,
|
||||||
|
expected_hit: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
candidates = [
|
||||||
|
self._build_case_values(manifest, fields, hit=expected_hit),
|
||||||
|
{field["key"]: self._default_value_for_field(field["key"]) for field in fields},
|
||||||
|
{
|
||||||
|
field["key"]: ("上海" if index == 0 else "北京")
|
||||||
|
for index, field in enumerate(fields)
|
||||||
|
},
|
||||||
|
{field["key"]: "北京" for field in fields},
|
||||||
|
{field["key"]: "" for field in fields},
|
||||||
|
]
|
||||||
|
severity = str((manifest.get("outcomes") or {}).get("fail", {}).get("severity") or "")
|
||||||
|
for values in candidates:
|
||||||
|
probe = AgentAssetRiskRuleSampleCase(
|
||||||
|
name="默认样例探测",
|
||||||
|
values=values,
|
||||||
|
expected_hit=expected_hit,
|
||||||
|
expected_severity=severity if expected_hit else "none",
|
||||||
|
)
|
||||||
|
result = self._run_sample_case(manifest, probe)
|
||||||
|
if bool(result["actual_hit"]) == expected_hit:
|
||||||
|
return values
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
def _build_case_values(
|
||||||
|
self,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
fields: list[dict[str, str]],
|
||||||
|
*,
|
||||||
|
hit: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
values = {field["key"]: self._default_value_for_field(field["key"]) for field in fields}
|
||||||
|
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":
|
||||||
|
condition = next(
|
||||||
|
(item for item in params.get("conditions", []) if isinstance(item, dict)),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
left = str(condition.get("left") or "").strip()
|
||||||
|
right = str(condition.get("right") or "").strip()
|
||||||
|
operator = str(condition.get("operator") or "overlap").strip()
|
||||||
|
if left and operator == "is_empty":
|
||||||
|
values[left] = "测试值" if hit else ""
|
||||||
|
elif left and right and operator in {"not_equals", "not_in", "not_overlap"}:
|
||||||
|
values[left] = "北京" if hit else "上海"
|
||||||
|
values[right] = "北京"
|
||||||
|
elif left and right:
|
||||||
|
values[left] = "上海" if hit else "北京"
|
||||||
|
values[right] = "北京"
|
||||||
|
elif template_key == "field_required_v1" and hit and fields:
|
||||||
|
values[fields[0]["key"]] = ""
|
||||||
|
elif template_key == "keyword_match_v1":
|
||||||
|
keywords = params.get("keywords") if isinstance(params.get("keywords"), list) else []
|
||||||
|
keyword = str(next(iter(keywords), "咨询费") or "咨询费")
|
||||||
|
target_key = fields[0]["key"] if fields else "claim.reason"
|
||||||
|
values[target_key] = f"本次报销包含{keyword}" if hit else "正常差旅报销"
|
||||||
|
return values
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_value_for_field(field_key: str) -> Any:
|
||||||
|
if field_key.endswith("amount"):
|
||||||
|
return "100.00"
|
||||||
|
if field_key.endswith("issue_date"):
|
||||||
|
return date.today().isoformat()
|
||||||
|
if field_key.endswith("route_cities"):
|
||||||
|
return ["北京"]
|
||||||
|
if field_key.endswith("ocr_text"):
|
||||||
|
return "正常发票内容"
|
||||||
|
if "city" in field_key or "location" in field_key:
|
||||||
|
return "北京"
|
||||||
|
if field_key.endswith("item_type"):
|
||||||
|
return "住宿费"
|
||||||
|
return "测试值"
|
||||||
|
|
||||||
|
def _query_expense_claim_samples(self, parsed_scope: dict[str, Any]) -> list[ExpenseClaim]:
|
||||||
|
days = int(parsed_scope.get("days") or 30)
|
||||||
|
limit = min(max(int(parsed_scope.get("limit") or 50), 1), 200)
|
||||||
|
since = datetime.now(UTC) - timedelta(days=days)
|
||||||
|
stmt = select(ExpenseClaim).where(ExpenseClaim.created_at >= since)
|
||||||
|
|
||||||
|
expense_keyword = str(parsed_scope.get("expense_keyword") or "").strip()
|
||||||
|
if expense_keyword:
|
||||||
|
like_keyword = f"%{expense_keyword}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
ExpenseClaim.expense_type.ilike(like_keyword),
|
||||||
|
ExpenseClaim.reason.ilike(like_keyword),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cities = [str(item or "").strip() for item in parsed_scope.get("cities", []) if item]
|
||||||
|
if cities:
|
||||||
|
city_filters = []
|
||||||
|
for city in cities[:8]:
|
||||||
|
like_city = f"%{city}%"
|
||||||
|
city_filters.extend(
|
||||||
|
[
|
||||||
|
ExpenseClaim.location.ilike(like_city),
|
||||||
|
ExpenseClaim.reason.ilike(like_city),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
stmt = stmt.where(or_(*city_filters))
|
||||||
|
|
||||||
|
stmt = stmt.order_by(ExpenseClaim.created_at.desc()).limit(limit)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_scenario_scope(intent: str, filters: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = str(intent or "")
|
||||||
|
raw_days = filters.get("days") or filters.get("recent_days")
|
||||||
|
days = int(raw_days) if str(raw_days or "").isdigit() else 30
|
||||||
|
match = re.search(r"最近\s*(\d{1,3})\s*天", text)
|
||||||
|
if match:
|
||||||
|
days = int(match.group(1))
|
||||||
|
limit = filters.get("limit") if str(filters.get("limit") or "").isdigit() else 50
|
||||||
|
expense_keyword = str(filters.get("expense_keyword") or "").strip()
|
||||||
|
if not expense_keyword and any(keyword in text for keyword in ("酒店", "住宿")):
|
||||||
|
expense_keyword = "住宿"
|
||||||
|
|
||||||
|
city_candidates = ("北京", "上海", "广州", "深圳", "武汉", "杭州", "成都", "南京")
|
||||||
|
cities = [
|
||||||
|
city
|
||||||
|
for city in city_candidates
|
||||||
|
if city in text or city in [str(item) for item in filters.get("cities", []) or []]
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"business_domain": "expense",
|
||||||
|
"days": max(1, min(days, 365)),
|
||||||
|
"limit": max(1, min(int(limit), 200)),
|
||||||
|
"expense_keyword": expense_keyword,
|
||||||
|
"cities": cities,
|
||||||
|
"execution_mode": "dry_run",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_manifest_fields(manifest: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
|
||||||
|
fields = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||||
|
normalized = []
|
||||||
|
for item in fields:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
key = str(item.get("key") or "").strip()
|
||||||
|
if key:
|
||||||
|
normalized.append({"key": key, "label": str(item.get("label") or key).strip()})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_sample_value(field_key: str, value: Any) -> Any:
|
||||||
|
if field_key.endswith("route_cities") and isinstance(value, str):
|
||||||
|
return [item.strip() for item in re.split(r"[,,、/ ]+", value) if item.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_decimal(value: Any) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(str(value or "0"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
def _resolve_asset(self, asset_or_id: AgentAsset | str) -> AgentAsset:
|
||||||
|
if isinstance(asset_or_id, AgentAsset):
|
||||||
|
return asset_or_id
|
||||||
|
asset = self.repository.get(str(asset_or_id))
|
||||||
|
if asset is None:
|
||||||
|
raise LookupError("Asset not found")
|
||||||
|
return asset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_json_risk_asset(asset: AgentAsset) -> None:
|
||||||
|
config_json = asset.config_json if isinstance(asset.config_json, dict) else {}
|
||||||
|
if asset.asset_type != AgentAssetType.RULE.value:
|
||||||
|
raise ValueError("仅规则资产支持风险规则操作。")
|
||||||
|
if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk":
|
||||||
|
raise ValueError("仅 JSON 风险规则支持该操作。")
|
||||||
|
|
||||||
|
def _resolve_target_version(self, asset: AgentAsset, version: str | None) -> str:
|
||||||
|
target = str(version or self._resolve_working_version(asset) or "").strip()
|
||||||
|
if not target:
|
||||||
|
raise ValueError("当前规则尚未配置工作版本。")
|
||||||
|
return target
|
||||||
|
|
||||||
|
def _delete_risk_rule_json_file(self, asset: AgentAsset) -> None:
|
||||||
|
try:
|
||||||
|
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
|
||||||
|
target = self.rule_library_manager.resolve_rule_library_path(
|
||||||
|
library=rule_library,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_test_run(
|
||||||
|
run: AgentAssetTestRun | None,
|
||||||
|
) -> AgentAssetRiskRuleTestRunRead | None:
|
||||||
|
return AgentAssetRiskRuleTestRunRead.model_validate(run) if run is not None else None
|
||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.agent_enums import (
|
from app.core.agent_enums import (
|
||||||
@@ -27,13 +28,14 @@ from app.schemas.agent_asset import (
|
|||||||
)
|
)
|
||||||
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
|
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
|
||||||
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
|
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
|
||||||
|
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
||||||
|
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
||||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
|
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
||||||
from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin
|
from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin
|
||||||
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
|
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
|
||||||
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
from app.services.settings import resolve_onlyoffice_settings
|
|
||||||
|
|
||||||
logger = get_logger("app.services.agent_assets")
|
logger = get_logger("app.services.agent_assets")
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ logger = get_logger("app.services.agent_assets")
|
|||||||
class AgentAssetService(
|
class AgentAssetService(
|
||||||
AgentAssetOnlyOfficeMixin,
|
AgentAssetOnlyOfficeMixin,
|
||||||
AgentAssetSpreadsheetHelperMixin,
|
AgentAssetSpreadsheetHelperMixin,
|
||||||
|
AgentAssetRiskRuleTestingMixin,
|
||||||
|
AgentAssetRiskRuleSimulationMixin,
|
||||||
AgentAssetTimelineMixin,
|
AgentAssetTimelineMixin,
|
||||||
AgentAssetJsonRuleMixin,
|
AgentAssetJsonRuleMixin,
|
||||||
):
|
):
|
||||||
@@ -66,10 +70,7 @@ class AgentAssetService(
|
|||||||
asset_type=asset_type, status=status, domain=domain, keyword=keyword
|
asset_type=asset_type, status=status, domain=domain, keyword=keyword
|
||||||
)
|
)
|
||||||
version_stats = self._collect_version_stats(assets)
|
version_stats = self._collect_version_stats(assets)
|
||||||
return [
|
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets]
|
||||||
self._serialize_list_item(asset, version_stats.get(asset.id))
|
|
||||||
for asset in assets
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
|
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
|
||||||
self._ensure_ready()
|
self._ensure_ready()
|
||||||
@@ -88,9 +89,7 @@ class AgentAssetService(
|
|||||||
else next(iter(self.repository.list_reviews(asset_id, limit=1)), None)
|
else next(iter(self.repository.list_reviews(asset_id, limit=1)), None)
|
||||||
)
|
)
|
||||||
current_version = (
|
current_version = (
|
||||||
self.repository.get_version(asset_id, working_version)
|
self.repository.get_version(asset_id, working_version) if working_version else None
|
||||||
if working_version
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
version_stats = self._collect_version_stats([asset]).get(asset.id)
|
version_stats = self._collect_version_stats([asset]).get(asset.id)
|
||||||
return AgentAssetRead(
|
return AgentAssetRead(
|
||||||
@@ -100,12 +99,14 @@ class AgentAssetService(
|
|||||||
else None,
|
else None,
|
||||||
current_version_content_type=current_version.content_type if current_version else None,
|
current_version_content_type=current_version.content_type if current_version else None,
|
||||||
current_version_change_note=current_version.change_note if current_version else None,
|
current_version_change_note=current_version.change_note if current_version else None,
|
||||||
recent_versions=[
|
recent_versions=[self._serialize_version(item, asset) for item in recent_versions],
|
||||||
self._serialize_version(item, asset) for item in recent_versions
|
|
||||||
],
|
|
||||||
latest_review=AgentAssetReviewRead.model_validate(latest_review)
|
latest_review=AgentAssetReviewRead.model_validate(latest_review)
|
||||||
if latest_review
|
if latest_review
|
||||||
else None,
|
else None,
|
||||||
|
latest_test_summary=self.get_latest_risk_rule_test_summary(asset)
|
||||||
|
if str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||||
|
== "json_risk"
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_asset(
|
def create_asset(
|
||||||
@@ -301,6 +302,13 @@ class AgentAssetService(
|
|||||||
if self.repository.get_version(asset_id, payload.version) is None:
|
if self.repository.get_version(asset_id, payload.version) is None:
|
||||||
raise LookupError(f"版本 {payload.version} 不存在")
|
raise LookupError(f"版本 {payload.version} 不存在")
|
||||||
if asset.asset_type == AgentAssetType.RULE.value:
|
if asset.asset_type == AgentAssetType.RULE.value:
|
||||||
|
if (
|
||||||
|
str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||||
|
== "json_risk"
|
||||||
|
and payload.review_status == AgentReviewStatus.PENDING
|
||||||
|
and not self.get_latest_risk_rule_test_summary(asset).test_passed
|
||||||
|
):
|
||||||
|
raise PermissionError("当前规则版本尚未完成测试通过确认,不能提交审核。")
|
||||||
working_version = self._resolve_working_version(asset)
|
working_version = self._resolve_working_version(asset)
|
||||||
if payload.version != working_version:
|
if payload.version != working_version:
|
||||||
raise ValueError("只能对当前工作版本发起审核。")
|
raise ValueError("只能对当前工作版本发起审核。")
|
||||||
@@ -594,11 +602,10 @@ class AgentAssetService(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _collect_version_stats(
|
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
|
||||||
self, assets: list[AgentAsset]
|
|
||||||
) -> dict[str, dict[str, int | str | None]]:
|
|
||||||
asset_ids = [item.id for item in assets]
|
asset_ids = [item.id for item in assets]
|
||||||
versions = self.repository.list_versions_for_assets(asset_ids)
|
versions = self.repository.list_versions_for_assets(asset_ids)
|
||||||
|
reviews = self.repository.list_reviews_for_assets(asset_ids)
|
||||||
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
||||||
resource_type=AgentAssetType.RULE.value,
|
resource_type=AgentAssetType.RULE.value,
|
||||||
resource_ids=[
|
resource_ids=[
|
||||||
@@ -610,23 +617,33 @@ class AgentAssetService(
|
|||||||
],
|
],
|
||||||
action="edit_rule_spreadsheet",
|
action="edit_rule_spreadsheet",
|
||||||
)
|
)
|
||||||
working_versions = {
|
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
|
||||||
item.id: self._resolve_working_version(item) for item in assets
|
|
||||||
}
|
|
||||||
version_counts: dict[str, int] = defaultdict(int)
|
version_counts: dict[str, int] = defaultdict(int)
|
||||||
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
||||||
|
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
|
||||||
|
published_by: dict[str, str | None] = {}
|
||||||
|
published_at: dict[str, datetime | None] = {}
|
||||||
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
||||||
spreadsheet_last_actor: dict[str, str | None] = {}
|
spreadsheet_last_actor: dict[str, str | None] = {}
|
||||||
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
||||||
|
|
||||||
for version in versions:
|
for version in versions:
|
||||||
version_counts[version.asset_id] += 1
|
version_counts[version.asset_id] += 1
|
||||||
if (
|
if modified_by.get(
|
||||||
modified_by.get(version.asset_id) is None
|
version.asset_id
|
||||||
and version.version == working_versions.get(version.asset_id)
|
) is None and version.version == working_versions.get(version.asset_id):
|
||||||
):
|
|
||||||
modified_by[version.asset_id] = version.created_by
|
modified_by[version.asset_id] = version.created_by
|
||||||
|
|
||||||
|
for review in reviews:
|
||||||
|
if review.asset_id in published_at:
|
||||||
|
continue
|
||||||
|
if review.version != published_versions.get(review.asset_id):
|
||||||
|
continue
|
||||||
|
if review.review_status != AgentReviewStatus.APPROVED.value:
|
||||||
|
continue
|
||||||
|
published_by[review.asset_id] = review.reviewer
|
||||||
|
published_at[review.asset_id] = review.reviewed_at or review.created_at
|
||||||
|
|
||||||
for log in spreadsheet_logs:
|
for log in spreadsheet_logs:
|
||||||
spreadsheet_edit_counts[log.resource_id] += 1
|
spreadsheet_edit_counts[log.resource_id] += 1
|
||||||
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
||||||
@@ -652,6 +669,8 @@ class AgentAssetService(
|
|||||||
and spreadsheet_last_actor.get(item.id)
|
and spreadsheet_last_actor.get(item.id)
|
||||||
else modified_by.get(item.id)
|
else modified_by.get(item.id)
|
||||||
),
|
),
|
||||||
|
"published_by": published_by.get(item.id),
|
||||||
|
"published_at": published_at.get(item.id),
|
||||||
}
|
}
|
||||||
for item in assets
|
for item in assets
|
||||||
}
|
}
|
||||||
@@ -663,9 +682,11 @@ class AgentAssetService(
|
|||||||
) -> AgentAssetListItem:
|
) -> AgentAssetListItem:
|
||||||
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
||||||
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
||||||
payload["modified_by"] = (
|
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
|
||||||
str((version_stats or {}).get("modified_by") or "").strip() or None
|
payload["published_by"] = (
|
||||||
|
str((version_stats or {}).get("published_by") or "").strip() or None
|
||||||
)
|
)
|
||||||
|
payload["published_at"] = (version_stats or {}).get("published_at")
|
||||||
return AgentAssetListItem.model_validate(payload)
|
return AgentAssetListItem.model_validate(payload)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import inspect, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -75,6 +75,7 @@ class AgentFoundationService(
|
|||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=self.db.get_bind())
|
Base.metadata.create_all(bind=self.db.get_bind())
|
||||||
self._ensure_agent_asset_schema()
|
self._ensure_agent_asset_schema()
|
||||||
|
self._ensure_financial_record_schema()
|
||||||
self._seed_agent_assets()
|
self._seed_agent_assets()
|
||||||
self._sync_demo_financial_records()
|
self._sync_demo_financial_records()
|
||||||
self._seed_runs_and_logs()
|
self._seed_runs_and_logs()
|
||||||
@@ -88,6 +89,36 @@ class AgentFoundationService(
|
|||||||
bind = self.db.get_bind()
|
bind = self.db.get_bind()
|
||||||
return str(getattr(bind, "url", "") or id(bind))
|
return str(getattr(bind, "url", "") or id(bind))
|
||||||
|
|
||||||
|
def _ensure_financial_record_schema(self) -> None:
|
||||||
|
bind = self.db.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
if "expense_claims" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
column_names = {column["name"] for column in inspector.get_columns("expense_claims")}
|
||||||
|
dialect_name = bind.dialect.name
|
||||||
|
timestamp_type = "TIMESTAMP WITH TIME ZONE" if dialect_name == "postgresql" else "DATETIME"
|
||||||
|
boolean_default = "FALSE" if dialect_name == "postgresql" else "0"
|
||||||
|
|
||||||
|
if "hermes_scanned_at" not in column_names:
|
||||||
|
self.db.execute(
|
||||||
|
text(f"ALTER TABLE expense_claims ADD COLUMN hermes_scanned_at {timestamp_type}")
|
||||||
|
)
|
||||||
|
if "hermes_risk_flag" not in column_names:
|
||||||
|
self.db.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE expense_claims "
|
||||||
|
f"ADD COLUMN hermes_risk_flag BOOLEAN DEFAULT {boolean_default} NOT NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_expense_claims_hermes_risk_flag "
|
||||||
|
"ON expense_claims (hermes_risk_flag)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
def _sync_demo_financial_records(self) -> None:
|
def _sync_demo_financial_records(self) -> None:
|
||||||
if get_settings().seed_demo_financial_records:
|
if get_settings().seed_demo_financial_records:
|
||||||
self._seed_financial_records()
|
self._seed_financial_records()
|
||||||
|
|||||||
@@ -651,7 +651,11 @@ class EmployeeService:
|
|||||||
column_names = {column["name"] for column in inspector.get_columns("employees")}
|
column_names = {column["name"] for column in inspector.get_columns("employees")}
|
||||||
if "password_hash" not in column_names:
|
if "password_hash" not in column_names:
|
||||||
self.db.execute(text("ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)"))
|
self.db.execute(text("ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)"))
|
||||||
self.db.flush()
|
if "compliance_score" not in column_names:
|
||||||
|
self.db.execute(
|
||||||
|
text("ALTER TABLE employees ADD COLUMN compliance_score INTEGER DEFAULT 100 NOT NULL")
|
||||||
|
)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
|
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
|
||||||
existing_keys = {
|
existing_keys = {
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|||||||
"办公用品",
|
"办公用品",
|
||||||
"办公耗材",
|
"办公耗材",
|
||||||
"办公设备",
|
"办公设备",
|
||||||
|
"采购",
|
||||||
|
"集中采购",
|
||||||
|
"物资采购",
|
||||||
|
"办公采购",
|
||||||
"办公",
|
"办公",
|
||||||
"文具",
|
"文具",
|
||||||
"耗材",
|
"耗材",
|
||||||
|
|||||||
104
server/src/app/services/hermes_expense_report.py
Normal file
104
server/src/app/services/hermes_expense_report.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.services.runtime_chat import RuntimeChatService
|
||||||
|
|
||||||
|
logger = get_logger("app.services.hermes_expense_report")
|
||||||
|
|
||||||
|
|
||||||
|
class HermesExpenseReportService:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.chat_service = RuntimeChatService(db)
|
||||||
|
|
||||||
|
def generate_weekly_report(self, log_id: str | None = None) -> None:
|
||||||
|
logger.info("Starting Hermes weekly expense report generation...")
|
||||||
|
|
||||||
|
# 1. 聚合数据
|
||||||
|
aggregated_data = self._aggregate_recent_expenses(days=7)
|
||||||
|
if not aggregated_data.get("total_amount"):
|
||||||
|
logger.info("No expense data in the last 7 days. Skipping report.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 传入大模型分析
|
||||||
|
report_markdown = self._generate_insights_with_llm(aggregated_data)
|
||||||
|
|
||||||
|
if not report_markdown:
|
||||||
|
logger.warning("Failed to generate expense report from LLM.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 模拟发送报告
|
||||||
|
self._deliver_report(report_markdown, log_id)
|
||||||
|
logger.info("Hermes weekly expense report generation completed.")
|
||||||
|
|
||||||
|
def _aggregate_recent_expenses(self, days: int = 7) -> dict[str, Any]:
|
||||||
|
target_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
|
||||||
|
# 基础过滤:最近N天且不是驳回状态的单据
|
||||||
|
base_filter = [
|
||||||
|
ExpenseClaim.occurred_at >= target_date,
|
||||||
|
ExpenseClaim.status != "rejected"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1. 按部门汇总
|
||||||
|
dept_stmt = select(
|
||||||
|
ExpenseClaim.department_name,
|
||||||
|
func.sum(ExpenseClaim.amount).label("total")
|
||||||
|
).where(*base_filter).group_by(ExpenseClaim.department_name)
|
||||||
|
|
||||||
|
dept_results = self.db.execute(dept_stmt).all()
|
||||||
|
by_department = {row.department_name or "Unknown": float(row.total or 0) for row in dept_results}
|
||||||
|
|
||||||
|
# 2. 按类目汇总
|
||||||
|
type_stmt = select(
|
||||||
|
ExpenseClaim.expense_type,
|
||||||
|
func.sum(ExpenseClaim.amount).label("total")
|
||||||
|
).where(*base_filter).group_by(ExpenseClaim.expense_type)
|
||||||
|
|
||||||
|
type_results = self.db.execute(type_stmt).all()
|
||||||
|
by_expense_type = {row.expense_type or "Unknown": float(row.total or 0) for row in type_results}
|
||||||
|
|
||||||
|
# 3. 总花费
|
||||||
|
total_amount = sum(by_department.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period": f"Last {days} days",
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"by_department": by_department,
|
||||||
|
"by_expense_type": by_expense_type
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_insights_with_llm(self, data: dict[str, Any]) -> str | None:
|
||||||
|
system_prompt = (
|
||||||
|
"你是公司的财务分析专家。请根据提供的最近期业务开销数据,撰写一份简洁有力的【高管费控洞察周报】。\n"
|
||||||
|
"要求:\n"
|
||||||
|
"1. 不要机械地罗列数字,要像人一样指出异常(例如:哪个部门花钱最多?打车费是不是异常高?)。\n"
|
||||||
|
"2. 给出 1 条削减成本的实操建议。\n"
|
||||||
|
"3. 纯 Markdown 格式输出,不超过 300 字。"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": f"开销统计数据:\n{json.dumps(data, ensure_ascii=False, indent=2)}"}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.chat_service.complete(
|
||||||
|
messages,
|
||||||
|
max_tokens=800,
|
||||||
|
temperature=0.4
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _deliver_report(self, report_markdown: str, log_id: str | None) -> None:
|
||||||
|
# TODO: 未来在这里接入企微/钉钉机器人或邮件发送接口
|
||||||
|
logger.info(f"\n================ Hermes Weekly Report [LogID: {log_id}] ================\n"
|
||||||
|
f"{report_markdown}\n"
|
||||||
|
f"==========================================================================")
|
||||||
135
server/src/app/services/hermes_risk_scanner.py
Normal file
135
server/src/app/services/hermes_risk_scanner.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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 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
|
||||||
|
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
).limit(50) # Batch size to prevent Token overflow
|
||||||
|
|
||||||
|
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 []
|
||||||
131
server/src/app/services/hermes_scheduler.py
Normal file
131
server/src/app/services/hermes_scheduler.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.db.session import get_session_factory
|
||||||
|
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||||
|
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||||
|
from app.services.hermes_expense_report import HermesExpenseReportService
|
||||||
|
|
||||||
|
logger = get_logger("app.services.hermes_scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
class HermesScheduler:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.session_factory = get_session_factory()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run_loop,
|
||||||
|
name="hermes-agent-scheduler",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Hermes Agent Scheduler started.")
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
thread = self._thread
|
||||||
|
self._thread = None
|
||||||
|
self._stop_event.set()
|
||||||
|
if thread is not None and thread.is_alive():
|
||||||
|
thread.join(timeout=3)
|
||||||
|
logger.info("Hermes Agent Scheduler stopped.")
|
||||||
|
|
||||||
|
def _run_loop(self) -> None:
|
||||||
|
logger.info("Hermes background loop is now active. Polling interval: 60s.")
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self._check_and_run_tasks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in Hermes run loop: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# 睡眠一分钟,每分钟轮询一次
|
||||||
|
if self._stop_event.wait(60.0):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _check_and_run_tasks(self) -> None:
|
||||||
|
db = self.session_factory()
|
||||||
|
try:
|
||||||
|
# 获取所有启用的任务配置
|
||||||
|
stmt = select(HermesTaskConfig).where(HermesTaskConfig.is_enabled == True)
|
||||||
|
configs = db.scalars(stmt).all()
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
if self._should_run_now(db, config):
|
||||||
|
self._execute_task(db, config)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def _should_run_now(self, db: Session, config: HermesTaskConfig) -> bool:
|
||||||
|
# 简单策略:检查是否在过去24小时内运行过。
|
||||||
|
# 如果没有 croniter 库,我们暂时采用按天执行的简化逻辑
|
||||||
|
stmt = select(HermesTaskExecutionLog).where(
|
||||||
|
HermesTaskExecutionLog.config_id == config.id,
|
||||||
|
HermesTaskExecutionLog.status.in_(["success", "running"])
|
||||||
|
).order_by(HermesTaskExecutionLog.started_at.desc()).limit(1)
|
||||||
|
|
||||||
|
last_log = db.scalars(stmt).first()
|
||||||
|
|
||||||
|
if not last_log:
|
||||||
|
return True # 从未执行过,立即执行
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
elapsed_hours = (now - last_log.started_at).total_seconds() / 3600
|
||||||
|
|
||||||
|
# 简化:只要距离上次成功执行超过了 23.5 小时,就认为该跑了(模拟每天跑一次)
|
||||||
|
if elapsed_hours >= 23.5:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _execute_task(self, db: Session, config: HermesTaskConfig) -> None:
|
||||||
|
logger.info(f"Triggering Hermes task: {config.task_type} (Config ID: {config.id})")
|
||||||
|
|
||||||
|
# 创建执行日志,标记为 running
|
||||||
|
log_record = HermesTaskExecutionLog(
|
||||||
|
config_id=config.id,
|
||||||
|
status="running"
|
||||||
|
)
|
||||||
|
db.add(log_record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(log_record)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.task_type == "global_risk_scan":
|
||||||
|
scanner = HermesRiskScannerService(db)
|
||||||
|
scanner.scan_global_risks(log_id=log_record.id)
|
||||||
|
elif config.task_type == "weekly_expense_report":
|
||||||
|
reporter = HermesExpenseReportService(db)
|
||||||
|
reporter.generate_weekly_report(log_id=log_record.id)
|
||||||
|
|
||||||
|
log_record.status = "success"
|
||||||
|
log_record.completed_at = datetime.now(timezone.utc)
|
||||||
|
log_record.result_summary = "Task executed successfully."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to execute Hermes task {config.task_type}: {e}")
|
||||||
|
log_record.status = "failed"
|
||||||
|
log_record.completed_at = datetime.now(timezone.utc)
|
||||||
|
log_record.error_trace = traceback.format_exc()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
hermes_scheduler = HermesScheduler()
|
||||||
@@ -34,10 +34,104 @@ def _extract_docx_text(file_path: Path) -> str:
|
|||||||
return "当前 Word 文件解析失败。"
|
return "当前 Word 文件解析失败。"
|
||||||
|
|
||||||
root = ElementTree.fromstring(xml_content)
|
root = ElementTree.fromstring(xml_content)
|
||||||
|
body = next((node for node in root.iter() if node.tag.endswith("}body")), root)
|
||||||
|
blocks: list[str] = []
|
||||||
|
|
||||||
|
for child in body:
|
||||||
|
if child.tag.endswith("}p"):
|
||||||
|
paragraph = _extract_docx_paragraph_text(child)
|
||||||
|
if paragraph:
|
||||||
|
blocks.append(paragraph)
|
||||||
|
continue
|
||||||
|
if child.tag.endswith("}tbl"):
|
||||||
|
table = _extract_docx_table_rows(child)
|
||||||
|
rendered = _build_docx_table_markdown(table)
|
||||||
|
if rendered:
|
||||||
|
blocks.append(rendered)
|
||||||
|
|
||||||
|
if blocks:
|
||||||
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text]
|
texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text]
|
||||||
return "\n".join(texts)
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx_paragraph_text(node: ElementTree.Element) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for child in node.iter():
|
||||||
|
if child.tag.endswith("}t") and child.text:
|
||||||
|
parts.append(child.text)
|
||||||
|
elif child.tag.endswith("}tab"):
|
||||||
|
parts.append("\t")
|
||||||
|
elif child.tag.endswith("}br"):
|
||||||
|
parts.append("\n")
|
||||||
|
return _normalize_docx_cell_text("".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx_table_rows(table_node: ElementTree.Element) -> list[list[str]]:
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for row_node in table_node:
|
||||||
|
if not row_node.tag.endswith("}tr"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
row: list[str] = []
|
||||||
|
for cell_node in row_node:
|
||||||
|
if not cell_node.tag.endswith("}tc"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cell_parts = [
|
||||||
|
_extract_docx_paragraph_text(paragraph)
|
||||||
|
for paragraph in cell_node
|
||||||
|
if paragraph.tag.endswith("}p")
|
||||||
|
]
|
||||||
|
row.append(_normalize_docx_cell_text(" ".join(part for part in cell_parts if part)))
|
||||||
|
|
||||||
|
if any(row):
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _build_docx_table_markdown(rows: list[list[str]]) -> str:
|
||||||
|
visible_rows = [
|
||||||
|
[_escape_markdown_cell(cell) for cell in row]
|
||||||
|
for row in rows
|
||||||
|
if any(str(cell or "").strip() for cell in row)
|
||||||
|
]
|
||||||
|
if len(visible_rows) < 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
column_count = max(len(row) for row in visible_rows)
|
||||||
|
normalized_rows = [row + [""] * (column_count - len(row)) for row in visible_rows]
|
||||||
|
header = [
|
||||||
|
cell or f"列{column_index + 1}" for column_index, cell in enumerate(normalized_rows[0])
|
||||||
|
]
|
||||||
|
body_rows = normalized_rows[1:]
|
||||||
|
parts = [_format_markdown_table(header, body_rows)]
|
||||||
|
|
||||||
|
row_clues: list[str] = []
|
||||||
|
for row_number, row in enumerate(body_rows, start=2):
|
||||||
|
pairs = [
|
||||||
|
f"{header[column_index]}={value}"
|
||||||
|
for column_index, value in enumerate(row)
|
||||||
|
if value
|
||||||
|
]
|
||||||
|
if pairs:
|
||||||
|
row_clues.append(f"- 表格第 {row_number} 行:" + ";".join(pairs))
|
||||||
|
|
||||||
|
if row_clues:
|
||||||
|
parts.append("### 表格行级检索线索")
|
||||||
|
parts.extend(row_clues)
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_docx_cell_text(value: str) -> str:
|
||||||
|
normalized = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
normalized = re.sub(r"[ \t]*\n[ \t]*", " ", normalized)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized)
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
|
||||||
def _extract_document_text_from_path(
|
def _extract_document_text_from_path(
|
||||||
*,
|
*,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ logger = get_logger("app.services.knowledge_normalizer")
|
|||||||
|
|
||||||
TABLE_MARKER_PATTERN = re.compile(r"表\s*(\d+)")
|
TABLE_MARKER_PATTERN = re.compile(r"表\s*(\d+)")
|
||||||
SECTION_HEADING_PATTERN = re.compile(
|
SECTION_HEADING_PATTERN = re.compile(
|
||||||
r"^(第[一二三四五六七八九十百零0-9]+[章节]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$"
|
r"^(第[一二三四五六七八九十百零0-9]+[部分章节]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$"
|
||||||
)
|
)
|
||||||
LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$")
|
LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$")
|
||||||
NUMBERED_ITEM_PATTERN = re.compile(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$")
|
NUMBERED_ITEM_PATTERN = re.compile(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$")
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ QUERY_TERM_STOPWORDS = {
|
|||||||
"哪些人",
|
"哪些人",
|
||||||
}
|
}
|
||||||
TABLE_OR_STANDARD_QUERY_HINTS = (
|
TABLE_OR_STANDARD_QUERY_HINTS = (
|
||||||
|
"表",
|
||||||
|
"表格",
|
||||||
|
"清单",
|
||||||
|
"明细",
|
||||||
|
"目录",
|
||||||
|
"科目",
|
||||||
"标准",
|
"标准",
|
||||||
"金额",
|
"金额",
|
||||||
"限额",
|
"限额",
|
||||||
@@ -61,6 +67,20 @@ TABLE_OR_STANDARD_QUERY_HINTS = (
|
|||||||
"档位",
|
"档位",
|
||||||
"额度",
|
"额度",
|
||||||
)
|
)
|
||||||
|
QUERY_ANCHOR_TERMS = (
|
||||||
|
"财务基础知识手册",
|
||||||
|
"基础知识手册",
|
||||||
|
"会计科目",
|
||||||
|
"常用会计科目",
|
||||||
|
"财务报表",
|
||||||
|
"主要税种",
|
||||||
|
"税种",
|
||||||
|
"标准",
|
||||||
|
"清单",
|
||||||
|
"明细",
|
||||||
|
"流程",
|
||||||
|
)
|
||||||
|
GENERIC_TITLE_TERMS = {"远光软件", "股份有限", "有限公司"}
|
||||||
STRUCTURED_APPENDIX_LEADING_MARKERS = (
|
STRUCTURED_APPENDIX_LEADING_MARKERS = (
|
||||||
"# 章节导航",
|
"# 章节导航",
|
||||||
"# 重点章节摘录",
|
"# 重点章节摘录",
|
||||||
@@ -96,6 +116,10 @@ class KnowledgeRagService:
|
|||||||
"message": "请先输入要检索的知识库问题。",
|
"message": "请先输入要检索的知识库问题。",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rewritten_query = normalized_query
|
||||||
|
if conversation_history:
|
||||||
|
rewritten_query = self._rewrite_query(normalized_query, conversation_history)
|
||||||
|
|
||||||
workspace = (
|
workspace = (
|
||||||
os.environ.get("LIGHTRAG_WORKSPACE", DEFAULT_LIGHTRAG_WORKSPACE).strip()
|
os.environ.get("LIGHTRAG_WORKSPACE", DEFAULT_LIGHTRAG_WORKSPACE).strip()
|
||||||
or DEFAULT_LIGHTRAG_WORKSPACE
|
or DEFAULT_LIGHTRAG_WORKSPACE
|
||||||
@@ -103,81 +127,102 @@ class KnowledgeRagService:
|
|||||||
local_result = query_local_text_chunks(
|
local_result = query_local_text_chunks(
|
||||||
lightrag_root=(self.storage_root / "knowledge" / ".lightrag").resolve(),
|
lightrag_root=(self.storage_root / "knowledge" / ".lightrag").resolve(),
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
query=normalized_query,
|
query=rewritten_query,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
if local_result.confident:
|
|
||||||
return {
|
|
||||||
"result_type": "knowledge_search",
|
|
||||||
"query": normalized_query,
|
|
||||||
"record_count": len(local_result.hits),
|
|
||||||
"hits": local_result.hits,
|
|
||||||
"references": [
|
|
||||||
str(item.get("code") or "").strip()
|
|
||||||
for item in local_result.hits
|
|
||||||
if str(item.get("code") or "").strip()
|
|
||||||
],
|
|
||||||
"raw_references": [],
|
|
||||||
"metadata": {
|
|
||||||
"retrieval_strategy": "local_text_chunks",
|
|
||||||
"elapsed_seconds": round(local_result.elapsed_seconds, 4),
|
|
||||||
"total_chunks": local_result.total_chunks,
|
|
||||||
"best_score": local_result.best_score,
|
|
||||||
},
|
|
||||||
"message": f"已从本地知识块中检索到 {len(local_result.hits)} 条相关内容。",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
runtime_hits: list[dict[str, Any]] = []
|
||||||
|
runtime_references: list[str] = []
|
||||||
try:
|
try:
|
||||||
runtime = self._get_runtime()
|
runtime = self._get_runtime()
|
||||||
raw = runtime.query_data(normalized_query, conversation_history=conversation_history)
|
raw = runtime.query_data(rewritten_query, conversation_history=conversation_history)
|
||||||
|
data = raw.get("data") if isinstance(raw, dict) else {}
|
||||||
|
chunks = list(data.get("chunks") or []) if isinstance(data, dict) else []
|
||||||
|
entities = list(data.get("entities") or []) if isinstance(data, dict) else []
|
||||||
|
runtime_references = list(data.get("references") or []) if isinstance(data, dict) else []
|
||||||
|
runtime_hits = self._build_hits_from_query_data(
|
||||||
|
query=rewritten_query,
|
||||||
|
chunks=chunks,
|
||||||
|
entities=entities,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Knowledge query failed: %s", exc)
|
logger.warning("Knowledge query failed: %s", exc)
|
||||||
|
|
||||||
|
all_hits: dict[str, dict[str, Any]] = {}
|
||||||
|
for hit in local_result.hits:
|
||||||
|
hit["score"] = int(hit.get("score") or 0)
|
||||||
|
all_hits[hit["code"]] = hit
|
||||||
|
|
||||||
|
for hit in runtime_hits:
|
||||||
|
code = hit["code"]
|
||||||
|
if code in all_hits:
|
||||||
|
all_hits[code]["score"] = max(all_hits[code]["score"], int(hit.get("score") or 0) + 20)
|
||||||
|
if not all_hits[code].get("tags") and hit.get("tags"):
|
||||||
|
all_hits[code]["tags"] = hit["tags"]
|
||||||
|
else:
|
||||||
|
hit["score"] = int(hit.get("score") or 0)
|
||||||
|
all_hits[code] = hit
|
||||||
|
|
||||||
|
merged_hits = sorted(all_hits.values(), key=lambda x: int(x.get("score") or 0), reverse=True)[:max(1, limit)]
|
||||||
|
|
||||||
|
if not merged_hits:
|
||||||
return {
|
return {
|
||||||
"result_type": "knowledge_search",
|
"result_type": "knowledge_search",
|
||||||
"query": normalized_query,
|
"query": rewritten_query,
|
||||||
"record_count": 0,
|
"record_count": 0,
|
||||||
"hits": [],
|
"hits": [],
|
||||||
"references": [],
|
"references": [],
|
||||||
"message": f"知识库检索暂不可用:{exc}",
|
"raw_references": runtime_references,
|
||||||
}
|
|
||||||
|
|
||||||
data = raw.get("data") if isinstance(raw, dict) else {}
|
|
||||||
chunks = list(data.get("chunks") or []) if isinstance(data, dict) else []
|
|
||||||
entities = list(data.get("entities") or []) if isinstance(data, dict) else []
|
|
||||||
references = list(data.get("references") or []) if isinstance(data, dict) else []
|
|
||||||
hits = self._build_hits_from_query_data(
|
|
||||||
query=normalized_query,
|
|
||||||
chunks=chunks,
|
|
||||||
entities=entities,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hits:
|
|
||||||
return {
|
|
||||||
"result_type": "knowledge_search",
|
|
||||||
"query": normalized_query,
|
|
||||||
"record_count": 0,
|
|
||||||
"hits": [],
|
|
||||||
"references": [],
|
|
||||||
"raw_references": references,
|
|
||||||
"message": "当前知识库中没有检索到与本次问题直接匹配的内容。",
|
"message": "当前知识库中没有检索到与本次问题直接匹配的内容。",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"result_type": "knowledge_search",
|
"result_type": "knowledge_search",
|
||||||
"query": normalized_query,
|
"query": rewritten_query,
|
||||||
"record_count": len(hits),
|
"record_count": len(merged_hits),
|
||||||
"hits": hits,
|
"hits": merged_hits,
|
||||||
"references": [
|
"references": [
|
||||||
str(item.get("code") or "").strip()
|
str(item.get("code") or "").strip()
|
||||||
for item in hits
|
for item in merged_hits
|
||||||
if str(item.get("code") or "").strip()
|
if str(item.get("code") or "").strip()
|
||||||
],
|
],
|
||||||
"raw_references": references,
|
"raw_references": runtime_references,
|
||||||
"metadata": raw.get("metadata") if isinstance(raw, dict) else {},
|
"metadata": {
|
||||||
"message": f"已从知识库中检索到 {len(hits)} 条相关内容。",
|
"retrieval_strategy": "fusion",
|
||||||
|
"local_total_chunks": local_result.total_chunks,
|
||||||
|
"local_best_score": local_result.best_score,
|
||||||
|
},
|
||||||
|
"message": f"已从知识库中联合检索到 {len(merged_hits)} 条相关内容。",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _rewrite_query(self, query: str, conversation_history: list[dict[str, str]]) -> str:
|
||||||
|
if not self.db:
|
||||||
|
return query
|
||||||
|
|
||||||
|
from app.services.runtime_chat import RuntimeChatService
|
||||||
|
try:
|
||||||
|
chat_service = RuntimeChatService(self.db)
|
||||||
|
messages: list[dict[str, Any]] = [{"role": "system", "content": "你是一个查询重写助手。你的任务是根据用户的多轮对话历史,将用户的最后一次提问重写为一句独立、完整的查询语句,以便于在知识库中进行向量检索。只输出重写后的句子,不要任何解释。"}]
|
||||||
|
for msg in conversation_history[-6:]:
|
||||||
|
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||||
|
messages.append({"role": "user", "content": f"当前提问:{query}\n\n请重写当前提问。"})
|
||||||
|
|
||||||
|
rewritten = chat_service.complete(
|
||||||
|
messages,
|
||||||
|
max_tokens=60,
|
||||||
|
temperature=0.1,
|
||||||
|
timeout_seconds=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if rewritten and len(rewritten) > 2 and len(rewritten) < 80:
|
||||||
|
logger.info("Query rewritten: '%s' -> '%s'", query, rewritten)
|
||||||
|
return rewritten
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Query rewrite failed: %s", exc)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
def index_documents(
|
def index_documents(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -686,6 +731,24 @@ def _extract_query_terms(query: str) -> list[str]:
|
|||||||
remember(item)
|
remember(item)
|
||||||
|
|
||||||
for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_query):
|
for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_query):
|
||||||
|
for marker in ("标准", "金额", "限额", "额度"):
|
||||||
|
marker_index = block.find(marker)
|
||||||
|
if marker_index <= 0:
|
||||||
|
continue
|
||||||
|
subject = block[:marker_index]
|
||||||
|
for width in (6, 4, 3, 2):
|
||||||
|
remember(subject[-width:])
|
||||||
|
for anchor in QUERY_ANCHOR_TERMS:
|
||||||
|
if anchor in block:
|
||||||
|
remember(anchor)
|
||||||
|
tail = block[-14:]
|
||||||
|
for size in (8, 7, 6, 5, 4):
|
||||||
|
for start in range(0, len(tail) - size + 1):
|
||||||
|
piece = tail[start : start + size]
|
||||||
|
if any(anchor in piece for anchor in QUERY_ANCHOR_TERMS):
|
||||||
|
remember(piece)
|
||||||
|
if len(terms) >= MAX_QUERY_TERMS:
|
||||||
|
return terms
|
||||||
if len(block) <= 4:
|
if len(block) <= 4:
|
||||||
remember(block)
|
remember(block)
|
||||||
continue
|
continue
|
||||||
@@ -715,6 +778,11 @@ def _score_knowledge_hit(
|
|||||||
matched_terms = [term for term in query_terms if term in haystack]
|
matched_terms = [term for term in query_terms if term in haystack]
|
||||||
score += len(matched_terms) * 8
|
score += len(matched_terms) * 8
|
||||||
score += sum(1 for term in matched_terms if term in title) * 6
|
score += sum(1 for term in matched_terms if term in title) * 6
|
||||||
|
score += sum(
|
||||||
|
(len(term) - 3) * 12
|
||||||
|
for term in matched_terms
|
||||||
|
if len(term) >= 4 and term in title and term not in GENERIC_TITLE_TERMS
|
||||||
|
)
|
||||||
|
|
||||||
leading_appendix_marker = _leading_structured_appendix_marker(content)
|
leading_appendix_marker = _leading_structured_appendix_marker(content)
|
||||||
if leading_appendix_marker == "# 章节导航":
|
if leading_appendix_marker == "# 章节导航":
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ LOCAL_QUERY_STOPWORDS = {
|
|||||||
"问题",
|
"问题",
|
||||||
}
|
}
|
||||||
LOCAL_TABLE_QUERY_HINTS = (
|
LOCAL_TABLE_QUERY_HINTS = (
|
||||||
|
"表",
|
||||||
|
"表格",
|
||||||
|
"清单",
|
||||||
|
"明细",
|
||||||
|
"目录",
|
||||||
|
"科目",
|
||||||
"标准",
|
"标准",
|
||||||
"金额",
|
"金额",
|
||||||
"限额",
|
"限额",
|
||||||
@@ -53,6 +59,20 @@ LOCAL_TABLE_QUERY_HINTS = (
|
|||||||
"档位",
|
"档位",
|
||||||
"额度",
|
"额度",
|
||||||
)
|
)
|
||||||
|
LOCAL_QUERY_ANCHOR_TERMS = (
|
||||||
|
"财务基础知识手册",
|
||||||
|
"基础知识手册",
|
||||||
|
"会计科目",
|
||||||
|
"常用会计科目",
|
||||||
|
"财务报表",
|
||||||
|
"主要税种",
|
||||||
|
"税种",
|
||||||
|
"标准",
|
||||||
|
"清单",
|
||||||
|
"明细",
|
||||||
|
"流程",
|
||||||
|
)
|
||||||
|
LOCAL_GENERIC_TITLE_TERMS = {"远光软件", "股份有限", "有限公司"}
|
||||||
LOCAL_DOMAIN_TERMS = (
|
LOCAL_DOMAIN_TERMS = (
|
||||||
"报销",
|
"报销",
|
||||||
"费用",
|
"费用",
|
||||||
@@ -253,6 +273,8 @@ def _score_local_chunk(
|
|||||||
score += weight
|
score += weight
|
||||||
if term in lowered_title:
|
if term in lowered_title:
|
||||||
score += max(4, weight)
|
score += max(4, weight)
|
||||||
|
if len(term) >= 4 and term not in LOCAL_GENERIC_TITLE_TERMS:
|
||||||
|
score += (len(term) - 3) * 12
|
||||||
occurrences = lowered_content.count(term)
|
occurrences = lowered_content.count(term)
|
||||||
if occurrences > 1:
|
if occurrences > 1:
|
||||||
score += min(8, occurrences * 2)
|
score += min(8, occurrences * 2)
|
||||||
@@ -299,6 +321,24 @@ def _extract_local_query_terms(query: str) -> list[str]:
|
|||||||
remember(item)
|
remember(item)
|
||||||
|
|
||||||
for block in re.findall(r"[\u4e00-\u9fff]{2,24}", normalized_query):
|
for block in re.findall(r"[\u4e00-\u9fff]{2,24}", normalized_query):
|
||||||
|
for marker in ("标准", "金额", "限额", "额度"):
|
||||||
|
marker_index = block.find(marker)
|
||||||
|
if marker_index <= 0:
|
||||||
|
continue
|
||||||
|
subject = block[:marker_index]
|
||||||
|
for width in (6, 4, 3, 2):
|
||||||
|
remember(subject[-width:])
|
||||||
|
for anchor in LOCAL_QUERY_ANCHOR_TERMS:
|
||||||
|
if anchor in block:
|
||||||
|
remember(anchor)
|
||||||
|
tail = block[-14:]
|
||||||
|
for size in (8, 7, 6, 5, 4):
|
||||||
|
for start in range(0, len(tail) - size + 1):
|
||||||
|
piece = tail[start : start + size]
|
||||||
|
if any(anchor in piece for anchor in LOCAL_QUERY_ANCHOR_TERMS):
|
||||||
|
remember(piece)
|
||||||
|
if len(terms) >= MAX_LOCAL_QUERY_TERMS:
|
||||||
|
return terms
|
||||||
if len(block) <= 4:
|
if len(block) <= 4:
|
||||||
remember(block)
|
remember(block)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class SemanticOntologyService(
|
|||||||
context_json = payload.context_json or {}
|
context_json = payload.context_json or {}
|
||||||
reference = self._load_reference_catalog()
|
reference = self._load_reference_catalog()
|
||||||
compact_query = self._compact(query)
|
compact_query = self._compact(query)
|
||||||
entities = self._extract_entities(query, compact_query, reference)
|
entities = self._extract_entities(query, compact_query, reference, context_json=context_json)
|
||||||
rule_scenario, scenario_score = self._detect_scenario(compact_query)
|
rule_scenario, scenario_score = self._detect_scenario(compact_query)
|
||||||
time_range, _time_score = self._extract_time_range(
|
time_range, _time_score = self._extract_time_range(
|
||||||
query,
|
query,
|
||||||
@@ -111,9 +111,14 @@ class SemanticOntologyService(
|
|||||||
)
|
)
|
||||||
session_scenario = self._resolve_session_type_scenario(context_json)
|
session_scenario = self._resolve_session_type_scenario(context_json)
|
||||||
context_scenario = self._resolve_context_scenario(context_json)
|
context_scenario = self._resolve_context_scenario(context_json)
|
||||||
|
application_context = self._is_expense_application_context(context_json)
|
||||||
|
application_query = self._looks_like_expense_application(compact_query)
|
||||||
if session_scenario == "knowledge":
|
if session_scenario == "knowledge":
|
||||||
rule_scenario = "knowledge"
|
rule_scenario = "knowledge"
|
||||||
scenario_score = max(scenario_score, 0.34)
|
scenario_score = max(scenario_score, 0.34)
|
||||||
|
if session_scenario != "knowledge" and (application_context or application_query):
|
||||||
|
rule_scenario = "expense"
|
||||||
|
scenario_score = max(scenario_score, 0.22)
|
||||||
if rule_scenario == "unknown" and context_scenario is not None:
|
if rule_scenario == "unknown" and context_scenario is not None:
|
||||||
rule_scenario = context_scenario
|
rule_scenario = context_scenario
|
||||||
scenario_score = max(scenario_score, 0.14)
|
scenario_score = max(scenario_score, 0.14)
|
||||||
@@ -138,6 +143,9 @@ class SemanticOntologyService(
|
|||||||
entities=entities,
|
entities=entities,
|
||||||
time_range=time_range,
|
time_range=time_range,
|
||||||
)
|
)
|
||||||
|
if session_scenario != "knowledge" and (application_context or application_query):
|
||||||
|
rule_intent = "draft"
|
||||||
|
intent_score = max(intent_score, 0.22)
|
||||||
if session_scenario != "knowledge" and self._should_inherit_expense_draft(
|
if session_scenario != "knowledge" and self._should_inherit_expense_draft(
|
||||||
compact_query,
|
compact_query,
|
||||||
scenario=rule_scenario,
|
scenario=rule_scenario,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from app.services.ontology_rules import (
|
|||||||
COMPARE_KEYWORDS,
|
COMPARE_KEYWORDS,
|
||||||
DRAFT_FOLLOW_UP_KEYWORDS,
|
DRAFT_FOLLOW_UP_KEYWORDS,
|
||||||
DRAFT_KEYWORDS,
|
DRAFT_KEYWORDS,
|
||||||
|
EXPENSE_APPLICATION_CONTEXT_TYPES,
|
||||||
|
EXPENSE_APPLICATION_KEYWORDS,
|
||||||
EXPENSE_NARRATIVE_KEYWORDS,
|
EXPENSE_NARRATIVE_KEYWORDS,
|
||||||
EXPENSE_REVIEW_ACTIONS,
|
EXPENSE_REVIEW_ACTIONS,
|
||||||
EXPLAIN_KEYWORDS,
|
EXPLAIN_KEYWORDS,
|
||||||
@@ -71,6 +73,21 @@ EXPLICIT_ENTERTAINMENT_KEYWORDS = (
|
|||||||
|
|
||||||
|
|
||||||
class OntologyDetectionMixin:
|
class OntologyDetectionMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_application_context(context_json: dict[str, Any]) -> bool:
|
||||||
|
document_type = str(context_json.get("document_type") or "").strip()
|
||||||
|
application_stage = str(context_json.get("application_stage") or "").strip()
|
||||||
|
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||||
|
return (
|
||||||
|
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
|
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
|
or entry_source in {"documents_application", "expense_application"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _looks_like_expense_application(compact_query: str) -> bool:
|
||||||
|
return any(keyword in compact_query for keyword in EXPENSE_APPLICATION_KEYWORDS)
|
||||||
|
|
||||||
def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
|
def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
|
||||||
scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
|
scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
|
||||||
for scenario, keywords in SCENARIO_KEYWORDS.items():
|
for scenario, keywords in SCENARIO_KEYWORDS.items():
|
||||||
@@ -341,6 +358,9 @@ class OntologyDetectionMixin:
|
|||||||
"conversation_id": payload.context_json.get("conversation_id"),
|
"conversation_id": payload.context_json.get("conversation_id"),
|
||||||
"conversation_scenario": payload.context_json.get("conversation_scenario"),
|
"conversation_scenario": payload.context_json.get("conversation_scenario"),
|
||||||
"conversation_intent": payload.context_json.get("conversation_intent"),
|
"conversation_intent": payload.context_json.get("conversation_intent"),
|
||||||
|
"document_type": payload.context_json.get("document_type"),
|
||||||
|
"application_stage": payload.context_json.get("application_stage"),
|
||||||
|
"application_fields": payload.context_json.get("application_fields"),
|
||||||
"draft_claim_id": payload.context_json.get("draft_claim_id"),
|
"draft_claim_id": payload.context_json.get("draft_claim_id"),
|
||||||
"review_action": payload.context_json.get("review_action"),
|
"review_action": payload.context_json.get("review_action"),
|
||||||
"review_form_values": payload.context_json.get("review_form_values"),
|
"review_form_values": payload.context_json.get("review_form_values"),
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ from app.services.ontology_rules import (
|
|||||||
DATE_RANGE_PATTERN,
|
DATE_RANGE_PATTERN,
|
||||||
EXPLICIT_DATE_PATTERN,
|
EXPLICIT_DATE_PATTERN,
|
||||||
EXPLICIT_MONTH_PATTERN,
|
EXPLICIT_MONTH_PATTERN,
|
||||||
|
EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES,
|
||||||
|
EXPENSE_APPLICATION_CONTEXT_TYPES,
|
||||||
|
EXPENSE_APPLICATION_KEYWORDS,
|
||||||
|
EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS,
|
||||||
EXPENSE_TYPE_KEYWORDS,
|
EXPENSE_TYPE_KEYWORDS,
|
||||||
|
GENERIC_EXPENSE_APPLICATION_PROMPTS,
|
||||||
GENERIC_EXPENSE_PROMPTS,
|
GENERIC_EXPENSE_PROMPTS,
|
||||||
LOCATION_KEYWORDS,
|
LOCATION_KEYWORDS,
|
||||||
MONTH_DAY_PATTERN,
|
MONTH_DAY_PATTERN,
|
||||||
@@ -30,6 +35,21 @@ from app.services.ontology_rules import (
|
|||||||
|
|
||||||
|
|
||||||
class OntologyExtractionMixin:
|
class OntologyExtractionMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_application_context_value(context_json: dict[str, Any]) -> bool:
|
||||||
|
document_type = str(context_json.get("document_type") or "").strip()
|
||||||
|
application_stage = str(context_json.get("application_stage") or "").strip()
|
||||||
|
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||||
|
return (
|
||||||
|
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
|
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||||
|
or entry_source in {"documents_application", "expense_application"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_expense_application_signal(compact_query: str) -> bool:
|
||||||
|
return any(keyword in compact_query for keyword in EXPENSE_APPLICATION_KEYWORDS)
|
||||||
|
|
||||||
def _infer_default_missing_slots(
|
def _infer_default_missing_slots(
|
||||||
self,
|
self,
|
||||||
compact_query: str,
|
compact_query: str,
|
||||||
@@ -46,6 +66,44 @@ class OntologyExtractionMixin:
|
|||||||
entity_types = {item.type for item in entities}
|
entity_types = {item.type for item in entities}
|
||||||
attachment_count = int(context_json.get("attachment_count") or 0)
|
attachment_count = int(context_json.get("attachment_count") or 0)
|
||||||
missing_slots: list[str] = []
|
missing_slots: list[str] = []
|
||||||
|
application_mode = (
|
||||||
|
self._is_expense_application_context_value(context_json)
|
||||||
|
or self._has_expense_application_signal(compact_query)
|
||||||
|
or any(
|
||||||
|
item.type == "document_type" and item.normalized_value == "expense_application"
|
||||||
|
for item in entities
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if application_mode:
|
||||||
|
form_values = context_json.get("review_form_values")
|
||||||
|
if not isinstance(form_values, dict):
|
||||||
|
form_values = {}
|
||||||
|
expense_type_codes = {
|
||||||
|
str(item.normalized_value or item.value or "").strip()
|
||||||
|
for item in entities
|
||||||
|
if item.type == "expense_type"
|
||||||
|
}
|
||||||
|
if "expense_type" not in entity_types and not str(form_values.get("expense_type") or "").strip():
|
||||||
|
missing_slots.append("expense_type")
|
||||||
|
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
|
||||||
|
missing_slots.append("amount")
|
||||||
|
if not time_range.start_date and not (
|
||||||
|
str(form_values.get("time_range") or form_values.get("business_time") or "").strip()
|
||||||
|
):
|
||||||
|
missing_slots.append("time_range")
|
||||||
|
reason_value = str(
|
||||||
|
form_values.get("reason")
|
||||||
|
or form_values.get("business_reason")
|
||||||
|
or form_values.get("reason_value")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
|
||||||
|
missing_slots.append("reason")
|
||||||
|
if attachment_count <= 0 and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES:
|
||||||
|
missing_slots.append("attachments")
|
||||||
|
ordered_keys = [*EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, "attachments"]
|
||||||
|
return [item for item in ordered_keys if item in missing_slots]
|
||||||
|
|
||||||
if self._is_generic_expense_prompt(compact_query):
|
if self._is_generic_expense_prompt(compact_query):
|
||||||
if "expense_type" not in entity_types:
|
if "expense_type" not in entity_types:
|
||||||
@@ -98,14 +156,40 @@ class OntologyExtractionMixin:
|
|||||||
query: str,
|
query: str,
|
||||||
compact_query: str,
|
compact_query: str,
|
||||||
reference: ReferenceCatalog,
|
reference: ReferenceCatalog,
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any] | None = None,
|
||||||
) -> list[OntologyEntity]:
|
) -> list[OntologyEntity]:
|
||||||
entities: dict[tuple[str, str], OntologyEntity] = {}
|
entities: dict[tuple[str, str], OntologyEntity] = {}
|
||||||
|
context_json = context_json or {}
|
||||||
|
|
||||||
def upsert(entity: OntologyEntity) -> None:
|
def upsert(entity: OntologyEntity) -> None:
|
||||||
key = (entity.type, entity.normalized_value)
|
key = (entity.type, entity.normalized_value)
|
||||||
if key not in entities:
|
if key not in entities:
|
||||||
entities[key] = entity
|
entities[key] = entity
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._is_expense_application_context_value(context_json)
|
||||||
|
or self._has_expense_application_signal(compact_query)
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"document_type",
|
||||||
|
"费用申请",
|
||||||
|
"expense_application",
|
||||||
|
role="target",
|
||||||
|
confidence=0.94,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"workflow_stage",
|
||||||
|
"前置申请",
|
||||||
|
"pre_approval",
|
||||||
|
role="target",
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for match in re.finditer(r"客户\s*([A-Za-z0-9一二三四五六七八九十]+)", query):
|
for match in re.finditer(r"客户\s*([A-Za-z0-9一二三四五六七八九十]+)", query):
|
||||||
suffix = match.group(1).strip()
|
suffix = match.group(1).strip()
|
||||||
normalized = f"客户{suffix}".replace(" ", "")
|
normalized = f"客户{suffix}".replace(" ", "")
|
||||||
@@ -510,6 +594,8 @@ class OntologyExtractionMixin:
|
|||||||
"project",
|
"project",
|
||||||
"location",
|
"location",
|
||||||
"expense_type",
|
"expense_type",
|
||||||
|
"document_type",
|
||||||
|
"workflow_stage",
|
||||||
}:
|
}:
|
||||||
upsert(
|
upsert(
|
||||||
OntologyConstraint(
|
OntologyConstraint(
|
||||||
|
|||||||
@@ -173,6 +173,49 @@ GENERIC_EXPENSE_PROMPTS = {
|
|||||||
"发起报销",
|
"发起报销",
|
||||||
"提交报销",
|
"提交报销",
|
||||||
}
|
}
|
||||||
|
EXPENSE_APPLICATION_CONTEXT_TYPES = {
|
||||||
|
"expense_application",
|
||||||
|
"application",
|
||||||
|
"pre_approval",
|
||||||
|
"preapproval",
|
||||||
|
}
|
||||||
|
EXPENSE_APPLICATION_KEYWORDS = (
|
||||||
|
"费用申请",
|
||||||
|
"申请单",
|
||||||
|
"发起申请",
|
||||||
|
"提交申请",
|
||||||
|
"提出申请",
|
||||||
|
"前置申请",
|
||||||
|
"报销申请",
|
||||||
|
"申请报销",
|
||||||
|
"差旅申请",
|
||||||
|
"出差申请",
|
||||||
|
"会务申请",
|
||||||
|
"会议申请",
|
||||||
|
"采购申请",
|
||||||
|
"培训申请",
|
||||||
|
"预算申请",
|
||||||
|
)
|
||||||
|
GENERIC_EXPENSE_APPLICATION_PROMPTS = {
|
||||||
|
"申请",
|
||||||
|
"费用申请",
|
||||||
|
"发起申请",
|
||||||
|
"提交申请",
|
||||||
|
"提出申请",
|
||||||
|
"申请报销",
|
||||||
|
"报销申请",
|
||||||
|
}
|
||||||
|
EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS = (
|
||||||
|
"expense_type",
|
||||||
|
"amount",
|
||||||
|
"time_range",
|
||||||
|
"reason",
|
||||||
|
)
|
||||||
|
EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES = {
|
||||||
|
"meeting",
|
||||||
|
"office",
|
||||||
|
"training",
|
||||||
|
}
|
||||||
MISSING_SLOT_LABELS = {
|
MISSING_SLOT_LABELS = {
|
||||||
"expense_type": "费用类型",
|
"expense_type": "费用类型",
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
|||||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
|
from app.services.expense_type_keywords import EXPENSE_TYPE_LABEL_BY_CODE
|
||||||
from app.services.risk_rule_flow_diagram import (
|
from app.services.risk_rule_flow_diagram import (
|
||||||
RiskRuleFlowDiagramField,
|
RiskRuleFlowDiagramField,
|
||||||
RiskRuleFlowDiagramRenderer,
|
RiskRuleFlowDiagramRenderer,
|
||||||
@@ -43,6 +44,24 @@ RISK_LEVEL_LABELS: dict[str, str] = {
|
|||||||
"high": "高风险",
|
"high": "高风险",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
|
||||||
|
"travel",
|
||||||
|
"hotel",
|
||||||
|
"transport",
|
||||||
|
"meal",
|
||||||
|
"meeting",
|
||||||
|
"office",
|
||||||
|
"training",
|
||||||
|
"communication",
|
||||||
|
"welfare",
|
||||||
|
)
|
||||||
|
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
|
||||||
|
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
|
||||||
|
}
|
||||||
|
EXPENSE_RISK_CATEGORY_ALIASES = {
|
||||||
|
"entertainment": "meal",
|
||||||
|
}
|
||||||
|
|
||||||
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||||
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
||||||
RiskRuleField(
|
RiskRuleField(
|
||||||
@@ -156,17 +175,23 @@ class RiskRuleGenerationService:
|
|||||||
risk_level = str(body.risk_level or "medium").strip().lower()
|
risk_level = str(body.risk_level or "medium").strip().lower()
|
||||||
if risk_level not in RISK_LEVEL_LABELS:
|
if risk_level not in RISK_LEVEL_LABELS:
|
||||||
raise ValueError("风险等级仅支持 low、medium、high。")
|
raise ValueError("风险等级仅支持 low、medium、high。")
|
||||||
|
requires_attachment = bool(body.requires_attachment)
|
||||||
|
expense_category = self._normalize_expense_category(body.expense_category, domain)
|
||||||
|
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||||
|
|
||||||
created_at = datetime.now(UTC)
|
created_at = datetime.now(UTC)
|
||||||
fields = self._resolve_fields(natural_language, domain=domain)
|
fields = self._resolve_fields(natural_language, domain=domain)
|
||||||
draft = self._compile_with_model(
|
draft = self._compile_with_model(
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
expense_category=expense_category,
|
||||||
|
expense_category_label=expense_category_label,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
) or self._build_fallback_draft(
|
) or self._build_fallback_draft(
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
expense_category_label=expense_category_label,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
)
|
)
|
||||||
@@ -179,10 +204,13 @@ class RiskRuleGenerationService:
|
|||||||
draft,
|
draft,
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
expense_category=expense_category,
|
||||||
|
expense_category_label=expense_category_label,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
|
requires_attachment=requires_attachment,
|
||||||
)
|
)
|
||||||
rule_code = str(payload["rule_code"])
|
rule_code = str(payload["rule_code"])
|
||||||
file_name = f"{rule_code}.json"
|
file_name = f"{rule_code}.json"
|
||||||
@@ -209,8 +237,11 @@ class RiskRuleGenerationService:
|
|||||||
config_json={
|
config_json={
|
||||||
"severity": risk_level,
|
"severity": risk_level,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
"requires_attachment": requires_attachment,
|
||||||
"tag": "风险规则",
|
"tag": "风险规则",
|
||||||
"detail_mode": "json_risk",
|
"detail_mode": "json_risk",
|
||||||
|
"expense_category": expense_category,
|
||||||
|
"expense_category_label": expense_category_label,
|
||||||
"risk_category": payload.get("risk_category"),
|
"risk_category": payload.get("risk_category"),
|
||||||
"rule_library": RISK_RULES_LIBRARY,
|
"rule_library": RISK_RULES_LIBRARY,
|
||||||
"rule_document": {
|
"rule_document": {
|
||||||
@@ -241,7 +272,13 @@ class RiskRuleGenerationService:
|
|||||||
resource_type=AgentAssetType.RULE.value,
|
resource_type=AgentAssetType.RULE.value,
|
||||||
resource_id=asset.id,
|
resource_id=asset.id,
|
||||||
before_json=None,
|
before_json=None,
|
||||||
after_json={"rule_code": rule_code, "risk_level": risk_level, "domain": domain},
|
after_json={
|
||||||
|
"rule_code": rule_code,
|
||||||
|
"risk_level": risk_level,
|
||||||
|
"domain": domain,
|
||||||
|
"expense_category": expense_category,
|
||||||
|
"requires_attachment": requires_attachment,
|
||||||
|
},
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
self.db.refresh(asset)
|
self.db.refresh(asset)
|
||||||
@@ -252,6 +289,8 @@ class RiskRuleGenerationService:
|
|||||||
*,
|
*,
|
||||||
natural_language: str,
|
natural_language: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
expense_category: str | None,
|
||||||
|
expense_category_label: str,
|
||||||
risk_level: str,
|
risk_level: str,
|
||||||
fields: list[RiskRuleField],
|
fields: list[RiskRuleField],
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
@@ -279,6 +318,8 @@ class RiskRuleGenerationService:
|
|||||||
{
|
{
|
||||||
"business_domain": domain,
|
"business_domain": domain,
|
||||||
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
|
"business_domain_label": BUSINESS_DOMAIN_LABELS[domain],
|
||||||
|
"expense_category": expense_category,
|
||||||
|
"expense_category_label": expense_category_label,
|
||||||
"risk_level": risk_level,
|
"risk_level": risk_level,
|
||||||
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
|
"risk_level_label": RISK_LEVEL_LABELS[risk_level],
|
||||||
"natural_language": natural_language,
|
"natural_language": natural_language,
|
||||||
@@ -370,6 +411,7 @@ class RiskRuleGenerationService:
|
|||||||
*,
|
*,
|
||||||
natural_language: str,
|
natural_language: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
expense_category_label: str,
|
||||||
risk_level: str,
|
risk_level: str,
|
||||||
fields: list[RiskRuleField],
|
fields: list[RiskRuleField],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -381,8 +423,9 @@ class RiskRuleGenerationService:
|
|||||||
fields=fields,
|
fields=fields,
|
||||||
)
|
)
|
||||||
name = self._infer_rule_name(natural_language)
|
name = self._infer_rule_name(natural_language)
|
||||||
|
business_label = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
||||||
description = (
|
description = (
|
||||||
f"当{BUSINESS_DOMAIN_LABELS[domain]}业务满足“{natural_language}”时,系统会按"
|
f"当{business_label}业务满足“{natural_language}”时,系统会按"
|
||||||
f"{RISK_LEVEL_LABELS[risk_level]}进行提示,并要求经办人或审核人补充核对依据。"
|
f"{RISK_LEVEL_LABELS[risk_level]}进行提示,并要求经办人或审核人补充核对依据。"
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -393,7 +436,7 @@ class RiskRuleGenerationService:
|
|||||||
"condition_summary": condition_summary,
|
"condition_summary": condition_summary,
|
||||||
"keywords": self._infer_keywords(natural_language),
|
"keywords": self._infer_keywords(natural_language),
|
||||||
"flow": {
|
"flow": {
|
||||||
"start": f"{BUSINESS_DOMAIN_LABELS[domain]}单据提交",
|
"start": f"{business_label}单据提交",
|
||||||
"evidence": "读取" + "、".join(item.label for item in fields[:3]),
|
"evidence": "读取" + "、".join(item.label for item in fields[:3]),
|
||||||
"decision": condition_summary,
|
"decision": condition_summary,
|
||||||
"pass": "未命中风险,继续业务流转",
|
"pass": "未命中风险,继续业务流转",
|
||||||
@@ -407,14 +450,18 @@ class RiskRuleGenerationService:
|
|||||||
*,
|
*,
|
||||||
natural_language: str,
|
natural_language: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
expense_category: str | None,
|
||||||
|
expense_category_label: str,
|
||||||
risk_level: str,
|
risk_level: str,
|
||||||
fields: list[RiskRuleField],
|
fields: list[RiskRuleField],
|
||||||
created_at: datetime,
|
created_at: datetime,
|
||||||
actor: str,
|
actor: str,
|
||||||
|
requires_attachment: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
created_stamp = created_at.strftime("%Y%m%d%H%M%S")
|
created_stamp = created_at.strftime("%Y%m%d%H%M%S%f")
|
||||||
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
|
domain_slug = {"expense": "expense", "ar": "ar", "ap": "ap"}[domain]
|
||||||
rule_code = f"risk.{domain_slug}.generated_{created_stamp}"
|
category_slug = f".{expense_category}" if expense_category else ""
|
||||||
|
rule_code = f"risk.{domain_slug}{category_slug}.generated_{created_stamp}"
|
||||||
template_key = str(draft.get("template_key") or "field_required_v1").strip()
|
template_key = str(draft.get("template_key") or "field_required_v1").strip()
|
||||||
field_keys = [
|
field_keys = [
|
||||||
str(item or "").strip()
|
str(item or "").strip()
|
||||||
@@ -424,7 +471,7 @@ class RiskRuleGenerationService:
|
|||||||
condition_summary = (
|
condition_summary = (
|
||||||
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
|
self._clean_text(draft.get("condition_summary")) or "判断是否符合自然语言规则描述"
|
||||||
)
|
)
|
||||||
risk_category = BUSINESS_DOMAIN_LABELS[domain]
|
risk_category = expense_category_label or BUSINESS_DOMAIN_LABELS[domain]
|
||||||
keywords = list(draft.get("keywords") or [])
|
keywords = list(draft.get("keywords") or [])
|
||||||
field_by_key = {item.key: item for item in fields}
|
field_by_key = {item.key: item for item in fields}
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
@@ -440,6 +487,9 @@ class RiskRuleGenerationService:
|
|||||||
if template_key == "keyword_match_v1":
|
if template_key == "keyword_match_v1":
|
||||||
params["keywords"] = keywords
|
params["keywords"] = keywords
|
||||||
params["search_fields"] = field_keys
|
params["search_fields"] = field_keys
|
||||||
|
applies_to: dict[str, Any] = {"domains": [domain]}
|
||||||
|
if expense_category:
|
||||||
|
applies_to["expense_categories"] = [expense_category]
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"schema_version": "2.0",
|
"schema_version": "2.0",
|
||||||
@@ -447,12 +497,13 @@ class RiskRuleGenerationService:
|
|||||||
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
|
"name": self._clean_text(draft.get("name")) or self._infer_rule_name(natural_language),
|
||||||
"description": self._clean_text(draft.get("description")) or natural_language,
|
"description": self._clean_text(draft.get("description")) or natural_language,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
"requires_attachment": requires_attachment,
|
||||||
"risk_dimension": "natural_language_rule",
|
"risk_dimension": "natural_language_rule",
|
||||||
"risk_category": risk_category,
|
"risk_category": risk_category,
|
||||||
"ontology_signal": "natural_language_risk",
|
"ontology_signal": "natural_language_risk",
|
||||||
"evaluator": "template_rule",
|
"evaluator": "template_rule",
|
||||||
"template_key": template_key,
|
"template_key": template_key,
|
||||||
"applies_to": {"domains": [domain]},
|
"applies_to": applies_to,
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -478,6 +529,9 @@ class RiskRuleGenerationService:
|
|||||||
"source_ref": "自然语言风险规则",
|
"source_ref": "自然语言风险规则",
|
||||||
"created_at": created_at.isoformat(),
|
"created_at": created_at.isoformat(),
|
||||||
"created_by": actor,
|
"created_by": actor,
|
||||||
|
"requires_attachment": requires_attachment,
|
||||||
|
"expense_category": expense_category,
|
||||||
|
"expense_category_label": expense_category_label,
|
||||||
"natural_language": natural_language,
|
"natural_language": natural_language,
|
||||||
"business_explanation": self._clean_text(draft.get("description")),
|
"business_explanation": self._clean_text(draft.get("description")),
|
||||||
"condition_summary": condition_summary,
|
"condition_summary": condition_summary,
|
||||||
@@ -488,6 +542,7 @@ class RiskRuleGenerationService:
|
|||||||
payload,
|
payload,
|
||||||
fields=[field_by_key[key] for key in field_keys if key in field_by_key],
|
fields=[field_by_key[key] for key in field_keys if key in field_by_key],
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
domain_label=risk_category,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
@@ -498,6 +553,7 @@ class RiskRuleGenerationService:
|
|||||||
*,
|
*,
|
||||||
fields: list[RiskRuleField],
|
fields: list[RiskRuleField],
|
||||||
domain: str,
|
domain: str,
|
||||||
|
domain_label: str | None = None,
|
||||||
risk_level: str,
|
risk_level: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||||
@@ -506,7 +562,7 @@ class RiskRuleGenerationService:
|
|||||||
return self.flow_diagram_renderer.render(
|
return self.flow_diagram_renderer.render(
|
||||||
RiskRuleFlowDiagramSpec(
|
RiskRuleFlowDiagramSpec(
|
||||||
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
|
title=self._clean_text(payload.get("name")) or "风险规则判断流程",
|
||||||
domain_label=BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
|
domain_label=domain_label or BUSINESS_DOMAIN_LABELS.get(domain, "业务"),
|
||||||
severity=risk_level,
|
severity=risk_level,
|
||||||
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
|
severity_label=RISK_LEVEL_LABELS.get(risk_level, "中风险"),
|
||||||
fields=tuple(
|
fields=tuple(
|
||||||
@@ -528,6 +584,21 @@ class RiskRuleGenerationService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_expense_category(value: str | None, domain: str) -> str | None:
|
||||||
|
if domain != AgentAssetDomain.EXPENSE.value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = str(value or "").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = EXPENSE_RISK_CATEGORY_ALIASES.get(normalized, normalized)
|
||||||
|
if normalized not in EXPENSE_RISK_CATEGORY_LABELS:
|
||||||
|
allowed = "、".join(EXPENSE_RISK_CATEGORY_LABELS.values())
|
||||||
|
raise ValueError(f"费用领域仅支持:{allowed}。")
|
||||||
|
return normalized
|
||||||
|
|
||||||
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
|
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
|
||||||
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
|
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
|
||||||
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]
|
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]
|
||||||
|
|||||||
@@ -172,8 +172,12 @@ class RiskRuleTemplateExecutor:
|
|||||||
if field_key == "ocr_text":
|
if field_key == "ocr_text":
|
||||||
values.extend([context.get("ocr_text"), context.get("ocr_summary")])
|
values.extend([context.get("ocr_text"), context.get("ocr_summary")])
|
||||||
if field_key in {"hotel_city", "route_cities"}:
|
if field_key in {"hotel_city", "route_cities"}:
|
||||||
values.extend(self._scan_document_values(document_info, field_key))
|
specific_values = self._scan_document_values(document_info, field_key)
|
||||||
values.extend(self._scan_document_values(document_info, "city"))
|
values.extend(
|
||||||
|
specific_values
|
||||||
|
if specific_values
|
||||||
|
else self._scan_document_values(document_info, "city")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
values.extend(self._scan_document_values(document_info, field_key))
|
values.extend(self._scan_document_values(document_info, field_key))
|
||||||
return self._normalize_values(values)
|
return self._normalize_values(values)
|
||||||
@@ -203,8 +207,8 @@ class RiskRuleTemplateExecutor:
|
|||||||
"buyer_name": ("购买方", "抬头", "买方"),
|
"buyer_name": ("购买方", "抬头", "买方"),
|
||||||
"goods_name": ("品名", "商品", "服务名称"),
|
"goods_name": ("品名", "商品", "服务名称"),
|
||||||
"issue_date": ("日期", "开票日期", "发票日期"),
|
"issue_date": ("日期", "开票日期", "发票日期"),
|
||||||
"hotel_city": ("住宿城市", "酒店城市", "酒店地点"),
|
"hotel_city": ("住宿城市", "酒店城市", "酒店地点", "住宿", "酒店"),
|
||||||
"route_cities": ("行程", "路线", "城市"),
|
"route_cities": ("行程", "路线", "目的地", "出差城市"),
|
||||||
"city": ("城市", "地点"),
|
"city": ("城市", "地点"),
|
||||||
}
|
}
|
||||||
return any(item in label for item in label_map.get(field_key, ()))
|
return any(item in label for item in label_map.get(field_key, ()))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.db.session import get_session_factory
|
|||||||
from app.models.system_model_setting import SystemModelSetting
|
from app.models.system_model_setting import SystemModelSetting
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.system_setting_secret import SystemSettingSecret
|
from app.models.system_setting_secret import SystemSettingSecret
|
||||||
|
from app.models.hermes_config import HermesTaskConfig
|
||||||
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
|
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
|
||||||
from app.schemas.settings import SettingsRead, SettingsWrite
|
from app.schemas.settings import SettingsRead, SettingsWrite
|
||||||
from app.services.hermes_sync import (
|
from app.services.hermes_sync import (
|
||||||
@@ -183,6 +184,8 @@ class SettingsService:
|
|||||||
capability=config.capability,
|
capability=config.capability,
|
||||||
priority=config.priority,
|
priority=config.priority,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
|
||||||
|
|
||||||
api_key_encrypted=str(getattr(secrets_row, config.legacy_secret_attr, "") or ""),
|
api_key_encrypted=str(getattr(secrets_row, config.legacy_secret_attr, "") or ""),
|
||||||
)
|
)
|
||||||
self.db.add(model_row)
|
self.db.add(model_row)
|
||||||
@@ -199,12 +202,12 @@ class SettingsService:
|
|||||||
def get_settings_snapshot(self) -> SettingsRead:
|
def get_settings_snapshot(self) -> SettingsRead:
|
||||||
settings_row, secrets_row = self.ensure_settings_ready()
|
settings_row, secrets_row = self.ensure_settings_ready()
|
||||||
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
||||||
return self._serialize(settings_row, secrets_row, model_rows)
|
hermes_form = self._build_hermes_form_snapshot()
|
||||||
|
return self._serialize(settings_row, secrets_row, model_rows, hermes_form)
|
||||||
|
|
||||||
def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
|
def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
|
||||||
settings_row, secrets_row = self.ensure_settings_ready()
|
settings_row, secrets_row = self.ensure_settings_ready()
|
||||||
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
|
||||||
|
|
||||||
if payload.adminForm.newPassword:
|
if payload.adminForm.newPassword:
|
||||||
if len(payload.adminForm.newPassword) < 5:
|
if len(payload.adminForm.newPassword) < 5:
|
||||||
raise ValueError("管理员密码至少需要 5 位。")
|
raise ValueError("管理员密码至少需要 5 位。")
|
||||||
@@ -309,6 +312,8 @@ class SettingsService:
|
|||||||
|
|
||||||
hermes_snapshot = capture_hermes_config_snapshot()
|
hermes_snapshot = capture_hermes_config_snapshot()
|
||||||
|
|
||||||
|
self._save_hermes_form_snapshot(payload.hermesForm)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sync_hermes_model_settings(
|
sync_hermes_model_settings(
|
||||||
primary_route=self._build_hermes_model_route(model_rows["main"]),
|
primary_route=self._build_hermes_model_route(model_rows["main"]),
|
||||||
@@ -642,11 +647,75 @@ class SettingsService:
|
|||||||
|
|
||||||
return should_commit
|
return should_commit
|
||||||
|
|
||||||
|
def _build_hermes_form_snapshot(self) -> dict:
|
||||||
|
configs = self.db.query(HermesTaskConfig).all()
|
||||||
|
capabilities = {}
|
||||||
|
schedules = {}
|
||||||
|
master_enabled = True # 这里假设只要有一个开启,主开关就是开启的(为简单起见)
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
task_type = config.task_type
|
||||||
|
capabilities[task_type] = config.is_enabled
|
||||||
|
|
||||||
|
# 简化解析 cron_expression 到 time (假设 cron 为 "0 9 * * 1" 这种形式)
|
||||||
|
time_str = "00:00"
|
||||||
|
if config.cron_expression:
|
||||||
|
parts = config.cron_expression.split(" ")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
minute, hour = parts[0], parts[1]
|
||||||
|
try:
|
||||||
|
time_str = f"{int(hour):02d}:{int(minute):02d}"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
schedules[task_type] = {
|
||||||
|
"enabled": config.is_enabled,
|
||||||
|
"time": time_str
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"masterEnabled": master_enabled,
|
||||||
|
"notifyOnFailure": True,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"schedules": schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
def _save_hermes_form_snapshot(self, hermes_form: dict) -> None:
|
||||||
|
if not hermes_form:
|
||||||
|
return
|
||||||
|
|
||||||
|
schedules = hermes_form.get("schedules", {})
|
||||||
|
capabilities = hermes_form.get("capabilities", {})
|
||||||
|
master_enabled = hermes_form.get("masterEnabled", True)
|
||||||
|
|
||||||
|
for task_type, schedule in schedules.items():
|
||||||
|
config = self.db.query(HermesTaskConfig).filter_by(task_type=task_type).first()
|
||||||
|
if not config:
|
||||||
|
config = HermesTaskConfig(task_type=task_type)
|
||||||
|
self.db.add(config)
|
||||||
|
|
||||||
|
task_enabled = schedule.get("enabled", False) and capabilities.get(task_type, False) and master_enabled
|
||||||
|
config.is_enabled = task_enabled
|
||||||
|
|
||||||
|
# 从 time 构建简单的 cron expression
|
||||||
|
time_str = schedule.get("time", "00:00")
|
||||||
|
parts = time_str.split(":")
|
||||||
|
if len(parts) == 2:
|
||||||
|
# 简单映射:把时分放进去,后面保留为 * * * (或者保留旧的后半段)
|
||||||
|
# 这里偷个懒,风险扫描每天跑,周报每周一跑
|
||||||
|
if task_type == "global_risk_scan":
|
||||||
|
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
||||||
|
elif task_type == "weekly_expense_report":
|
||||||
|
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * 1"
|
||||||
|
else:
|
||||||
|
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize(
|
def _serialize(
|
||||||
settings_row: SystemSetting,
|
settings_row: SystemSetting,
|
||||||
secrets_row: SystemSettingSecret,
|
secrets_row: SystemSettingSecret,
|
||||||
model_rows: dict[str, SystemModelSetting],
|
model_rows: dict[str, SystemModelSetting],
|
||||||
|
hermes_form: dict,
|
||||||
) -> SettingsRead:
|
) -> SettingsRead:
|
||||||
main_model = model_rows["main"]
|
main_model = model_rows["main"]
|
||||||
backup_model = model_rows["backup"]
|
backup_model = model_rows["backup"]
|
||||||
@@ -676,12 +745,9 @@ class SettingsService:
|
|||||||
sessionForm={
|
sessionForm={
|
||||||
"conversationRetentionDays": settings_row.conversation_retention_days,
|
"conversationRetentionDays": settings_row.conversation_retention_days,
|
||||||
},
|
},
|
||||||
|
hermesForm=hermes_form,
|
||||||
llmForm={
|
llmForm={
|
||||||
"mainProvider": main_model.provider,
|
"mainProvider": main_model.provider,
|
||||||
"mainModel": main_model.model_name,
|
|
||||||
"mainEndpoint": main_model.endpoint,
|
|
||||||
"mainApiKey": "",
|
|
||||||
"mainApiKeyConfigured": bool(main_model.api_key_encrypted),
|
|
||||||
"backupProvider": backup_model.provider,
|
"backupProvider": backup_model.provider,
|
||||||
"backupModel": backup_model.model_name,
|
"backupModel": backup_model.model_name,
|
||||||
"backupEndpoint": backup_model.endpoint,
|
"backupEndpoint": backup_model.endpoint,
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ EXPENSE_SCENE_SELECTION_OPTIONS = (
|
|||||||
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
||||||
)
|
)
|
||||||
|
|
||||||
KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3
|
KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 20
|
||||||
KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5
|
KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 30
|
||||||
KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS
|
KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS
|
||||||
|
|
||||||
EXPENSE_STATUS_LABELS = {
|
EXPENSE_STATUS_LABELS = {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
|||||||
*,
|
*,
|
||||||
citations: list[UserAgentCitation],
|
citations: list[UserAgentCitation],
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
return None
|
||||||
if payload.ontology.scenario != "knowledge":
|
if payload.ontology.scenario != "knowledge":
|
||||||
return None
|
return None
|
||||||
if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search":
|
if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search":
|
||||||
@@ -583,20 +584,23 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
|||||||
evidence_lines: list[str] = []
|
evidence_lines: list[str] = []
|
||||||
for item in evidence_items[:3]:
|
for item in evidence_items[:3]:
|
||||||
heading = str(item.get("heading") or "").strip()
|
heading = str(item.get("heading") or "").strip()
|
||||||
heading_text = f" > {heading}" if heading else ""
|
if "表格行级检索线索" in heading:
|
||||||
|
heading = heading.replace("表格行级检索线索", "").strip(" >")
|
||||||
|
heading_text = f"({heading})" if heading else ""
|
||||||
|
item_title = item.get("title") or title
|
||||||
if str(item.get("kind") or "") == "table":
|
if str(item.get("kind") or "") == "table":
|
||||||
preview = self._extract_relevant_table_preview(
|
preview = self._extract_relevant_table_preview(
|
||||||
str(item.get("content") or ""),
|
str(item.get("content") or ""),
|
||||||
self._extract_knowledge_query_terms(self._resolve_knowledge_question(payload)),
|
self._extract_knowledge_query_terms(self._resolve_knowledge_question(payload)),
|
||||||
)
|
)
|
||||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{preview}")
|
evidence_lines.append(f"- **《{item_title}》** {heading_text}\n{preview}")
|
||||||
continue
|
continue
|
||||||
rendered = self._render_knowledge_evidence_text(item)
|
rendered = self._render_knowledge_evidence_text(item)
|
||||||
if rendered:
|
if rendered:
|
||||||
if "\n" in rendered:
|
if "\n" in rendered:
|
||||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{rendered}")
|
evidence_lines.append(f"- **《{item_title}》** {heading_text}\n{rendered}")
|
||||||
else:
|
else:
|
||||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{rendered}")
|
evidence_lines.append(f"- **《{item_title}》** {heading_text}\n {rendered}")
|
||||||
|
|
||||||
if not evidence_lines:
|
if not evidence_lines:
|
||||||
for item in hits[:2]:
|
for item in hits[:2]:
|
||||||
@@ -607,21 +611,22 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
|||||||
)
|
)
|
||||||
if not excerpt:
|
if not excerpt:
|
||||||
continue
|
continue
|
||||||
evidence_lines.append(f"- 《{item_title}》:{excerpt}")
|
evidence_lines.append(f"- **《{item_title}》**:{excerpt}")
|
||||||
|
|
||||||
if not evidence_lines:
|
if not evidence_lines:
|
||||||
return (
|
return (
|
||||||
f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据,"
|
f"{prefix}当前《{title}》里可用于回答的关键条款还不够明确。"
|
||||||
"但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败,"
|
"请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"
|
||||||
"建议先检查主对话模型的连通性。"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
f"{prefix}我已经命中与你这次问题最相关的制度依据,但答案整理阶段本轮没有及时返回。",
|
f"{prefix}我先根据当前制度依据给出可以确认的部分。",
|
||||||
"先给你当前最直接的依据:",
|
"",
|
||||||
|
"**依据**:",
|
||||||
*evidence_lines,
|
*evidence_lines,
|
||||||
"如果你希望我继续把这些依据整理成更完整的结论、步骤或对比说明,可以继续缩小问题范围后再问一次。",
|
"",
|
||||||
|
"**说明**:以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。",
|
||||||
]
|
]
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import re
|
|||||||
|
|
||||||
KNOWLEDGE_DIRECT_ANSWER_HINTS = (
|
KNOWLEDGE_DIRECT_ANSWER_HINTS = (
|
||||||
"是什么",
|
"是什么",
|
||||||
|
"介绍",
|
||||||
|
"说明",
|
||||||
|
"概述",
|
||||||
"标准",
|
"标准",
|
||||||
"限额",
|
"限额",
|
||||||
"流程",
|
"流程",
|
||||||
@@ -45,7 +48,7 @@ MAX_KNOWLEDGE_QUERY_TERMS = 12
|
|||||||
MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4
|
MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4
|
||||||
MAX_KNOWLEDGE_MODEL_HITS = 5
|
MAX_KNOWLEDGE_MODEL_HITS = 5
|
||||||
KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile(
|
KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile(
|
||||||
r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$"
|
r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[部分章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$"
|
||||||
)
|
)
|
||||||
KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$")
|
KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$")
|
||||||
KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile(
|
KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile(
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ from app.services.user_agent_knowledge_constants import (
|
|||||||
|
|
||||||
|
|
||||||
class UserAgentKnowledgeHelpersMixin:
|
class UserAgentKnowledgeHelpersMixin:
|
||||||
|
GENERIC_KNOWLEDGE_TITLE_TERMS = {"远光软件", "股份有限", "有限公司"}
|
||||||
|
KNOWLEDGE_QUERY_ANCHOR_TERMS = (
|
||||||
|
"财务基础知识手册",
|
||||||
|
"基础知识手册",
|
||||||
|
"会计科目",
|
||||||
|
"常用会计科目",
|
||||||
|
"财务报表",
|
||||||
|
"主要税种",
|
||||||
|
"税种",
|
||||||
|
"标准",
|
||||||
|
"清单",
|
||||||
|
"明细",
|
||||||
|
"流程",
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _select_knowledge_model_hits(
|
def _select_knowledge_model_hits(
|
||||||
@@ -26,7 +40,7 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
item
|
item
|
||||||
for item in list(tool_payload.get("hits") or [])
|
for item in list(tool_payload.get("hits") or [])
|
||||||
if isinstance(item, dict)
|
if isinstance(item, dict)
|
||||||
][: max(MAX_KNOWLEDGE_MODEL_HITS + 1, 6)]
|
][: max(MAX_KNOWLEDGE_MODEL_HITS + 3, 8)]
|
||||||
if not raw_hits:
|
if not raw_hits:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -64,7 +78,16 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
matched_terms = [term for term in query_terms if term in haystack]
|
matched_terms = [term for term in query_terms if term in haystack]
|
||||||
score = max(1, 48 - rank_index * 4)
|
score = max(1, 48 - rank_index * 4)
|
||||||
score += len(matched_terms) * 10
|
score += len(matched_terms) * 10
|
||||||
|
score += sum(max(0, len(term) - 4) * 8 for term in matched_terms)
|
||||||
score += sum(1 for term in matched_terms if term in title) * 8
|
score += sum(1 for term in matched_terms if term in title) * 8
|
||||||
|
score += sum(max(0, len(term) - 4) * 6 for term in matched_terms if term in title)
|
||||||
|
score += sum(
|
||||||
|
(len(term) - 3) * 10
|
||||||
|
for term in matched_terms
|
||||||
|
if len(term) >= 4
|
||||||
|
and term in title
|
||||||
|
and term not in UserAgentKnowledgeHelpersMixin.GENERIC_KNOWLEDGE_TITLE_TERMS
|
||||||
|
)
|
||||||
|
|
||||||
leading_marker = UserAgentKnowledgeHelpersMixin._leading_knowledge_appendix_marker(content)
|
leading_marker = UserAgentKnowledgeHelpersMixin._leading_knowledge_appendix_marker(content)
|
||||||
if leading_marker == "# 章节导航":
|
if leading_marker == "# 章节导航":
|
||||||
@@ -149,6 +172,40 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _knowledge_list_marker_sort_key(content: str) -> int:
|
||||||
|
normalized = str(content or "").strip()
|
||||||
|
match = re.match(r"^[((]([一二三四五六七八九十百零0-9]+)[))]", normalized)
|
||||||
|
if not match:
|
||||||
|
return 999
|
||||||
|
marker = match.group(1)
|
||||||
|
if marker.isdigit():
|
||||||
|
return int(marker)
|
||||||
|
values = {
|
||||||
|
"零": 0,
|
||||||
|
"一": 1,
|
||||||
|
"二": 2,
|
||||||
|
"三": 3,
|
||||||
|
"四": 4,
|
||||||
|
"五": 5,
|
||||||
|
"六": 6,
|
||||||
|
"七": 7,
|
||||||
|
"八": 8,
|
||||||
|
"九": 9,
|
||||||
|
"十": 10,
|
||||||
|
}
|
||||||
|
if marker in values:
|
||||||
|
return values[marker]
|
||||||
|
if marker.startswith("十") and len(marker) == 2:
|
||||||
|
return 10 + values.get(marker[1], 0)
|
||||||
|
if marker.endswith("十") and len(marker) == 2:
|
||||||
|
return values.get(marker[0], 0) * 10
|
||||||
|
if "十" in marker:
|
||||||
|
left, right = marker.split("十", 1)
|
||||||
|
return values.get(left, 1) * 10 + values.get(right, 0)
|
||||||
|
return 999
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_knowledge_heading_label(heading: str) -> str:
|
def _format_knowledge_heading_label(heading: str) -> str:
|
||||||
@@ -156,6 +213,169 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
return " / ".join(parts)
|
return " / ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_inline_numbered_knowledge_items(content: str) -> bool:
|
||||||
|
return len(
|
||||||
|
re.findall(
|
||||||
|
r"[((][一二三四五六七八九十百零0-9]+[))]",
|
||||||
|
str(content or ""),
|
||||||
|
)
|
||||||
|
) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_inline_numbered_knowledge_items(content: str) -> list[str]:
|
||||||
|
normalized = str(content or "").strip()
|
||||||
|
if not UserAgentKnowledgeHelpersMixin._has_inline_numbered_knowledge_items(normalized):
|
||||||
|
return [normalized] if normalized else []
|
||||||
|
|
||||||
|
marker_pattern = r"[((][一二三四五六七八九十百零0-9]+[))]"
|
||||||
|
first_marker = re.search(marker_pattern, normalized)
|
||||||
|
if first_marker is None:
|
||||||
|
return [normalized] if normalized else []
|
||||||
|
|
||||||
|
prefix = normalized[: first_marker.start()].strip(" ::")
|
||||||
|
tail = normalized[first_marker.start() :].strip()
|
||||||
|
item_pattern = (
|
||||||
|
r"([((][一二三四五六七八九十百零0-9]+[))]\s*.*?"
|
||||||
|
r"(?=\s*[((][一二三四五六七八九十百零0-9]+[))]|\s*$))"
|
||||||
|
)
|
||||||
|
items = [item.strip() for item in re.findall(item_pattern, tail) if item.strip()]
|
||||||
|
if prefix:
|
||||||
|
return [prefix, *items]
|
||||||
|
return items or [normalized]
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _focus_knowledge_segment_content(content: str, query_terms: list[str]) -> str:
|
||||||
|
normalized = re.sub(r"\s+", " ", str(content or "").strip())
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
anchor_terms = sorted(
|
||||||
|
{
|
||||||
|
str(term or "").strip()
|
||||||
|
for term in query_terms
|
||||||
|
if len(str(term or "").strip()) >= 3
|
||||||
|
},
|
||||||
|
key=len,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
anchor_index = -1
|
||||||
|
for term in anchor_terms:
|
||||||
|
anchor_index = normalized.lower().find(term.lower())
|
||||||
|
if anchor_index >= 0:
|
||||||
|
break
|
||||||
|
if anchor_index < 0:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
prefix_window = normalized[max(0, anchor_index - 40) : anchor_index]
|
||||||
|
marker_match = None
|
||||||
|
for match in re.finditer(
|
||||||
|
r"(?:第[一二三四五六七八九十百零0-9]+[部分章节条]|[一二三四五六七八九十]+、|[((][一二三四五六七八九十百零0-9]+[))])",
|
||||||
|
prefix_window,
|
||||||
|
):
|
||||||
|
marker_match = match
|
||||||
|
start = anchor_index
|
||||||
|
if marker_match is not None:
|
||||||
|
start = max(0, anchor_index - len(prefix_window) + marker_match.start())
|
||||||
|
|
||||||
|
return normalized[start : start + 700].strip()
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_markdown_table_cells(line: str) -> list[str]:
|
||||||
|
stripped = str(line or "").strip()
|
||||||
|
if stripped.startswith("|"):
|
||||||
|
stripped = stripped[1:]
|
||||||
|
if stripped.endswith("|"):
|
||||||
|
stripped = stripped[:-1]
|
||||||
|
return [
|
||||||
|
re.sub(r"\s+", " ", cell.replace("**", "").strip())
|
||||||
|
for cell in stripped.split("|")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _summarize_knowledge_table_preview(cls, preview: str) -> str:
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for line in str(preview or "").splitlines():
|
||||||
|
if line.count("|") < 2:
|
||||||
|
continue
|
||||||
|
cells = cls._split_markdown_table_cells(line)
|
||||||
|
if not cells or all(re.fullmatch(r":?-{2,}:?", cell.replace(" ", "")) for cell in cells):
|
||||||
|
continue
|
||||||
|
rows.append(cells)
|
||||||
|
|
||||||
|
if len(rows) < 2:
|
||||||
|
return "可直接参考的标准表如下。"
|
||||||
|
|
||||||
|
header = rows[0]
|
||||||
|
data_rows = [row for row in rows[1:] if len(row) == len(header)]
|
||||||
|
if len(data_rows) == 1 and len(header) >= 2:
|
||||||
|
row = data_rows[0]
|
||||||
|
subject = row[0] or "该项目"
|
||||||
|
pairs = [
|
||||||
|
f"{label}:{value}"
|
||||||
|
for label, value in zip(header[1:], row[1:])
|
||||||
|
if label and value and value not in {"-", "—"}
|
||||||
|
]
|
||||||
|
if pairs:
|
||||||
|
return f"{subject}的标准为:{';'.join(pairs)}。"
|
||||||
|
|
||||||
|
return "相关标准项如下,请按表头和行内容对应使用。"
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_knowledge_lines_conclusion(
|
||||||
|
self,
|
||||||
|
lines: list[str],
|
||||||
|
*,
|
||||||
|
heading: str = "",
|
||||||
|
) -> str:
|
||||||
|
clean_lines = [
|
||||||
|
self._clean_knowledge_segment_text(line)
|
||||||
|
for line in lines
|
||||||
|
if self._clean_knowledge_segment_text(line)
|
||||||
|
]
|
||||||
|
if not clean_lines:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
clean_heading = str(heading or "").strip()
|
||||||
|
if not clean_heading and clean_lines and ":" not in clean_lines[0] and ":" not in clean_lines[0]:
|
||||||
|
clean_heading = clean_lines[0]
|
||||||
|
clean_heading = re.sub(
|
||||||
|
r"^[一二三四五六七八九十百零0-9]+、\s*",
|
||||||
|
"",
|
||||||
|
clean_heading,
|
||||||
|
)
|
||||||
|
item_labels: list[str] = []
|
||||||
|
for line in clean_lines:
|
||||||
|
if ":" not in line and ":" not in line:
|
||||||
|
continue
|
||||||
|
label = re.split(r"[::]", line, maxsplit=1)[0].strip()
|
||||||
|
if 1 <= len(label) <= 24:
|
||||||
|
item_labels.append(label)
|
||||||
|
|
||||||
|
if clean_heading and len(item_labels) >= 2:
|
||||||
|
return f"{clean_heading}包括:{'、'.join(item_labels[:6])}。"
|
||||||
|
if item_labels:
|
||||||
|
return f"{item_labels[0]}:{clean_lines[0].split(':', 1)[-1].strip()}"
|
||||||
|
return clean_lines[0]
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _knowledge_lines_have_multiple_labeled_items(lines: list[str]) -> bool:
|
||||||
|
labeled_count = 0
|
||||||
|
for line in lines:
|
||||||
|
normalized = str(line or "").strip()
|
||||||
|
if ":" not in normalized and ":" not in normalized:
|
||||||
|
continue
|
||||||
|
label = re.split(r"[::]", normalized, maxsplit=1)[0].strip()
|
||||||
|
if 1 <= len(label) <= 24:
|
||||||
|
labeled_count += 1
|
||||||
|
return labeled_count >= 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _score_knowledge_evidence_candidate(
|
def _score_knowledge_evidence_candidate(
|
||||||
self,
|
self,
|
||||||
@@ -169,10 +389,14 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
|
|
||||||
matched_terms = [term for term in query_terms if term in haystack]
|
matched_terms = [term for term in query_terms if term in haystack]
|
||||||
score = len(matched_terms) * 10
|
score = len(matched_terms) * 10
|
||||||
|
score += sum(max(0, len(term) - 4) * 8 for term in matched_terms)
|
||||||
score += sum(1 for term in matched_terms if term in heading) * 6
|
score += sum(1 for term in matched_terms if term in heading) * 6
|
||||||
|
score += sum(max(0, len(term) - 4) * 6 for term in matched_terms if term in heading)
|
||||||
|
|
||||||
if kind == "table":
|
if kind == "table":
|
||||||
score += 10
|
score += 10
|
||||||
|
if content.count("\n") < 2:
|
||||||
|
score -= 24
|
||||||
elif kind in {"kv", "clause", "list"}:
|
elif kind in {"kv", "clause", "list"}:
|
||||||
score += 8
|
score += 8
|
||||||
elif kind == "paragraph":
|
elif kind == "paragraph":
|
||||||
@@ -220,6 +444,30 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
remember(item)
|
remember(item)
|
||||||
|
|
||||||
for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_question):
|
for block in re.findall(r"[\u4e00-\u9fff]{2,20}", normalized_question):
|
||||||
|
remember(block)
|
||||||
|
if len(terms) >= MAX_KNOWLEDGE_QUERY_TERMS:
|
||||||
|
return terms
|
||||||
|
for marker in ("标准", "金额", "限额", "额度"):
|
||||||
|
marker_index = block.find(marker)
|
||||||
|
if marker_index <= 0:
|
||||||
|
continue
|
||||||
|
subject = block[:marker_index]
|
||||||
|
for width in (6, 4, 3, 2):
|
||||||
|
remember(subject[-width:])
|
||||||
|
for anchor in UserAgentKnowledgeHelpersMixin.KNOWLEDGE_QUERY_ANCHOR_TERMS:
|
||||||
|
if anchor in block:
|
||||||
|
remember(anchor)
|
||||||
|
tail = block[-14:]
|
||||||
|
for size in (8, 7, 6, 5, 4):
|
||||||
|
for start in range(0, len(tail) - size + 1):
|
||||||
|
piece = tail[start : start + size]
|
||||||
|
if any(
|
||||||
|
anchor in piece
|
||||||
|
for anchor in UserAgentKnowledgeHelpersMixin.KNOWLEDGE_QUERY_ANCHOR_TERMS
|
||||||
|
):
|
||||||
|
remember(piece)
|
||||||
|
if len(terms) >= MAX_KNOWLEDGE_QUERY_TERMS:
|
||||||
|
return terms
|
||||||
if len(block) <= 4:
|
if len(block) <= 4:
|
||||||
remember(block)
|
remember(block)
|
||||||
continue
|
continue
|
||||||
@@ -276,7 +524,14 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_relevant_table_preview(content: str, query_terms: list[str]) -> str:
|
def _extract_relevant_table_preview(
|
||||||
|
content: str,
|
||||||
|
query_terms: list[str],
|
||||||
|
*,
|
||||||
|
preferred_terms: list[str] | None = None,
|
||||||
|
max_rows: int = 3,
|
||||||
|
fallback_rows: int = 2,
|
||||||
|
) -> str:
|
||||||
lines = [line.strip() for line in str(content or "").splitlines() if line.strip()]
|
lines = [line.strip() for line in str(content or "").splitlines() if line.strip()]
|
||||||
if len(lines) <= 3:
|
if len(lines) <= 3:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@@ -285,12 +540,39 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
divider = lines[1] if len(lines) > 1 else ""
|
divider = lines[1] if len(lines) > 1 else ""
|
||||||
body = lines[2:] if divider.count("|") >= 2 else lines[1:]
|
body = lines[2:] if divider.count("|") >= 2 else lines[1:]
|
||||||
|
|
||||||
|
preferred = [
|
||||||
|
str(term or "").strip().lower()
|
||||||
|
for term in list(preferred_terms or [])
|
||||||
|
if str(term or "").strip()
|
||||||
|
]
|
||||||
|
base_terms = preferred + [
|
||||||
|
str(term or "").strip().lower()
|
||||||
|
for term in query_terms
|
||||||
|
if str(term or "").strip().lower() not in preferred
|
||||||
|
]
|
||||||
|
derived_terms: list[str] = []
|
||||||
|
for term in base_terms:
|
||||||
|
for marker in ("标准", "金额", "限额", "额度", "是多少"):
|
||||||
|
marker_index = term.find(marker)
|
||||||
|
if marker_index <= 0:
|
||||||
|
continue
|
||||||
|
subject = term[:marker_index].strip()
|
||||||
|
if len(subject) < 2:
|
||||||
|
continue
|
||||||
|
for width in (6, 4, 3, 2):
|
||||||
|
derived_terms.append(subject[-width:])
|
||||||
|
|
||||||
|
search_terms: list[str] = []
|
||||||
|
for term in [*preferred, *derived_terms, *base_terms]:
|
||||||
|
if term and term not in search_terms:
|
||||||
|
search_terms.append(term)
|
||||||
|
|
||||||
matched_rows = [
|
matched_rows = [
|
||||||
row
|
row
|
||||||
for row in body
|
for row in body
|
||||||
if any(term in row.lower() for term in query_terms)
|
if any(term in row.lower() for term in search_terms)
|
||||||
]
|
]
|
||||||
selected_rows = matched_rows[:3] or body[:2]
|
selected_rows = matched_rows[:max_rows] or body[:fallback_rows]
|
||||||
preview_lines = [header]
|
preview_lines = [header]
|
||||||
if divider:
|
if divider:
|
||||||
preview_lines.append(divider)
|
preview_lines.append(divider)
|
||||||
@@ -298,6 +580,18 @@ class UserAgentKnowledgeHelpersMixin:
|
|||||||
return "\n".join(preview_lines).strip()
|
return "\n".join(preview_lines).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _question_requests_broad_knowledge_table(question: str) -> bool:
|
||||||
|
normalized = str(question or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
broad_hints = ("有哪些", "是什么", "介绍", "说明", "列表", "清单", "全部", "完整")
|
||||||
|
table_subject_hints = ("科目", "目录", "清单", "列表", "表", "明细")
|
||||||
|
return any(hint in normalized for hint in broad_hints) and any(
|
||||||
|
hint in normalized for hint in table_subject_hints
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _question_requires_explicit_condition(question: str) -> bool:
|
def _question_requires_explicit_condition(question: str) -> bool:
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ class UserAgentResponseMixin:
|
|||||||
"draft_payload": draft_payload.model_dump(mode="json") if draft_payload is not None else None,
|
"draft_payload": draft_payload.model_dump(mode="json") if draft_payload is not None else None,
|
||||||
"selected_capability_codes": payload.selected_capability_codes,
|
"selected_capability_codes": payload.selected_capability_codes,
|
||||||
"requires_confirmation": payload.requires_confirmation,
|
"requires_confirmation": payload.requires_confirmation,
|
||||||
"fallback_answer": fallback_answer,
|
|
||||||
}
|
}
|
||||||
if payload.ontology.scenario == "knowledge":
|
if payload.ontology.scenario == "knowledge":
|
||||||
facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks(
|
facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks(
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 3,
|
||||||
"ingest_status_updated_at": "2026-05-22T09:22:52.110824+00:00",
|
"ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T09:22:52.110824+00:00",
|
"ingest_completed_at": "2026-05-23T14:30:33.605531+00:00",
|
||||||
"ingest_document_name": "远光软件财务基础知识手册.docx",
|
"ingest_document_name": "远光软件财务基础知识手册.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -82,23 +82,6 @@
|
|||||||
"processing_end_time": 1779441745
|
"processing_end_time": 1779441745
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"b0277cd76034437997fbf5219662725a": {
|
|
||||||
"status": "processed",
|
|
||||||
"chunks_count": 1,
|
|
||||||
"chunks_list": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"content_summary": "远光软件股份有限公司\n财务基础知识手册\n第一部分 会计基础知识\n一、会计要素\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n会计恒等式:资产 = 负债 + 所有者权益\n二、常用会计科目\n科目类别\n科目名称\n说明\n资产类\n库存现金\n公司持有的现金\n资产类\n银行存款\n存放在银行的资金\n资产类\n应收账款\n因销售商品或提供劳务应收的款项\n资产类\n固定资产\n使用年限超过一年的有形资产\n负债类\n应付账款\n因购买商品或接受劳务应付的款项\n负债类\n应交税费\n应缴纳的各种税费\n负债类\n应付职工薪酬\n...",
|
|
||||||
"content_length": 1082,
|
|
||||||
"created_at": "2026-05-22T09:22:31.538281+00:00",
|
|
||||||
"updated_at": "2026-05-22T09:22:52.110824+00:00",
|
|
||||||
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
|
||||||
"track_id": "insert_20260522_092231_e1b9d415",
|
|
||||||
"metadata": {
|
|
||||||
"processing_start_time": 1779441751,
|
|
||||||
"processing_end_time": 1779441772
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"23f56f159a3e4bc3b2338056544120dd": {
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
"status": "processed",
|
"status": "processed",
|
||||||
"chunks_count": 1,
|
"chunks_count": 1,
|
||||||
@@ -688,5 +671,23 @@
|
|||||||
"processing_start_time": 1779467725,
|
"processing_start_time": 1779467725,
|
||||||
"processing_end_time": 1779467727
|
"processing_end_time": 1779467727
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"status": "processed",
|
||||||
|
"chunks_count": 2,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b",
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
|
],
|
||||||
|
"content_summary": "远光软件股份有限公司 财务基础知识手册\n\n第一部分 会计基础知识\n\n一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n\n会计恒等式:资产 = 负债 + 所有者权益\n\n二、常用会计科目\n\n| 科目类别 | 科目名称 | 说明 |\n| --- | --- | --- |\n| 资产类 | 库存现金 | 公司持有的现金 |\n| 资产类 | 银行存款 | 存放在银行的资金 |\n| 资产类 | 应收账款 | 因销售商品或提供劳务应收的款项 |\n| 资产类 | 固定资产 | 使用年限超...",
|
||||||
|
"content_length": 1641,
|
||||||
|
"created_at": "2026-05-23T14:29:37.233751+00:00",
|
||||||
|
"updated_at": "2026-05-23T14:30:33.605531+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
||||||
|
"track_id": "insert_20260523_142937_5cd25327",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779546577,
|
||||||
|
"processing_end_time": 1779546633
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263",
|
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263",
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
"chunk-e726f44fb0287c5192cf61b350f18abb",
|
"chunk-e726f44fb0287c5192cf61b350f18abb",
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1",
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1",
|
||||||
"chunk-2c8384b328272063de4dac306a52d21e",
|
"chunk-2c8384b328272063de4dac306a52d21e",
|
||||||
"chunk-6c549250b13b7728acb37eb6082bc178",
|
"chunk-6c549250b13b7728acb37eb6082bc178",
|
||||||
@@ -44,8 +43,9 @@
|
|||||||
"chunk-6175768b05adf2e7229c16f13ee7cffd",
|
"chunk-6175768b05adf2e7229c16f13ee7cffd",
|
||||||
"chunk-cfac1ddf5942f8fe2d5a296380818faf"
|
"chunk-cfac1ddf5942f8fe2d5a296380818faf"
|
||||||
],
|
],
|
||||||
"count": 42,
|
"count": 41,
|
||||||
"update_time": 1779467739,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "远光软件股份有限公司"
|
"_id": "远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"第一章总则": {
|
"第一章总则": {
|
||||||
@@ -3504,31 +3504,31 @@
|
|||||||
},
|
},
|
||||||
"库存现金": {
|
"库存现金": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "库存现金"
|
"_id": "库存现金"
|
||||||
},
|
},
|
||||||
"银行存款": {
|
"银行存款": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "银行存款"
|
"_id": "银行存款"
|
||||||
},
|
},
|
||||||
"应收账款": {
|
"应收账款": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-6f1d6991d45799bc8ff24afaed39244d",
|
"chunk-6f1d6991d45799bc8ff24afaed39244d",
|
||||||
"chunk-af56151a803634f02e294f2d692fc1f0"
|
"chunk-af56151a803634f02e294f2d692fc1f0",
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
],
|
],
|
||||||
"count": 4,
|
"count": 4,
|
||||||
"update_time": 1779467128,
|
"update_time": 1779546632,
|
||||||
"_id": "应收账款"
|
"_id": "应收账款"
|
||||||
},
|
},
|
||||||
"其他应收款": {
|
"其他应收款": {
|
||||||
@@ -3552,12 +3552,12 @@
|
|||||||
"固定资产": {
|
"固定资产": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-4e95fc3e38b2bf65fcb3f6f0664fd9df",
|
"chunk-4e95fc3e38b2bf65fcb3f6f0664fd9df",
|
||||||
"chunk-4287121b009a169fe4155526bfe413ea"
|
"chunk-4287121b009a169fe4155526bfe413ea"
|
||||||
],
|
],
|
||||||
"count": 4,
|
"count": 3,
|
||||||
"update_time": 1779465910,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "固定资产"
|
"_id": "固定资产"
|
||||||
},
|
},
|
||||||
"累计折旧": {
|
"累计折旧": {
|
||||||
@@ -3572,39 +3572,39 @@
|
|||||||
"应付账款": {
|
"应付账款": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-6f1d6991d45799bc8ff24afaed39244d",
|
"chunk-6f1d6991d45799bc8ff24afaed39244d",
|
||||||
"chunk-af56151a803634f02e294f2d692fc1f0"
|
"chunk-af56151a803634f02e294f2d692fc1f0"
|
||||||
],
|
],
|
||||||
"count": 4,
|
"count": 3,
|
||||||
"update_time": 1779467128,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "应付账款"
|
"_id": "应付账款"
|
||||||
},
|
},
|
||||||
"应交税费": {
|
"应交税费": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "应交税费"
|
"_id": "应交税费"
|
||||||
},
|
},
|
||||||
"应付职工薪酬": {
|
"应付职工薪酬": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "应付职工薪酬"
|
"_id": "应付职工薪酬"
|
||||||
},
|
},
|
||||||
"主营业务收入": {
|
"主营业务收入": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "主营业务收入"
|
"_id": "主营业务收入"
|
||||||
},
|
},
|
||||||
"主营业务成本": {
|
"主营业务成本": {
|
||||||
@@ -3618,20 +3618,20 @@
|
|||||||
},
|
},
|
||||||
"管理费用": {
|
"管理费用": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "管理费用"
|
"_id": "管理费用"
|
||||||
},
|
},
|
||||||
"销售费用": {
|
"销售费用": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779441756,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "销售费用"
|
"_id": "销售费用"
|
||||||
},
|
},
|
||||||
"财务费用": {
|
"财务费用": {
|
||||||
@@ -3667,11 +3667,11 @@
|
|||||||
"资产类": {
|
"资产类": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
"chunk-e726f44fb0287c5192cf61b350f18abb",
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 3,
|
||||||
"create_time": 1779441742,
|
"update_time": 1779546632,
|
||||||
"update_time": 1779441742,
|
|
||||||
"_id": "资产类"
|
"_id": "资产类"
|
||||||
},
|
},
|
||||||
"负债类": {
|
"负债类": {
|
||||||
@@ -3811,192 +3811,48 @@
|
|||||||
"update_time": 1779441742,
|
"update_time": 1779441742,
|
||||||
"_id": "1221其他应收款"
|
"_id": "1221其他应收款"
|
||||||
},
|
},
|
||||||
"财务基础知识手册": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "财务基础知识手册"
|
|
||||||
},
|
|
||||||
"会计要素": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "会计要素"
|
|
||||||
},
|
|
||||||
"资产": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "资产"
|
|
||||||
},
|
|
||||||
"负债": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "负债"
|
|
||||||
},
|
|
||||||
"所有者权益": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "所有者权益"
|
|
||||||
},
|
|
||||||
"收入": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "收入"
|
|
||||||
},
|
|
||||||
"费用": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441755,
|
|
||||||
"update_time": 1779441755,
|
|
||||||
"_id": "费用"
|
|
||||||
},
|
|
||||||
"利润": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "利润"
|
|
||||||
},
|
|
||||||
"会计恒等式": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "会计恒等式"
|
|
||||||
},
|
|
||||||
"增值税": {
|
"增值税": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-2c8384b328272063de4dac306a52d21e",
|
"chunk-2c8384b328272063de4dac306a52d21e",
|
||||||
"chunk-f61c91e28e8d0f773f83e3daf161ab1c",
|
"chunk-f61c91e28e8d0f773f83e3daf161ab1c",
|
||||||
"chunk-570642e8a00db7819c2b4048ebf1b279",
|
"chunk-570642e8a00db7819c2b4048ebf1b279",
|
||||||
"chunk-89afdbbf904b60cf6494cba2638e08a8"
|
"chunk-89afdbbf904b60cf6494cba2638e08a8"
|
||||||
],
|
],
|
||||||
"count": 5,
|
|
||||||
"update_time": 1779467303,
|
|
||||||
"_id": "增值税"
|
|
||||||
},
|
|
||||||
"企业所得税": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-2c8384b328272063de4dac306a52d21e",
|
|
||||||
"chunk-f61c91e28e8d0f773f83e3daf161ab1c"
|
|
||||||
],
|
|
||||||
"count": 3,
|
|
||||||
"update_time": 1779466482,
|
|
||||||
"_id": "企业所得税"
|
|
||||||
},
|
|
||||||
"个人所得税": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-f61c91e28e8d0f773f83e3daf161ab1c",
|
|
||||||
"chunk-570642e8a00db7819c2b4048ebf1b279",
|
|
||||||
"chunk-89afdbbf904b60cf6494cba2638e08a8"
|
|
||||||
],
|
|
||||||
"count": 4,
|
"count": 4,
|
||||||
"update_time": 1779467303,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
|
"_id": "增值税"
|
||||||
|
},
|
||||||
|
"企业所得税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e",
|
||||||
|
"chunk-f61c91e28e8d0f773f83e3daf161ab1c"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
|
"_id": "企业所得税"
|
||||||
|
},
|
||||||
|
"个人所得税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-f61c91e28e8d0f773f83e3daf161ab1c",
|
||||||
|
"chunk-570642e8a00db7819c2b4048ebf1b279",
|
||||||
|
"chunk-89afdbbf904b60cf6494cba2638e08a8"
|
||||||
|
],
|
||||||
|
"count": 3,
|
||||||
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "个人所得税"
|
"_id": "个人所得税"
|
||||||
},
|
},
|
||||||
"印花税": {
|
"印花税": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
|
||||||
"chunk-f61c91e28e8d0f773f83e3daf161ab1c"
|
"chunk-f61c91e28e8d0f773f83e3daf161ab1c"
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 1,
|
||||||
"update_time": 1779466482,
|
"updated_at": 1779546575,
|
||||||
|
"update_time": 1779546575,
|
||||||
"_id": "印花税"
|
"_id": "印花税"
|
||||||
},
|
},
|
||||||
"三大财务报表": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "三大财务报表"
|
|
||||||
},
|
|
||||||
"资产负债表": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "资产负债表"
|
|
||||||
},
|
|
||||||
"利润表": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "利润表"
|
|
||||||
},
|
|
||||||
"现金流量表": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "现金流量表"
|
|
||||||
},
|
|
||||||
"会计基础知识": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441756,
|
|
||||||
"update_time": 1779441756,
|
|
||||||
"_id": "会计基础知识"
|
|
||||||
},
|
|
||||||
"税务基础知识": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441757,
|
|
||||||
"update_time": 1779441757,
|
|
||||||
"_id": "税务基础知识"
|
|
||||||
},
|
|
||||||
"财务报表解读": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441757,
|
|
||||||
"update_time": 1779441757,
|
|
||||||
"_id": "财务报表解读"
|
|
||||||
},
|
|
||||||
"财务术语解释手册": {
|
"财务术语解释手册": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
@@ -4181,10 +4037,11 @@
|
|||||||
"Corporate Income Tax": {
|
"Corporate Income Tax": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-93d2389cdb74257e90201dccbc3f6539",
|
"chunk-93d2389cdb74257e90201dccbc3f6539",
|
||||||
"chunk-bdfd18ae478b23604f1318623e8e9508"
|
"chunk-bdfd18ae478b23604f1318623e8e9508",
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
],
|
],
|
||||||
"count": 2,
|
"count": 3,
|
||||||
"update_time": 1779466433,
|
"update_time": 1779546632,
|
||||||
"_id": "Corporate Income Tax"
|
"_id": "Corporate Income Tax"
|
||||||
},
|
},
|
||||||
"Venture Capital Deduction": {
|
"Venture Capital Deduction": {
|
||||||
@@ -5896,11 +5753,11 @@
|
|||||||
},
|
},
|
||||||
"Yuan Guang Software Co., Ltd.": {
|
"Yuan Guang Software Co., Ltd.": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-96ab661ad24e0cb4c468128a58a76b6d"
|
"chunk-96ab661ad24e0cb4c468128a58a76b6d",
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 2,
|
||||||
"create_time": 1779465962,
|
"update_time": 1779546631,
|
||||||
"update_time": 1779465962,
|
|
||||||
"_id": "Yuan Guang Software Co., Ltd."
|
"_id": "Yuan Guang Software Co., Ltd."
|
||||||
},
|
},
|
||||||
"Procurement Management Methods": {
|
"Procurement Management Methods": {
|
||||||
@@ -12387,5 +12244,302 @@
|
|||||||
"create_time": 1779467740,
|
"create_time": 1779467740,
|
||||||
"update_time": 1779467740,
|
"update_time": 1779467740,
|
||||||
"_id": "第三部分发票问题"
|
"_id": "第三部分发票问题"
|
||||||
|
},
|
||||||
|
"Accounting Elements": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Accounting Elements"
|
||||||
|
},
|
||||||
|
"Assets": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Assets"
|
||||||
|
},
|
||||||
|
"Liabilities": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Liabilities"
|
||||||
|
},
|
||||||
|
"Owner's Equity": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Owner's Equity"
|
||||||
|
},
|
||||||
|
"Revenue": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Revenue"
|
||||||
|
},
|
||||||
|
"Expenses": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Expenses"
|
||||||
|
},
|
||||||
|
"Profit": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Profit"
|
||||||
|
},
|
||||||
|
"Accounting Equation": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Accounting Equation"
|
||||||
|
},
|
||||||
|
"Common Accounting Items": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Common Accounting Items"
|
||||||
|
},
|
||||||
|
"Assets Category": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Assets Category"
|
||||||
|
},
|
||||||
|
"Liabilities Category": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Liabilities Category"
|
||||||
|
},
|
||||||
|
"Income And Expense Category": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Income And Expense Category"
|
||||||
|
},
|
||||||
|
"Cash": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Cash"
|
||||||
|
},
|
||||||
|
"Bank Deposits": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Bank Deposits"
|
||||||
|
},
|
||||||
|
"Accounts Receivable": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Accounts Receivable"
|
||||||
|
},
|
||||||
|
"Fixed Assets": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Fixed Assets"
|
||||||
|
},
|
||||||
|
"Accounts Payable": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Accounts Payable"
|
||||||
|
},
|
||||||
|
"Taxes Payable": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Taxes Payable"
|
||||||
|
},
|
||||||
|
"Employee Compensation Payable": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Employee Compensation Payable"
|
||||||
|
},
|
||||||
|
"Main Business Revenue": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Main Business Revenue"
|
||||||
|
},
|
||||||
|
"Management Expenses": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Management Expenses"
|
||||||
|
},
|
||||||
|
"Sales Expenses": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Sales Expenses"
|
||||||
|
},
|
||||||
|
"Tax Fundamentals": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546631,
|
||||||
|
"update_time": 1779546631,
|
||||||
|
"_id": "Tax Fundamentals"
|
||||||
|
},
|
||||||
|
"Major Tax Types": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Major Tax Types"
|
||||||
|
},
|
||||||
|
"Value Added Tax": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Value Added Tax"
|
||||||
|
},
|
||||||
|
"Individual Income Tax": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Individual Income Tax"
|
||||||
|
},
|
||||||
|
"Stamp Duty": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Stamp Duty"
|
||||||
|
},
|
||||||
|
"Software Services Tax Rate 6%": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Software Services Tax Rate 6%"
|
||||||
|
},
|
||||||
|
"Software Product Sales Tax Rate 13%": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Software Product Sales Tax Rate 13%"
|
||||||
|
},
|
||||||
|
"Corporate Income Tax Rate 25%": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "Corporate Income Tax Rate 25%"
|
||||||
|
},
|
||||||
|
"High-Tech Enterprise Preferential Tax Rate 15%": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "High-Tech Enterprise Preferential Tax Rate 15%"
|
||||||
|
},
|
||||||
|
"所有者权益": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "所有者权益"
|
||||||
|
},
|
||||||
|
"常用会计科目": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "常用会计科目"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,13 +20,6 @@
|
|||||||
"update_time": 1779441661,
|
"update_time": 1779441661,
|
||||||
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
||||||
},
|
},
|
||||||
"b0277cd76034437997fbf5219662725a": {
|
|
||||||
"content": "远光软件股份有限公司\n财务基础知识手册\n第一部分 会计基础知识\n一、会计要素\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n会计恒等式:资产 = 负债 + 所有者权益\n二、常用会计科目\n科目类别\n科目名称\n说明\n资产类\n库存现金\n公司持有的现金\n资产类\n银行存款\n存放在银行的资金\n资产类\n应收账款\n因销售商品或提供劳务应收的款项\n资产类\n固定资产\n使用年限超过一年的有形资产\n负债类\n应付账款\n因购买商品或接受劳务应付的款项\n负债类\n应交税费\n应缴纳的各种税费\n负债类\n应付职工薪酬\n应付给职工的工资、福利等\n损益类\n主营业务收入\n主要经营业务产生的收入\n损益类\n管理费用\n为管理生产经营发生的费用\n损益类\n销售费用\n为销售产品发生的费用\n第二部分 税务基础知识\n三、主要税种介绍\n(一)增值税:公司为一般纳税人,软件服务适用6%税率,软件产品销售适用13%税率。\n(二)企业所得税:税率为25%,高新技术企业享受15%优惠税率。\n(三)个人所得税:按累进税率3%-45%,由公司代扣代缴。\n(四)印花税:对经济活动中的应税凭证征收。\n第三部分 财务报表解读\n四、三大财务报表\n(一)资产负债表:反映企业在某一特定日期的财务状况。\n(二)利润表:反映企业在一定期间的经营成果。\n(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。\n\n# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 一、会计要素\n- 二、常用会计科目\n- (四)印花税:对经济活动中的应税凭证征收。\n\n# 重点章节摘录\n\n## 一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。;会计恒等式:资产 = 负债 + 所有者权益\n\n## 二、常用会计科目\n\n科目类别;科目名称;说明\n\n## (四)印花税:对经济活动中的应税凭证征收。\n\n第三部分 财务报表解读\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 一、会计要素:会计要素包括:资产、负债、所有者权益、收入、费用和利润\n- 一、会计要素:会计恒等式:资产 = 负债 + 所有者权益\n- 二、常用会计科目:因销售商品或提供劳务应收的款项\n- 二、常用会计科目:因购买商品或接受劳务应付的款项\n- 二、常用会计科目:应缴纳的各种税费\n- 二、常用会计科目:应付职工薪酬\n- (四)印花税:对经济活动中的应税凭证征收。:第三部分 财务报表解读",
|
|
||||||
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
|
||||||
"create_time": 1779441751,
|
|
||||||
"update_time": 1779441751,
|
|
||||||
"_id": "b0277cd76034437997fbf5219662725a"
|
|
||||||
},
|
|
||||||
"23f56f159a3e4bc3b2338056544120dd": {
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
"content": "远光软件股份有限公司\n财务术语解释手册\n权责发生制\n以权利和责任的发生来决定收入和费用归属期的会计基础。即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用。\n收付实现制\n以现金收到或付出为标准来记录收入的实现和费用的发生。即凡是当期收到和支付的现金,都作为当期的收入和费用。\n固定资产折旧\n固定资产在使用过程中因磨损而逐渐转移的价值。公司采用年限平均法计提折旧。\n摊销\n将无形资产或长期待摊费用按照规定期限分期计入当期损益的过程。\n增值税进项税额\n企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣。\n增值税销项税额\n企业销售货物、提供应税劳务或应税服务收取的增值税额。\n预算\n企业对未来一定时期内经营活动的数量化计划,包括收入预算、支出预算、资本预算等。\n现金流\n企业在一定期间内现金和现金等价物流入和流出的数量。\n毛利率\n毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力。计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n净资产收益率(ROE)\n净利润占股东权益的百分比,反映股东投入资金的获利能力。计算公式:ROE = 净利润 / 股东权益 × 100%\n成本中心\n企业内部只发生成本费用而不产生收入的组织单位,用于成本核算和控制。\n利润中心\n企业内部既发生成本费用又产生收入的组织单位,用于考核盈利能力。\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 正文:以权利和责任的发生来决定收入和费用归属期的会计基础\n- 正文:即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用\n- 正文:以现金收到或付出为标准来记录收入的实现和费用的发生\n- 正文:即凡是当期收到和支付的现金,都作为当期的收入和费用\n- 正文:企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣\n- 正文:企业销售货物、提供应税劳务或应税服务收取的增值税额\n- 正文:毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力\n- 正文:计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n- 正文:净利润占股东权益的百分比,反映股东投入资金的获利能力\n- 正文:计算公式:ROE = 净利润 / 股东权益 × 100%",
|
"content": "远光软件股份有限公司\n财务术语解释手册\n权责发生制\n以权利和责任的发生来决定收入和费用归属期的会计基础。即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用。\n收付实现制\n以现金收到或付出为标准来记录收入的实现和费用的发生。即凡是当期收到和支付的现金,都作为当期的收入和费用。\n固定资产折旧\n固定资产在使用过程中因磨损而逐渐转移的价值。公司采用年限平均法计提折旧。\n摊销\n将无形资产或长期待摊费用按照规定期限分期计入当期损益的过程。\n增值税进项税额\n企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣。\n增值税销项税额\n企业销售货物、提供应税劳务或应税服务收取的增值税额。\n预算\n企业对未来一定时期内经营活动的数量化计划,包括收入预算、支出预算、资本预算等。\n现金流\n企业在一定期间内现金和现金等价物流入和流出的数量。\n毛利率\n毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力。计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n净资产收益率(ROE)\n净利润占股东权益的百分比,反映股东投入资金的获利能力。计算公式:ROE = 净利润 / 股东权益 × 100%\n成本中心\n企业内部只发生成本费用而不产生收入的组织单位,用于成本核算和控制。\n利润中心\n企业内部既发生成本费用又产生收入的组织单位,用于考核盈利能力。\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 正文:以权利和责任的发生来决定收入和费用归属期的会计基础\n- 正文:即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用\n- 正文:以现金收到或付出为标准来记录收入的实现和费用的发生\n- 正文:即凡是当期收到和支付的现金,都作为当期的收入和费用\n- 正文:企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣\n- 正文:企业销售货物、提供应税劳务或应税服务收取的增值税额\n- 正文:毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力\n- 正文:计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n- 正文:净利润占股东权益的百分比,反映股东投入资金的获利能力\n- 正文:计算公式:ROE = 净利润 / 股东权益 × 100%",
|
||||||
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx",
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx",
|
||||||
@@ -257,5 +250,12 @@
|
|||||||
"create_time": 1779467725,
|
"create_time": 1779467725,
|
||||||
"update_time": 1779467725,
|
"update_time": 1779467725,
|
||||||
"_id": "3acd9c2df63b4a438c7eab876269b25d"
|
"_id": "3acd9c2df63b4a438c7eab876269b25d"
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"content": "远光软件股份有限公司 财务基础知识手册\n\n第一部分 会计基础知识\n\n一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n\n会计恒等式:资产 = 负债 + 所有者权益\n\n二、常用会计科目\n\n| 科目类别 | 科目名称 | 说明 |\n| --- | --- | --- |\n| 资产类 | 库存现金 | 公司持有的现金 |\n| 资产类 | 银行存款 | 存放在银行的资金 |\n| 资产类 | 应收账款 | 因销售商品或提供劳务应收的款项 |\n| 资产类 | 固定资产 | 使用年限超过一年的有形资产 |\n| 负债类 | 应付账款 | 因购买商品或接受劳务应付的款项 |\n| 负债类 | 应交税费 | 应缴纳的各种税费 |\n| 负债类 | 应付职工薪酬 | 应付给职工的工资、福利等 |\n| 损益类 | 主营业务收入 | 主要经营业务产生的收入 |\n| 损益类 | 管理费用 | 为管理生产经营发生的费用 |\n| 损益类 | 销售费用 | 为销售产品发生的费用 |\n\n### 表格行级检索线索\n\n- 表格第 2 行:科目类别=资产类;科目名称=库存现金;说明=公司持有的现金\n\n- 表格第 3 行:科目类别=资产类;科目名称=银行存款;说明=存放在银行的资金\n\n- 表格第 4 行:科目类别=资产类;科目名称=应收账款;说明=因销售商品或提供劳务应收的款项\n\n- 表格第 5 行:科目类别=资产类;科目名称=固定资产;说明=使用年限超过一年的有形资产\n\n- 表格第 6 行:科目类别=负债类;科目名称=应付账款;说明=因购买商品或接受劳务应付的款项\n\n- 表格第 7 行:科目类别=负债类;科目名称=应交税费;说明=应缴纳的各种税费\n\n- 表格第 8 行:科目类别=负债类;科目名称=应付职工薪酬;说明=应付给职工的工资、福利等\n\n- 表格第 9 行:科目类别=损益类;科目名称=主营业务收入;说明=主要经营业务产生的收入\n\n- 表格第 10 行:科目类别=损益类;科目名称=管理费用;说明=为管理生产经营发生的费用\n\n- 表格第 11 行:科目类别=损益类;科目名称=销售费用;说明=为销售产品发生的费用\n\n第二部分 税务基础知识\n\n三、主要税种介绍\n\n(一)增值税:公司为一般纳税人,软件服务适用6%税率,软件产品销售适用13%税率。\n\n(二)企业所得税:税率为25%,高新技术企业享受15%优惠税率。\n\n(三)个人所得税:按累进税率3%-45%,由公司代扣代缴。\n\n(四)印花税:对经济活动中的应税凭证征收。\n\n第三部分 财务报表解读\n\n四、三大财务报表\n\n(一)资产负债表:反映企业在某一特定日期的财务状况。\n\n(二)利润表:反映企业在一定期间的经营成果。\n\n(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。\n\n# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 一、会计要素\n- 二、常用会计科目\n\n# 重点章节摘录\n\n## 一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。;会计恒等式:资产 = 负债 + 所有者权益\n\n## 二、常用会计科目\n\n| 科目类别 | 科目名称 | 说明 |;| --- | --- | --- |;| 资产类 | 库存现金 | 公司持有的现金 |\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 一、会计要素:会计要素包括:资产、负债、所有者权益、收入、费用和利润\n- 一、会计要素:会计恒等式:资产 = 负债 + 所有者权益\n- 二、常用会计科目:表格第 2 行:科目类别=资产类\n- 二、常用会计科目:表格第 3 行:科目类别=资产类\n- 二、常用会计科目:表格第 4 行:科目类别=资产类\n- 二、常用会计科目:科目名称=应收账款",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
||||||
|
"create_time": 1779546577,
|
||||||
|
"update_time": 1779546577,
|
||||||
|
"_id": "b0277cd76034437997fbf5219662725a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,45 +432,6 @@
|
|||||||
"update_time": 1779441745,
|
"update_time": 1779441745,
|
||||||
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
||||||
},
|
},
|
||||||
"b0277cd76034437997fbf5219662725a": {
|
|
||||||
"entity_names": [
|
|
||||||
"固定资产",
|
|
||||||
"财务报表解读",
|
|
||||||
"银行存款",
|
|
||||||
"收入",
|
|
||||||
"负债",
|
|
||||||
"现金流量表",
|
|
||||||
"企业所得税",
|
|
||||||
"三大财务报表",
|
|
||||||
"会计恒等式",
|
|
||||||
"库存现金",
|
|
||||||
"所有者权益",
|
|
||||||
"费用",
|
|
||||||
"财务基础知识手册",
|
|
||||||
"应付账款",
|
|
||||||
"利润表",
|
|
||||||
"会计基础知识",
|
|
||||||
"应收账款",
|
|
||||||
"应交税费",
|
|
||||||
"主营业务收入",
|
|
||||||
"资产",
|
|
||||||
"管理费用",
|
|
||||||
"税务基础知识",
|
|
||||||
"应付职工薪酬",
|
|
||||||
"销售费用",
|
|
||||||
"印花税",
|
|
||||||
"资产负债表",
|
|
||||||
"个人所得税",
|
|
||||||
"会计要素",
|
|
||||||
"远光软件股份有限公司",
|
|
||||||
"利润",
|
|
||||||
"增值税"
|
|
||||||
],
|
|
||||||
"count": 31,
|
|
||||||
"create_time": 1779441772,
|
|
||||||
"update_time": 1779441772,
|
|
||||||
"_id": "b0277cd76034437997fbf5219662725a"
|
|
||||||
},
|
|
||||||
"23f56f159a3e4bc3b2338056544120dd": {
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
"entity_names": [
|
"entity_names": [
|
||||||
"净利润",
|
"净利润",
|
||||||
@@ -1890,5 +1851,50 @@
|
|||||||
"create_time": 1779467727,
|
"create_time": 1779467727,
|
||||||
"update_time": 1779467727,
|
"update_time": 1779467727,
|
||||||
"_id": "3acd9c2df63b4a438c7eab876269b25d"
|
"_id": "3acd9c2df63b4a438c7eab876269b25d"
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"entity_names": [
|
||||||
|
"Fixed Assets",
|
||||||
|
"Stamp Duty",
|
||||||
|
"Liabilities",
|
||||||
|
"Revenue",
|
||||||
|
"Management Expenses",
|
||||||
|
"Accounts Receivable",
|
||||||
|
"Profit",
|
||||||
|
"Income And Expense Category",
|
||||||
|
"Software Product Sales Tax Rate 13%",
|
||||||
|
"Expenses",
|
||||||
|
"High-Tech Enterprise Preferential Tax Rate 15%",
|
||||||
|
"Software Services Tax Rate 6%",
|
||||||
|
"应收账款",
|
||||||
|
"所有者权益",
|
||||||
|
"Accounting Equation",
|
||||||
|
"资产类",
|
||||||
|
"Main Business Revenue",
|
||||||
|
"Accounts Payable",
|
||||||
|
"Employee Compensation Payable",
|
||||||
|
"Individual Income Tax",
|
||||||
|
"Liabilities Category",
|
||||||
|
"Value Added Tax",
|
||||||
|
"Bank Deposits",
|
||||||
|
"Common Accounting Items",
|
||||||
|
"常用会计科目",
|
||||||
|
"Owner's Equity",
|
||||||
|
"Accounting Elements",
|
||||||
|
"Assets Category",
|
||||||
|
"Corporate Income Tax Rate 25%",
|
||||||
|
"Yuan Guang Software Co., Ltd.",
|
||||||
|
"Assets",
|
||||||
|
"Taxes Payable",
|
||||||
|
"Corporate Income Tax",
|
||||||
|
"Tax Fundamentals",
|
||||||
|
"Cash",
|
||||||
|
"Sales Expenses",
|
||||||
|
"Major Tax Types"
|
||||||
|
],
|
||||||
|
"count": 37,
|
||||||
|
"create_time": 1779546633,
|
||||||
|
"update_time": 1779546633,
|
||||||
|
"_id": "b0277cd76034437997fbf5219662725a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,34 +355,6 @@
|
|||||||
"update_time": 1779441745,
|
"update_time": 1779441745,
|
||||||
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
||||||
},
|
},
|
||||||
"b0277cd76034437997fbf5219662725a": {
|
|
||||||
"relation_pairs": [
|
|
||||||
[
|
|
||||||
"会计要素",
|
|
||||||
"资产"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"财务基础知识手册",
|
|
||||||
"远光软件股份有限公司"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"财务基础知识手册",
|
|
||||||
"财务报表解读"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"税务基础知识",
|
|
||||||
"财务基础知识手册"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"会计基础知识",
|
|
||||||
"财务基础知识手册"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"count": 5,
|
|
||||||
"create_time": 1779441772,
|
|
||||||
"update_time": 1779441772,
|
|
||||||
"_id": "b0277cd76034437997fbf5219662725a"
|
|
||||||
},
|
|
||||||
"23f56f159a3e4bc3b2338056544120dd": {
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
"relation_pairs": [
|
"relation_pairs": [
|
||||||
[
|
[
|
||||||
@@ -2194,5 +2166,21 @@
|
|||||||
"create_time": 1779467727,
|
"create_time": 1779467727,
|
||||||
"update_time": 1779467727,
|
"update_time": 1779467727,
|
||||||
"_id": "3acd9c2df63b4a438c7eab876269b25d"
|
"_id": "3acd9c2df63b4a438c7eab876269b25d"
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"relation_pairs": [
|
||||||
|
[
|
||||||
|
"应收账款",
|
||||||
|
"资产类"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"常用会计科目",
|
||||||
|
"资产类"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779546633,
|
||||||
|
"update_time": 1779546633,
|
||||||
|
"_id": "b0277cd76034437997fbf5219662725a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,51 +746,6 @@
|
|||||||
"update_time": 1779441745,
|
"update_time": 1779441745,
|
||||||
"_id": "1221其他应收款<SEP>远光软件股份有限公司"
|
"_id": "1221其他应收款<SEP>远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"财务基础知识手册<SEP>远光软件股份有限公司": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441770,
|
|
||||||
"update_time": 1779441770,
|
|
||||||
"_id": "财务基础知识手册<SEP>远光软件股份有限公司"
|
|
||||||
},
|
|
||||||
"会计要素<SEP>资产": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441770,
|
|
||||||
"update_time": 1779441770,
|
|
||||||
"_id": "会计要素<SEP>资产"
|
|
||||||
},
|
|
||||||
"会计基础知识<SEP>财务基础知识手册": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441771,
|
|
||||||
"update_time": 1779441771,
|
|
||||||
"_id": "会计基础知识<SEP>财务基础知识手册"
|
|
||||||
},
|
|
||||||
"税务基础知识<SEP>财务基础知识手册": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441771,
|
|
||||||
"update_time": 1779441771,
|
|
||||||
"_id": "税务基础知识<SEP>财务基础知识手册"
|
|
||||||
},
|
|
||||||
"财务基础知识手册<SEP>财务报表解读": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1779441771,
|
|
||||||
"update_time": 1779441771,
|
|
||||||
"_id": "财务基础知识手册<SEP>财务报表解读"
|
|
||||||
},
|
|
||||||
"财务术语解释手册<SEP>远光软件股份有限公司": {
|
"财务术语解释手册<SEP>远光软件股份有限公司": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
@@ -4230,5 +4185,23 @@
|
|||||||
"create_time": 1779467726,
|
"create_time": 1779467726,
|
||||||
"update_time": 1779467726,
|
"update_time": 1779467726,
|
||||||
"_id": "第三部分发票问题<SEP>财务报销常见问题解答"
|
"_id": "第三部分发票问题<SEP>财务报销常见问题解答"
|
||||||
|
},
|
||||||
|
"常用会计科目<SEP>资产类": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546632,
|
||||||
|
"update_time": 1779546632,
|
||||||
|
"_id": "常用会计科目<SEP>资产类"
|
||||||
|
},
|
||||||
|
"应收账款<SEP>资产类": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779546633,
|
||||||
|
"update_time": 1779546633,
|
||||||
|
"_id": "应收账款<SEP>资产类"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,17 +197,6 @@
|
|||||||
"update_time": 1779441661,
|
"update_time": 1779441661,
|
||||||
"_id": "chunk-e726f44fb0287c5192cf61b350f18abb"
|
"_id": "chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
},
|
},
|
||||||
"chunk-78edb0c8ccc8238159196ecaeeb08d43": {
|
|
||||||
"tokens": 839,
|
|
||||||
"content": "远光软件股份有限公司\n财务基础知识手册\n第一部分 会计基础知识\n一、会计要素\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n会计恒等式:资产 = 负债 + 所有者权益\n二、常用会计科目\n科目类别\n科目名称\n说明\n资产类\n库存现金\n公司持有的现金\n资产类\n银行存款\n存放在银行的资金\n资产类\n应收账款\n因销售商品或提供劳务应收的款项\n资产类\n固定资产\n使用年限超过一年的有形资产\n负债类\n应付账款\n因购买商品或接受劳务应付的款项\n负债类\n应交税费\n应缴纳的各种税费\n负债类\n应付职工薪酬\n应付给职工的工资、福利等\n损益类\n主营业务收入\n主要经营业务产生的收入\n损益类\n管理费用\n为管理生产经营发生的费用\n损益类\n销售费用\n为销售产品发生的费用\n第二部分 税务基础知识\n三、主要税种介绍\n(一)增值税:公司为一般纳税人,软件服务适用6%税率,软件产品销售适用13%税率。\n(二)企业所得税:税率为25%,高新技术企业享受15%优惠税率。\n(三)个人所得税:按累进税率3%-45%,由公司代扣代缴。\n(四)印花税:对经济活动中的应税凭证征收。\n第三部分 财务报表解读\n四、三大财务报表\n(一)资产负债表:反映企业在某一特定日期的财务状况。\n(二)利润表:反映企业在一定期间的经营成果。\n(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。\n\n# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 一、会计要素\n- 二、常用会计科目\n- (四)印花税:对经济活动中的应税凭证征收。\n\n# 重点章节摘录\n\n## 一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。;会计恒等式:资产 = 负债 + 所有者权益\n\n## 二、常用会计科目\n\n科目类别;科目名称;说明\n\n## (四)印花税:对经济活动中的应税凭证征收。\n\n第三部分 财务报表解读\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 一、会计要素:会计要素包括:资产、负债、所有者权益、收入、费用和利润\n- 一、会计要素:会计恒等式:资产 = 负债 + 所有者权益\n- 二、常用会计科目:因销售商品或提供劳务应收的款项\n- 二、常用会计科目:因购买商品或接受劳务应付的款项\n- 二、常用会计科目:应缴纳的各种税费\n- 二、常用会计科目:应付职工薪酬\n- (四)印花税:对经济活动中的应税凭证征收。:第三部分 财务报表解读",
|
|
||||||
"chunk_order_index": 0,
|
|
||||||
"full_doc_id": "b0277cd76034437997fbf5219662725a",
|
|
||||||
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
|
||||||
"llm_cache_list": [],
|
|
||||||
"create_time": 1779441751,
|
|
||||||
"update_time": 1779441751,
|
|
||||||
"_id": "chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
|
||||||
},
|
|
||||||
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1": {
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1": {
|
||||||
"tokens": 760,
|
"tokens": 760,
|
||||||
"content": "远光软件股份有限公司\n财务术语解释手册\n权责发生制\n以权利和责任的发生来决定收入和费用归属期的会计基础。即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用。\n收付实现制\n以现金收到或付出为标准来记录收入的实现和费用的发生。即凡是当期收到和支付的现金,都作为当期的收入和费用。\n固定资产折旧\n固定资产在使用过程中因磨损而逐渐转移的价值。公司采用年限平均法计提折旧。\n摊销\n将无形资产或长期待摊费用按照规定期限分期计入当期损益的过程。\n增值税进项税额\n企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣。\n增值税销项税额\n企业销售货物、提供应税劳务或应税服务收取的增值税额。\n预算\n企业对未来一定时期内经营活动的数量化计划,包括收入预算、支出预算、资本预算等。\n现金流\n企业在一定期间内现金和现金等价物流入和流出的数量。\n毛利率\n毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力。计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n净资产收益率(ROE)\n净利润占股东权益的百分比,反映股东投入资金的获利能力。计算公式:ROE = 净利润 / 股东权益 × 100%\n成本中心\n企业内部只发生成本费用而不产生收入的组织单位,用于成本核算和控制。\n利润中心\n企业内部既发生成本费用又产生收入的组织单位,用于考核盈利能力。\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 正文:以权利和责任的发生来决定收入和费用归属期的会计基础\n- 正文:即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用\n- 正文:以现金收到或付出为标准来记录收入的实现和费用的发生\n- 正文:即凡是当期收到和支付的现金,都作为当期的收入和费用\n- 正文:企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣\n- 正文:企业销售货物、提供应税劳务或应税服务收取的增值税额\n- 正文:毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力\n- 正文:计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n- 正文:净利润占股东权益的百分比,反映股东投入资金的获利能力\n- 正文:计算公式:ROE = 净利润 / 股东权益 × 100%",
|
"content": "远光软件股份有限公司\n财务术语解释手册\n权责发生制\n以权利和责任的发生来决定收入和费用归属期的会计基础。即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用。\n收付实现制\n以现金收到或付出为标准来记录收入的实现和费用的发生。即凡是当期收到和支付的现金,都作为当期的收入和费用。\n固定资产折旧\n固定资产在使用过程中因磨损而逐渐转移的价值。公司采用年限平均法计提折旧。\n摊销\n将无形资产或长期待摊费用按照规定期限分期计入当期损益的过程。\n增值税进项税额\n企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣。\n增值税销项税额\n企业销售货物、提供应税劳务或应税服务收取的增值税额。\n预算\n企业对未来一定时期内经营活动的数量化计划,包括收入预算、支出预算、资本预算等。\n现金流\n企业在一定期间内现金和现金等价物流入和流出的数量。\n毛利率\n毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力。计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n净资产收益率(ROE)\n净利润占股东权益的百分比,反映股东投入资金的获利能力。计算公式:ROE = 净利润 / 股东权益 × 100%\n成本中心\n企业内部只发生成本费用而不产生收入的组织单位,用于成本核算和控制。\n利润中心\n企业内部既发生成本费用又产生收入的组织单位,用于考核盈利能力。\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 正文:以权利和责任的发生来决定收入和费用归属期的会计基础\n- 正文:即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用\n- 正文:以现金收到或付出为标准来记录收入的实现和费用的发生\n- 正文:即凡是当期收到和支付的现金,都作为当期的收入和费用\n- 正文:企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣\n- 正文:企业销售货物、提供应税劳务或应税服务收取的增值税额\n- 正文:毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力\n- 正文:计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n- 正文:净利润占股东权益的百分比,反映股东投入资金的获利能力\n- 正文:计算公式:ROE = 净利润 / 股东权益 × 100%",
|
||||||
@@ -889,5 +878,27 @@
|
|||||||
"create_time": 1779467725,
|
"create_time": 1779467725,
|
||||||
"update_time": 1779467725,
|
"update_time": 1779467725,
|
||||||
"_id": "chunk-cfac1ddf5942f8fe2d5a296380818faf"
|
"_id": "chunk-cfac1ddf5942f8fe2d5a296380818faf"
|
||||||
|
},
|
||||||
|
"chunk-6fdc554482754c7c662adc7804d3cf0b": {
|
||||||
|
"tokens": 1195,
|
||||||
|
"content": "远光软件股份有限公司 财务基础知识手册\n\n第一部分 会计基础知识\n\n一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n\n会计恒等式:资产 = 负债 + 所有者权益\n\n二、常用会计科目\n\n| 科目类别 | 科目名称 | 说明 |\n| --- | --- | --- |\n| 资产类 | 库存现金 | 公司持有的现金 |\n| 资产类 | 银行存款 | 存放在银行的资金 |\n| 资产类 | 应收账款 | 因销售商品或提供劳务应收的款项 |\n| 资产类 | 固定资产 | 使用年限超过一年的有形资产 |\n| 负债类 | 应付账款 | 因购买商品或接受劳务应付的款项 |\n| 负债类 | 应交税费 | 应缴纳的各种税费 |\n| 负债类 | 应付职工薪酬 | 应付给职工的工资、福利等 |\n| 损益类 | 主营业务收入 | 主要经营业务产生的收入 |\n| 损益类 | 管理费用 | 为管理生产经营发生的费用 |\n| 损益类 | 销售费用 | 为销售产品发生的费用 |\n\n### 表格行级检索线索\n\n- 表格第 2 行:科目类别=资产类;科目名称=库存现金;说明=公司持有的现金\n\n- 表格第 3 行:科目类别=资产类;科目名称=银行存款;说明=存放在银行的资金\n\n- 表格第 4 行:科目类别=资产类;科目名称=应收账款;说明=因销售商品或提供劳务应收的款项\n\n- 表格第 5 行:科目类别=资产类;科目名称=固定资产;说明=使用年限超过一年的有形资产\n\n- 表格第 6 行:科目类别=负债类;科目名称=应付账款;说明=因购买商品或接受劳务应付的款项\n\n- 表格第 7 行:科目类别=负债类;科目名称=应交税费;说明=应缴纳的各种税费\n\n- 表格第 8 行:科目类别=负债类;科目名称=应付职工薪酬;说明=应付给职工的工资、福利等\n\n- 表格第 9 行:科目类别=损益类;科目名称=主营业务收入;说明=主要经营业务产生的收入\n\n- 表格第 10 行:科目类别=损益类;科目名称=管理费用;说明=为管理生产经营发生的费用\n\n- 表格第 11 行:科目类别=损益类;科目名称=销售费用;说明=为销售产品发生的费用\n\n第二部分 税务基础知识\n\n三、主要税种介绍\n\n(一)增值税:公司为一般纳税人,软件服务适用6%税率,软件产品销售适用13%税率。\n\n(二)企业所得税:税率为25%,高新技术企业享受15%优惠税率。\n\n(三)个人所得税:按累进税率3%-45%,由公司代扣代缴。\n\n(四)印花税:对经济活动中的应税凭证征收。\n\n第三部分 财务报表解读\n\n四、三大财务报表\n\n(一)资产负债表:反映企业在某一特定日期的财务状况。\n\n(二)利润表:反映企业在一定期间的经营成果。\n\n(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。\n\n# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 一、会计要素\n- 二、常用会计科目\n\n# 重点章节摘录\n\n## 一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。;会计恒等式:资产 = 负债 + 所有者权益\n\n## 二、常用会计科目\n\n| 科目类别 | 科目名称 | 说明 |;| --- | --- | --- |;| 资产类 | 库存现金 | 公司持有的现金 |\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 一、会计要素:会计要素包括:资产、负债、所有者权益、收入、费用和利润\n- 一、会计要素:会计恒等式:资产 = 负债 + 所有者权益\n- 二、常用会计科目:表格第 2 行:科目类别=资产类\n- 二、常用会计科目:表格第 3 行:科目类别=资产类\n- 二、常用会计科目:表格第 4 行:科目类别=资产类\n- 二、常用会计科目:科目名称=应收账款",
|
||||||
|
"chunk_order_index": 0,
|
||||||
|
"full_doc_id": "b0277cd76034437997fbf5219662725a",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779546577,
|
||||||
|
"update_time": 1779546577,
|
||||||
|
"_id": "chunk-6fdc554482754c7c662adc7804d3cf0b"
|
||||||
|
},
|
||||||
|
"chunk-f894acfbb6c681d00f75cf9c486d491b": {
|
||||||
|
"tokens": 95,
|
||||||
|
"content": "所有者权益\n- 二、常用会计科目:表格第 2 行:科目类别=资产类\n- 二、常用会计科目:表格第 3 行:科目类别=资产类\n- 二、常用会计科目:表格第 4 行:科目类别=资产类\n- 二、常用会计科目:科目名称=应收账款",
|
||||||
|
"chunk_order_index": 1,
|
||||||
|
"full_doc_id": "b0277cd76034437997fbf5219662725a",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779546577,
|
||||||
|
"update_time": 1779546577,
|
||||||
|
"_id": "chunk-f894acfbb6c681d00f75cf9c486d491b"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,38 @@ from zipfile import ZipFile
|
|||||||
from app.services.knowledge_document_extractors import _extract_document_text_from_path
|
from app.services.knowledge_document_extractors import _extract_document_text_from_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_docx_document_text_preserves_tables_as_markdown(tmp_path) -> None:
|
||||||
|
file_path = tmp_path / "financial-basic.docx"
|
||||||
|
_write_minimal_docx_with_table(
|
||||||
|
file_path,
|
||||||
|
paragraphs=[
|
||||||
|
"远光软件股份有限公司",
|
||||||
|
"财务基础知识手册",
|
||||||
|
"二、常用会计科目",
|
||||||
|
],
|
||||||
|
table=[
|
||||||
|
["科目类别", "科目名称", "说明"],
|
||||||
|
["资产类", "库存现金", "公司持有的现金"],
|
||||||
|
["负债类", "应付账款", "因购买商品或接受劳务应付的款项"],
|
||||||
|
["损益类", "销售费用", "为销售产品发生的费用"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
text = _extract_document_text_from_path(
|
||||||
|
file_path=file_path,
|
||||||
|
original_name="远光软件财务基础知识手册.docx",
|
||||||
|
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "二、常用会计科目" in text
|
||||||
|
assert "| 科目类别 | 科目名称 | 说明 |" in text
|
||||||
|
assert "| 资产类 | 库存现金 | 公司持有的现金 |" in text
|
||||||
|
assert "| 负债类 | 应付账款 | 因购买商品或接受劳务应付的款项 |" in text
|
||||||
|
assert "| 损益类 | 销售费用 | 为销售产品发生的费用 |" in text
|
||||||
|
assert "表格第 2 行:科目类别=资产类;科目名称=库存现金;说明=公司持有的现金" in text
|
||||||
|
assert "科目类别\n科目名称\n说明" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_extract_xlsx_document_text_builds_markdown_with_row_clues(tmp_path) -> None:
|
def test_extract_xlsx_document_text_builds_markdown_with_row_clues(tmp_path) -> None:
|
||||||
file_path = tmp_path / "company-expense-rules.xlsx"
|
file_path = tmp_path / "company-expense-rules.xlsx"
|
||||||
_write_minimal_xlsx(
|
_write_minimal_xlsx(
|
||||||
@@ -58,6 +90,39 @@ def test_extract_pptx_document_text_builds_markdown_slides(tmp_path) -> None:
|
|||||||
assert "- 发票、审批、预算三项要素必须齐全" in text
|
assert "- 发票、审批、预算三项要素必须齐全" in text
|
||||||
|
|
||||||
|
|
||||||
|
def _write_minimal_docx_with_table(
|
||||||
|
file_path,
|
||||||
|
*,
|
||||||
|
paragraphs: list[str],
|
||||||
|
table: list[list[str]],
|
||||||
|
) -> None:
|
||||||
|
paragraph_xml = "\n".join(f"<w:p>{_docx_text_run(text)}</w:p>" for text in paragraphs)
|
||||||
|
table_xml = (
|
||||||
|
"<w:tbl>"
|
||||||
|
+ "".join(
|
||||||
|
"<w:tr>"
|
||||||
|
+ "".join(f"<w:tc><w:p>{_docx_text_run(cell)}</w:p></w:tc>" for cell in row)
|
||||||
|
+ "</w:tr>"
|
||||||
|
for row in table
|
||||||
|
)
|
||||||
|
+ "</w:tbl>"
|
||||||
|
)
|
||||||
|
document_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
|
<w:body>
|
||||||
|
{paragraph_xml}
|
||||||
|
{table_xml}
|
||||||
|
</w:body>
|
||||||
|
</w:document>
|
||||||
|
"""
|
||||||
|
with ZipFile(file_path, "w") as archive:
|
||||||
|
archive.writestr("word/document.xml", document_xml)
|
||||||
|
|
||||||
|
|
||||||
|
def _docx_text_run(text: str) -> str:
|
||||||
|
return f"<w:r><w:t>{text}</w:t></w:r>"
|
||||||
|
|
||||||
|
|
||||||
def _write_minimal_xlsx(file_path, *, sheet_name: str, rows: list[list[str]]) -> None:
|
def _write_minimal_xlsx(file_path, *, sheet_name: str, rows: list[list[str]]) -> None:
|
||||||
workbook_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
workbook_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
|||||||
@@ -58,6 +58,34 @@ def test_build_hits_boosts_query_term_matches() -> None:
|
|||||||
assert [item["candidate_id"] for item in hits] == ["ent-1", "travel-1"]
|
assert [item["candidate_id"] for item in hits] == ["ent-1", "travel-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_hits_keeps_long_query_anchor_terms_for_accounting_table() -> None:
|
||||||
|
hits = KnowledgeRagService._build_hits_from_query_data(
|
||||||
|
query="远光软件财务基础知识手册里的常用会计科目是什么?",
|
||||||
|
chunks=[
|
||||||
|
{
|
||||||
|
"chunk_id": "generic-1",
|
||||||
|
"file_path": "/tmp/doc-1__远光软件财务制度培训手册.docx",
|
||||||
|
"content": "远光软件股份有限公司财务培训内容,介绍费用报销和财务制度。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chunk_id": "accounts-1",
|
||||||
|
"file_path": "/tmp/doc-2__远光软件财务基础知识手册.docx",
|
||||||
|
"content": (
|
||||||
|
"二、常用会计科目\n\n"
|
||||||
|
"| 科目类别 | 科目名称 | 说明 |\n"
|
||||||
|
"| --- | --- | --- |\n"
|
||||||
|
"| 资产类 | 库存现金 | 公司持有的现金 |\n"
|
||||||
|
"| 损益类 | 销售费用 | 为销售产品发生的费用 |"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entities=[],
|
||||||
|
limit=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item["candidate_id"] for item in hits] == ["accounts-1", "generic-1"]
|
||||||
|
|
||||||
|
|
||||||
def test_build_hits_prioritizes_answer_clue_appendix_for_rule_queries() -> None:
|
def test_build_hits_prioritizes_answer_clue_appendix_for_rule_queries() -> None:
|
||||||
hits = KnowledgeRagService._build_hits_from_query_data(
|
hits = KnowledgeRagService._build_hits_from_query_data(
|
||||||
query="报销时限是多少?",
|
query="报销时限是多少?",
|
||||||
|
|||||||
@@ -589,6 +589,66 @@ def test_semantic_ontology_service_covers_common_expense_scene_keywords(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_connects_expense_application_to_ontology() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="申请2026-06-01 ~ 2026-06-03去北京做客户现场验收,差旅预算18000元",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"document_type": "expense_application",
|
||||||
|
"application_stage": "pre_approval",
|
||||||
|
"entry_source": "documents_application",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert any(
|
||||||
|
item.type == "document_type" and item.normalized_value == "expense_application"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
item.type == "workflow_stage" and item.normalized_value == "pre_approval"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
item.field == "document_type" and item.value == "expense_application"
|
||||||
|
for item in result.constraints
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
item.type == "expense_type" and item.normalized_value == "travel"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_requires_attachment_for_meeting_application() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="发起会务申请,2026-06-01 ~ 2026-06-02上海产品发布会,预算32000元",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"document_type": "expense_application",
|
||||||
|
"application_stage": "pre_approval",
|
||||||
|
"entry_source": "documents_application",
|
||||||
|
"attachment_count": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert any(
|
||||||
|
item.type == "expense_type" and item.normalized_value == "meeting"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
assert "attachments" in result.missing_slots
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.agent_asset import AgentAsset
|
from app.models.agent_asset import AgentAsset
|
||||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.schemas.agent_asset import (
|
||||||
|
AgentAssetReviewCreate,
|
||||||
|
AgentAssetRiskRuleGenerateRequest,
|
||||||
|
AgentAssetRiskRuleReportRequest,
|
||||||
|
AgentAssetRiskRuleSampleTestRequest,
|
||||||
|
AgentAssetRiskRuleScenarioTestRequest,
|
||||||
|
AgentAssetRiskRuleSimulationRequest,
|
||||||
|
)
|
||||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||||
from app.services.risk_rule_flow_diagram import RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramSpec
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
from app.services.risk_rule_flow_diagram import (
|
||||||
|
RiskRuleFlowDiagramRenderer,
|
||||||
|
RiskRuleFlowDiagramSpec,
|
||||||
|
)
|
||||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +57,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
|||||||
asset_id = service.generate_rule_asset(
|
asset_id = service.generate_rule_asset(
|
||||||
AgentAssetRiskRuleGenerateRequest(
|
AgentAssetRiskRuleGenerateRequest(
|
||||||
business_domain=AgentAssetDomain.EXPENSE,
|
business_domain=AgentAssetDomain.EXPENSE,
|
||||||
|
expense_category="travel",
|
||||||
risk_level="high",
|
risk_level="high",
|
||||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||||
),
|
),
|
||||||
@@ -54,12 +69,18 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
|||||||
assert asset.status == AgentAssetStatus.DRAFT.value
|
assert asset.status == AgentAssetStatus.DRAFT.value
|
||||||
assert asset.config_json["detail_mode"] == "json_risk"
|
assert asset.config_json["detail_mode"] == "json_risk"
|
||||||
assert asset.config_json["evaluator"] == "template_rule"
|
assert asset.config_json["evaluator"] == "template_rule"
|
||||||
|
assert asset.config_json["expense_category"] == "travel"
|
||||||
|
assert asset.config_json["risk_category"] == "差旅费"
|
||||||
|
assert asset.scenario_json == ["差旅费"]
|
||||||
assert asset.current_version == "v0.1.0"
|
assert asset.current_version == "v0.1.0"
|
||||||
|
|
||||||
file_name = asset.config_json["rule_document"]["file_name"]
|
file_name = asset.config_json["rule_document"]["file_name"]
|
||||||
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
|
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
|
||||||
payload = json.loads(rule_path.read_text(encoding="utf-8"))
|
payload = json.loads(rule_path.read_text(encoding="utf-8"))
|
||||||
assert payload["rule_code"] == asset.code
|
assert payload["rule_code"] == asset.code
|
||||||
|
assert payload["applies_to"]["expense_categories"] == ["travel"]
|
||||||
|
assert payload["risk_category"] == "差旅费"
|
||||||
|
assert payload["metadata"]["expense_category"] == "travel"
|
||||||
assert payload["outcomes"]["fail"]["severity"] == "high"
|
assert payload["outcomes"]["fail"]["severity"] == "high"
|
||||||
assert payload["template_key"] == "field_compare_v1"
|
assert payload["template_key"] == "field_compare_v1"
|
||||||
assert payload["metadata"]["natural_language"].startswith("住宿城市")
|
assert payload["metadata"]["natural_language"].startswith("住宿城市")
|
||||||
@@ -104,3 +125,206 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
|||||||
assert "#dc2626" in high_svg
|
assert "#dc2626" in high_svg
|
||||||
assert high_svg.count("#dc2626") == 1
|
assert high_svg.count("#dc2626") == 1
|
||||||
assert "#10a37f" not in high_svg
|
assert "#10a37f" not in high_svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||||
|
generator = RiskRuleGenerationService(
|
||||||
|
db,
|
||||||
|
rule_library_manager=manager,
|
||||||
|
runtime_chat_service=NullRuntimeChatService(),
|
||||||
|
)
|
||||||
|
asset_id = generator.generate_rule_asset(
|
||||||
|
AgentAssetRiskRuleGenerateRequest(
|
||||||
|
business_domain=AgentAssetDomain.EXPENSE,
|
||||||
|
risk_level="high",
|
||||||
|
natural_language="酒店发票城市必须与行程城市一致,不一致时标记高风险。",
|
||||||
|
),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
service.rule_library_manager = manager
|
||||||
|
|
||||||
|
asset = db.get(AgentAsset, asset_id)
|
||||||
|
assert asset is not None
|
||||||
|
try:
|
||||||
|
service.create_review(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetReviewCreate(
|
||||||
|
version=asset.working_version or "v0.1.0",
|
||||||
|
reviewer="manager",
|
||||||
|
review_status=AgentReviewStatus.PENDING,
|
||||||
|
review_note="送审",
|
||||||
|
),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
assert "测试通过" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("未测试通过的风险规则不应允许提交审核")
|
||||||
|
|
||||||
|
simulation = service.simulate_risk_rule_message(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetRiskRuleSimulationRequest(
|
||||||
|
message="我想仿真一张酒店报销单,酒店发票城市上海,申报目的地北京,金额580元。",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert simulation.execution_mode == "risk_rule_simulation"
|
||||||
|
assert simulation.ready is True
|
||||||
|
assert simulation.hit is True
|
||||||
|
assert simulation.severity == "high"
|
||||||
|
assert "不创建业务单据" in simulation.summary
|
||||||
|
assert service.get_latest_risk_rule_test_summary(asset_id).sample is None
|
||||||
|
|
||||||
|
blocked_simulation = service.simulate_risk_rule_message(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetRiskRuleSimulationRequest(
|
||||||
|
message="请识别上传单据是否命中风险规则。",
|
||||||
|
attachments=[{"name": "hotel-invoice.pdf", "content_type": "application/pdf"}],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert blocked_simulation.ready is False
|
||||||
|
assert blocked_simulation.stage == "needs_recognition"
|
||||||
|
assert blocked_simulation.hit is False
|
||||||
|
assert "尚未完成识别" in blocked_simulation.summary
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ExpenseClaim(
|
||||||
|
claim_no="TEST-CLAIM-001",
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="财务部",
|
||||||
|
expense_type="住宿费",
|
||||||
|
reason="北京出差住宿",
|
||||||
|
location="北京",
|
||||||
|
amount=Decimal("300.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
sample = service.run_risk_rule_sample_test(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetRiskRuleSampleTestRequest(),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
assert sample.passed is True
|
||||||
|
|
||||||
|
scenario = service.run_risk_rule_scenario_test(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetRiskRuleScenarioTestRequest(intent="用最近30天的住宿报销单试运行"),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
assert scenario.passed is True
|
||||||
|
assert scenario.result_json["total_count"] == 1
|
||||||
|
|
||||||
|
report = service.confirm_risk_rule_test_report(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetRiskRuleReportRequest(confirm_passed=True),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
assert report.passed is True
|
||||||
|
|
||||||
|
review = service.create_review(
|
||||||
|
asset_id,
|
||||||
|
AgentAssetReviewCreate(
|
||||||
|
version=asset.working_version or "v0.1.0",
|
||||||
|
reviewer="manager",
|
||||||
|
review_status=AgentReviewStatus.PENDING,
|
||||||
|
review_note="送审",
|
||||||
|
),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
assert review.review_status == AgentReviewStatus.PENDING.value
|
||||||
|
published = service.publish_risk_rule(asset_id, actor="manager")
|
||||||
|
assert published.status == AgentAssetStatus.ACTIVE.value
|
||||||
|
assert published.published_version == asset.working_version
|
||||||
|
|
||||||
|
disabled = service.set_risk_rule_enabled(
|
||||||
|
asset_id,
|
||||||
|
enabled=False,
|
||||||
|
actor="manager",
|
||||||
|
)
|
||||||
|
assert disabled.config_json["enabled"] is False
|
||||||
|
rule_document = disabled.config_json["rule_document"]
|
||||||
|
manifest = manager.read_rule_library_json(
|
||||||
|
library=RISK_RULES_LIBRARY,
|
||||||
|
file_name=rule_document["file_name"],
|
||||||
|
)
|
||||||
|
assert manifest["enabled"] is False
|
||||||
|
|
||||||
|
attachment_required_id = generator.generate_rule_asset(
|
||||||
|
AgentAssetRiskRuleGenerateRequest(
|
||||||
|
business_domain=AgentAssetDomain.EXPENSE,
|
||||||
|
risk_level="medium",
|
||||||
|
natural_language="发票号码不能为空,缺失时进入中风险复核。",
|
||||||
|
requires_attachment=True,
|
||||||
|
),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
attachment_required_asset = db.get(AgentAsset, attachment_required_id)
|
||||||
|
assert attachment_required_asset is not None
|
||||||
|
assert attachment_required_asset.config_json["requires_attachment"] is True
|
||||||
|
attachment_rule_document = attachment_required_asset.config_json["rule_document"]
|
||||||
|
attachment_manifest = manager.read_rule_library_json(
|
||||||
|
library=RISK_RULES_LIBRARY,
|
||||||
|
file_name=attachment_rule_document["file_name"],
|
||||||
|
)
|
||||||
|
assert attachment_manifest["requires_attachment"] is True
|
||||||
|
no_attachment_simulation = service.simulate_risk_rule_message(
|
||||||
|
attachment_required_id,
|
||||||
|
AgentAssetRiskRuleSimulationRequest(message="请测试这条规则。"),
|
||||||
|
)
|
||||||
|
assert no_attachment_simulation.ready is False
|
||||||
|
assert no_attachment_simulation.stage == "needs_attachment"
|
||||||
|
|
||||||
|
attachment_only_simulation = service.simulate_risk_rule_message(
|
||||||
|
attachment_required_id,
|
||||||
|
AgentAssetRiskRuleSimulationRequest(
|
||||||
|
message="请识别上传单据是否命中风险规则。",
|
||||||
|
attachments=[
|
||||||
|
{
|
||||||
|
"name": "invoice.pdf",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"document_fields": [
|
||||||
|
{"key": "invoice_no", "label": "发票号码", "value": "INV-001"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert attachment_only_simulation.ready is False
|
||||||
|
assert attachment_only_simulation.stage == "needs_test_intent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_unpublished_risk_rule_removes_asset_and_json_file(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,
|
||||||
|
risk_level="medium",
|
||||||
|
natural_language="报销事由不能为空,缺失时进入中风险复核。",
|
||||||
|
),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
asset = db.get(AgentAsset, asset_id)
|
||||||
|
assert asset is not None
|
||||||
|
file_name = asset.config_json["rule_document"]["file_name"]
|
||||||
|
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
|
||||||
|
assert rule_path.exists()
|
||||||
|
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
service.rule_library_manager = manager
|
||||||
|
service.delete_unpublished_asset(asset_id, actor="pytest")
|
||||||
|
|
||||||
|
assert db.get(AgentAsset, asset_id) is None
|
||||||
|
assert not rule_path.exists()
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
|
|||||||
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
|
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
|
||||||
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
|
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
|
||||||
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
|
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
|
||||||
|
assert "最终答复必须像助手在认真回答问题" in messages[0]["content"]
|
||||||
|
assert "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"]
|
||||||
assert "knowledge_evidence_blocks" in messages[0]["content"]
|
assert "knowledge_evidence_blocks" in messages[0]["content"]
|
||||||
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
||||||
|
|
||||||
@@ -162,8 +164,9 @@ def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert answer.startswith("张三,您好。")
|
assert answer.startswith("张三,您好。")
|
||||||
assert "答案整理阶段本轮没有及时返回" in answer
|
assert "我先根据当前制度依据给出可以确认的部分" in answer
|
||||||
assert "先给你当前最直接的依据" in answer
|
assert "已命中" not in answer
|
||||||
|
assert "答案整理阶段本轮没有及时返回" not in answer
|
||||||
assert "《差旅费制度》" in answer
|
assert "《差旅费制度》" in answer
|
||||||
|
|
||||||
|
|
||||||
@@ -241,6 +244,40 @@ def test_user_agent_prefers_relevant_raw_hit_over_generic_appendix() -> None:
|
|||||||
assert "组织人事部" in selected[0]["content"]
|
assert "组织人事部" in selected[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_model_hit_selection_keeps_later_relevant_hits() -> None:
|
||||||
|
selected = UserAgentService._select_knowledge_model_hits(
|
||||||
|
{
|
||||||
|
"hits": [
|
||||||
|
{"content": "一般说明一"},
|
||||||
|
{"content": "一般说明二"},
|
||||||
|
{"content": "一般说明三"},
|
||||||
|
{"content": "一般说明四"},
|
||||||
|
{"content": "一般说明五"},
|
||||||
|
{"content": "一般说明六"},
|
||||||
|
{"content": "一般说明七"},
|
||||||
|
{
|
||||||
|
"content": (
|
||||||
|
"# 问答线索补充\n\n"
|
||||||
|
"- 第二章 报销时限:差旅费应在行程结束三个月内提交;逾期不予报销出差补贴。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
question="差旅费报销时限是多少?",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "三个月内提交" in selected[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_knowledge_terms_keep_accounting_subject_in_long_query() -> None:
|
||||||
|
terms = UserAgentService._extract_knowledge_query_terms(
|
||||||
|
"远光软件财务基础知识手册里的常用会计科目是什么?"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "常用会计科目" in terms
|
||||||
|
assert "会计科目" in terms
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
|
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -286,12 +323,170 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.answer.startswith("张三,您好。")
|
assert response.answer.startswith("张三,您好。")
|
||||||
assert "当前能直接确认的是" in response.answer
|
assert "**结论**" in response.answer
|
||||||
assert "30 日内提交报销申请" in response.answer
|
assert "30 日内提交报销申请" in response.answer
|
||||||
assert "## 依据" not in response.answer
|
assert "## 依据" not in response.answer
|
||||||
assert "答案整理阶段本轮没有及时返回" not in response.answer
|
assert "答案整理阶段本轮没有及时返回" not in response.answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_fast_knowledge_answer_focuses_inline_section_items() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="主要税种介绍",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={"session_type": "knowledge"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service = UserAgentService(db)
|
||||||
|
|
||||||
|
answer = service._build_fast_knowledge_answer(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message="主要税种介绍",
|
||||||
|
ontology=ontology,
|
||||||
|
context_json={
|
||||||
|
"session_type": "knowledge",
|
||||||
|
"user_input_text": "主要税种介绍",
|
||||||
|
},
|
||||||
|
tool_payload={
|
||||||
|
"result_type": "knowledge_search",
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"title": "财务基础知识",
|
||||||
|
"content": (
|
||||||
|
"资产类 银行存款 企业存放在银行的款项 负债类 应付账款 "
|
||||||
|
"因购买商品或接受劳务应付的款项 负债类 应交税费 应缴纳的各种税费 "
|
||||||
|
"第二部分 税务基础知识 三、主要税种介绍 "
|
||||||
|
"(一)增值税:公司为一般纳税人,软件服务适用6%税率,软件产品销售适用13%税率。 "
|
||||||
|
"(二)企业所得税:税率为25%,高新技术企业享受15%优惠税率。 "
|
||||||
|
"(三)个人所得税:员工工资薪金由公司代扣代缴。 "
|
||||||
|
"(四)印花税:购销合同、账簿等按规定缴纳。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
citations=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert answer is not None
|
||||||
|
assert "主要税种介绍包括:增值税、企业所得税、个人所得税、印花税" in answer
|
||||||
|
assert "软件服务适用6%税率" in answer
|
||||||
|
assert "软件产品销售适用13%税率" in answer
|
||||||
|
assert "高新技术企业享受15%优惠税率" in answer
|
||||||
|
assert "员工工资薪金由公司代扣代缴" in answer
|
||||||
|
assert "购销合同、账簿等按规定缴纳" in answer
|
||||||
|
assert "应付账款" not in answer
|
||||||
|
assert "银行存款" not in answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_fast_knowledge_answer_summarizes_financial_statements() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="三大财务报表 是什么?",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={"session_type": "knowledge"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service = UserAgentService(db)
|
||||||
|
|
||||||
|
answer = service._build_fast_knowledge_answer(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message="三大财务报表 是什么?",
|
||||||
|
ontology=ontology,
|
||||||
|
context_json={
|
||||||
|
"session_type": "knowledge",
|
||||||
|
"user_input_text": "三大财务报表 是什么?",
|
||||||
|
},
|
||||||
|
tool_payload={
|
||||||
|
"result_type": "knowledge_search",
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"title": "财务基础知识",
|
||||||
|
"content": (
|
||||||
|
"第三部分 财务报表解读 四、三大财务报表 "
|
||||||
|
"(一)资产负债表:反映企业在某一特定日期的财务状况。 "
|
||||||
|
"(二)利润表:反映企业在一定期间的经营成果。 "
|
||||||
|
"(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
citations=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert answer is not None
|
||||||
|
assert "三大财务报表包括:资产负债表、利润表、现金流量表" in answer
|
||||||
|
assert "资产负债表:反映企业在某一特定日期的财务状况" in answer
|
||||||
|
assert "利润表:反映企业在一定期间的经营成果" in answer
|
||||||
|
assert "现金流量表:反映企业在一定期间现金和现金等价物的流入和流出" in answer
|
||||||
|
assert "第三部分 财务报表解读" not in answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_fast_knowledge_answer_expands_broad_accounting_table() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="常用会计科目是什么?",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={"session_type": "knowledge"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service = UserAgentService(db)
|
||||||
|
|
||||||
|
answer = service._build_fast_knowledge_answer(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message="常用会计科目是什么?",
|
||||||
|
ontology=ontology,
|
||||||
|
context_json={
|
||||||
|
"session_type": "knowledge",
|
||||||
|
"user_input_text": "常用会计科目是什么?",
|
||||||
|
},
|
||||||
|
tool_payload={
|
||||||
|
"result_type": "knowledge_search",
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"title": "财务基础知识",
|
||||||
|
"content": (
|
||||||
|
"二、常用会计科目\n\n"
|
||||||
|
"| 科目类别 | 科目名称 | 说明 |\n"
|
||||||
|
"| --- | --- | --- |\n"
|
||||||
|
"| 资产类 | 库存现金 | 公司持有的现金 |\n"
|
||||||
|
"| 资产类 | 银行存款 | 存放在银行的资金 |\n"
|
||||||
|
"| 资产类 | 应收账款 | 因销售商品或提供劳务应收的款项 |\n"
|
||||||
|
"| 资产类 | 固定资产 | 使用年限超过一年的有形资产 |\n"
|
||||||
|
"| 负债类 | 应付账款 | 因购买商品或接受劳务应付的款项 |\n"
|
||||||
|
"| 负债类 | 应交税费 | 应缴纳的各种税费 |\n"
|
||||||
|
"| 负债类 | 应付职工薪酬 | 应付给职工的工资、福利等 |\n"
|
||||||
|
"| 损益类 | 主营业务收入 | 主要经营业务产生的收入 |\n"
|
||||||
|
"| 损益类 | 管理费用 | 为管理生产经营发生的费用 |\n"
|
||||||
|
"| 损益类 | 销售费用 | 为销售产品发生的费用 |\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
citations=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert answer is not None
|
||||||
|
assert "| 科目类别 | 科目名称 | 说明 |" in answer
|
||||||
|
assert "| 资产类 | 库存现金 | 公司持有的现金 |" in answer
|
||||||
|
assert "| 负债类 | 应付职工薪酬 | 应付给职工的工资、福利等 |" in answer
|
||||||
|
assert "| 损益类 | 销售费用 | 为销售产品发生的费用 |" in answer
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> None:
|
def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -337,9 +532,65 @@ def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> No
|
|||||||
assert answer is not None
|
assert answer is not None
|
||||||
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
|
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
|
||||||
assert "| 餐补 | 75 | 55 | 140 |" in answer
|
assert "| 餐补 | 75 | 55 | 140 |" in answer
|
||||||
|
assert "餐补的标准为" in answer
|
||||||
assert "## 依据" not in answer
|
assert "## 依据" not in answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_fast_knowledge_answer_uses_user_grade_for_table_row() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="我的住宿费标准是多少?",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={"session_type": "knowledge"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service = UserAgentService(db)
|
||||||
|
|
||||||
|
answer = service._build_fast_knowledge_answer(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message="我的住宿费标准是多少?",
|
||||||
|
ontology=ontology,
|
||||||
|
context_json={
|
||||||
|
"name": "张三",
|
||||||
|
"grade": "P5",
|
||||||
|
"position": "实施经理",
|
||||||
|
"session_type": "knowledge",
|
||||||
|
"user_input_text": "我的住宿费标准是多少?",
|
||||||
|
},
|
||||||
|
tool_payload={
|
||||||
|
"result_type": "knowledge_search",
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"title": "费用报销制度",
|
||||||
|
"content": (
|
||||||
|
"# 结构化表格补充\n\n"
|
||||||
|
"## 国内住宿限额标准\n\n"
|
||||||
|
"| 职级 | 直辖市/特区/港澳台 | 省会城市 | 其他地区 |\n"
|
||||||
|
"| --- | --- | --- | --- |\n"
|
||||||
|
"| 公司领导(P8及以上) | 800 | 500 | 400 |\n"
|
||||||
|
"| 高层经理(P7) | 700 | 450 | 400 |\n"
|
||||||
|
"| 中层经理、基层经理(P4~P6、外聘专家) | 600 | 400 | 350 |\n"
|
||||||
|
"| 其他员工 | 500 | 350 | 300 |\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
citations=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert answer is not None
|
||||||
|
assert answer.startswith("张三,您好。")
|
||||||
|
assert "中层经理、基层经理(P4~P6、外聘专家)的标准为" in answer
|
||||||
|
assert "| 中层经理、基层经理(P4~P6、外聘专家) | 600 | 400 | 350 |" in answer
|
||||||
|
assert "| 公司领导(P8及以上) | 800 | 500 | 400 |" not in answer
|
||||||
|
assert "| 高层经理(P7) | 700 | 450 | 400 |" not in answer
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> None:
|
def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -384,6 +635,7 @@ def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() ->
|
|||||||
|
|
||||||
assert answer is not None
|
assert answer is not None
|
||||||
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
|
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
|
||||||
|
assert "**说明**" in answer
|
||||||
assert "## 依据" not in answer
|
assert "## 依据" not in answer
|
||||||
|
|
||||||
|
|
||||||
@@ -429,7 +681,7 @@ def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert answer is not None
|
assert answer is not None
|
||||||
assert "当前能直接确认的是" in answer
|
assert "**结论**" in answer
|
||||||
assert "登机牌、高速道路通行记录" in answer
|
assert "登机牌、高速道路通行记录" in answer
|
||||||
assert "支付记录" in answer
|
assert "支付记录" in answer
|
||||||
assert "出差审批邮件、短信、微信等" in answer
|
assert "出差审批邮件、短信、微信等" in answer
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.main.documents-main,
|
||||||
.main.requests-main,
|
.main.requests-main,
|
||||||
.main.approval-main,
|
.main.approval-main,
|
||||||
.main.archive-main,
|
.main.archive-main,
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
}
|
}
|
||||||
.workarea { min-height: 0; overflow: auto; padding: 24px; }
|
.workarea { min-height: 0; overflow: auto; padding: 24px; }
|
||||||
.workarea.requests-workarea,
|
.workarea.requests-workarea,
|
||||||
|
.workarea.documents-workarea,
|
||||||
.workarea.approval-workarea,
|
.workarea.approval-workarea,
|
||||||
.workarea.archive-workarea,
|
.workarea.archive-workarea,
|
||||||
.workarea.policies-workarea,
|
.workarea.policies-workarea,
|
||||||
|
|||||||
330
web/src/assets/styles/components/expense-application-dialog.css
Normal file
330
web/src/assets/styles/components/expense-application-dialog.css
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
.expense-application-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(15, 23, 42, 0.26);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-application-dialog {
|
||||||
|
width: min(1040px, calc(100vw - 56px));
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-dialog-header,
|
||||||
|
.application-dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #e8eef4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-dialog-footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid #e8eef4;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-dialog-eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-dialog-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #102033;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-icon-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #4b5f73;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1.08fr);
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-input-panel,
|
||||||
|
.application-ontology-panel {
|
||||||
|
border: 1px solid #e1e9f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #425466;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-input-panel textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 132px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #cfd9e3;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: #182536;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.55;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-input-panel textarea:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-example-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-example-chip {
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #395066;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-parse-btn,
|
||||||
|
.confirm-btn,
|
||||||
|
.secondary-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-parse-btn,
|
||||||
|
.confirm-btn {
|
||||||
|
border: 0;
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-parse-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
background: #fff;
|
||||||
|
color: #395066;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-parse-btn:disabled,
|
||||||
|
.confirm-btn:disabled {
|
||||||
|
background: #b7c5d1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #425466;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-panel-head strong {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-empty-state {
|
||||||
|
min-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #6b7c8f;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-empty-state i {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-empty-state p {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ontology-chip {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eefbf5;
|
||||||
|
color: #047857;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-field-card {
|
||||||
|
min-height: 76px;
|
||||||
|
border: 1px solid #e4ebf2;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fbfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-field-card span,
|
||||||
|
.application-reason-block span,
|
||||||
|
.application-missing-block span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: #6b7c8f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-field-card strong {
|
||||||
|
color: #182536;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip,
|
||||||
|
.application-reason-block,
|
||||||
|
.application-missing-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip i {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip.required {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip.required i {
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip strong {
|
||||||
|
color: #182536;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-policy-strip p,
|
||||||
|
.application-reason-block p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #4b5f73;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-missing-block div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-missing-block em {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.expense-application-mask {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-application-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-dialog-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
741
web/src/assets/styles/components/risk-rule-test-dialog.css
Normal file
741
web/src/assets/styles/components/risk-rule-test-dialog.css
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
.risk-sim-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(15, 23, 42, 0.44);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-modal {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
height: min(820px, calc(100vh - 36px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(203, 213, 225, 0.88);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-title span,
|
||||||
|
.risk-sim-context-panel span,
|
||||||
|
.risk-sim-result-head span,
|
||||||
|
.risk-sim-evidence span,
|
||||||
|
.risk-sim-file-strip > span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-title h3 {
|
||||||
|
margin: 4px 0 5px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-title p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-icon-btn,
|
||||||
|
.risk-sim-tool-btn,
|
||||||
|
.risk-sim-send-btn {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-icon-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid #e5edf5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-meta span {
|
||||||
|
min-height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-meta .tone-low {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-meta .tone-medium {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-meta .tone-high {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-main {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-dialog-panel,
|
||||||
|
.risk-sim-context-panel {
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid #dfe8f1;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-dialog-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-row.user {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-row.user .risk-sim-avatar {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-row.user .risk-sim-bubble {
|
||||||
|
grid-column: 1;
|
||||||
|
justify-self: end;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-row.user .risk-sim-bubble header,
|
||||||
|
.risk-sim-message-row.user .risk-sim-bubble p {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-row.user .risk-sim-avatar {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-bubble {
|
||||||
|
max-width: min(100%, 760px);
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-bubble header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-bubble header strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-bubble p {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-thinking {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-files,
|
||||||
|
.risk-sim-file-strip div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-files {
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-files span,
|
||||||
|
.risk-sim-file-chip {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 260px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid rgba(191, 219, 254, 0.8);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-message-files span {
|
||||||
|
color: #dbeafe;
|
||||||
|
border-color: rgba(219, 234, 254, 0.38);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card.high {
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card.medium {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card.low {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-head strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-head b {
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card.high .risk-sim-result-head b {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card.medium .risk-sim-result-head b {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-card.low .risk-sim-result-head b {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-result-message {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-blocking-message {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-grid div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-grid span {
|
||||||
|
display: block;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-missing-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-missing-fields span {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-missing-fields b {
|
||||||
|
min-height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-evidence ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-strip {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip em {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
opacity: 0.78;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip.pending {
|
||||||
|
border-color: #dbe5ef;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip.recognizing {
|
||||||
|
border-color: #dbeafe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip.recognized {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-chip.failed {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) 42px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-composer.text-only {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-tool-btn,
|
||||||
|
.risk-sim-send-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-send-btn {
|
||||||
|
border-color: #0f766e;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-tool-btn:disabled,
|
||||||
|
.risk-sim-send-btn:disabled,
|
||||||
|
.risk-sim-primary-btn:disabled,
|
||||||
|
.risk-sim-secondary-btn:disabled {
|
||||||
|
opacity: 0.48;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-composer-shell {
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-composer-shell:focus-within {
|
||||||
|
border-color: rgba(15, 118, 110, 0.58);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-composer-shell textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
max-height: 110px;
|
||||||
|
resize: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-composer-shell textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-context-panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-context-panel section {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step-list,
|
||||||
|
.risk-sim-recognition-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
gap: 9px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step i {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step.done i {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step.running i {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step.warning i {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-step p,
|
||||||
|
.risk-sim-recognition-list p,
|
||||||
|
.risk-sim-recognition-list small {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-recognition-list article {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-recognition-list article strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-recognition-list small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-error-text {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-context-panel strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-context-panel p,
|
||||||
|
.risk-sim-field-list em {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-list b {
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid #dfe8f1;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-foot > span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-foot div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-primary-btn,
|
||||||
|
.risk-sim-secondary-btn {
|
||||||
|
min-height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-primary-btn {
|
||||||
|
border: 1px solid #0f766e;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-secondary-btn {
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-dialog-enter-active,
|
||||||
|
.risk-sim-dialog-leave-active {
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-dialog-enter-from,
|
||||||
|
.risk-sim-dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.risk-sim-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-context-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-foot {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-sim-foot div {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,53 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-submit-test-state,
|
||||||
|
.risk-rule-action-confirm,
|
||||||
|
.risk-rule-action-note {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-submit-test-state span,
|
||||||
|
.risk-rule-action-confirm span,
|
||||||
|
.risk-rule-action-note span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-submit-test-state strong,
|
||||||
|
.risk-rule-action-confirm strong {
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-submit-test-state strong.passed {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-submit-test-state p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-action-note textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.review-submit-hint {
|
.review-submit-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -847,6 +894,41 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.risk-rule-create-toggle {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-create-toggle input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 3px;
|
||||||
|
accent-color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-create-toggle span {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-create-toggle strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-create-toggle small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-rule-create-form label span {
|
.risk-rule-create-form label span {
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -949,6 +1031,20 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.minor-action.enable-action {
|
||||||
|
border-color: rgba(100, 116, 139, 0.26);
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minor-action.enable-action.is-on {
|
||||||
|
border-color: rgba(5, 150, 105, 0.26);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minor-action.enable-action i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.minor-action.danger-action {
|
.minor-action.danger-action {
|
||||||
border-color: rgba(220, 38, 38, 0.2);
|
border-color: rgba(220, 38, 38, 0.2);
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
@@ -1009,6 +1105,16 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-btn.danger {
|
||||||
|
border-color: rgba(220, 38, 38, 0.24);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn.warning {
|
||||||
|
border-color: rgba(245, 158, 11, 0.28);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1320px) {
|
@media (max-width: 1320px) {
|
||||||
.detail-hero {
|
.detail-hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1325,16 +1431,18 @@
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.json-risk-summary-card,
|
||||||
|
.json-risk-flow-card,
|
||||||
|
.json-risk-description-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.json-risk-flow-card {
|
.json-risk-flow-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-risk-description-card {
|
|
||||||
border-color: #fecdd3;
|
|
||||||
background: linear-gradient(180deg, #fffafb 0%, #ffffff 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-risk-description-text {
|
.json-risk-description-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 4px 8px;
|
padding: 0 4px 8px;
|
||||||
@@ -1348,36 +1456,109 @@
|
|||||||
.json-risk-description-source {
|
.json-risk-description-source {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 12px 4px;
|
padding: 8px 12px 4px;
|
||||||
border-top: 1px solid #ffe4e6;
|
border-top: 1px solid #e2e8f0;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-risk-summary-grid {
|
.json-risk-meta-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
column-gap: 36px;
|
||||||
|
row-gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-risk-summary-grid span {
|
.json-risk-meta-item {
|
||||||
min-height: 34px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
padding: 10px 0;
|
||||||
padding: 10px 12px;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
border-radius: 10px;
|
font-size: 13px;
|
||||||
background: #f8fafc;
|
min-height: 40px;
|
||||||
color: #475569;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-risk-summary-grid strong {
|
.json-risk-meta-item.full-width {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-label {
|
||||||
|
width: 84px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-value {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value-hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
line-height: 1.2;
|
||||||
letter-spacing: 0.04em;
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-badge.high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-badge.medium {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-badge.low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-badge.test-passed {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-risk-meta-badge.test-pending {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-status-indicator .indicator-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-status-indicator.is-active .indicator-dot {
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 4px rgba(16, 185, 129, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-risk-editor-toolbar {
|
.json-risk-editor-toolbar {
|
||||||
@@ -1403,9 +1584,12 @@
|
|||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.risk-rule-create-form,
|
.risk-rule-create-form,
|
||||||
.json-risk-summary-grid {
|
.json-risk-meta-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.json-risk-meta-item.full-width {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
@@ -1417,4 +1601,5 @@
|
|||||||
.json-risk-editor-actions {
|
.json-risk-editor-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,7 +425,7 @@
|
|||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1120px;
|
min-width: 1260px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
720
web/src/assets/styles/views/documents-center-view.css
Normal file
720
web/src/assets/styles/views/documents-center-view.css
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
.documents-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
animation: fadeUp 220ms var(--ease) both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
margin-top: 14px;
|
||||||
|
border-bottom: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs button {
|
||||||
|
position: relative;
|
||||||
|
min-height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs button.active {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs button.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px 999px 0 0;
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-tab-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-set,
|
||||||
|
.document-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search {
|
||||||
|
position: relative;
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search .mdi {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px 0 36px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search input::placeholder {
|
||||||
|
color: #8da0b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search input:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn,
|
||||||
|
.page-size {
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
min-width: 120px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover,
|
||||||
|
.page-size:hover {
|
||||||
|
border-color: rgba(16, 185, 129, .32);
|
||||||
|
color: #0f9f78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-filter,
|
||||||
|
.date-range-filter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-filter-menu,
|
||||||
|
.date-range-popover,
|
||||||
|
.page-size-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-filter-menu {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 150px;
|
||||||
|
max-height: 280px;
|
||||||
|
padding: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-filter-menu button,
|
||||||
|
.page-size-dropdown button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-filter-menu button:hover,
|
||||||
|
.document-filter-menu button.active {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-trigger {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-label {
|
||||||
|
max-width: 104px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-popover {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
width: 320px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-popover header,
|
||||||
|
.date-range-popover footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-popover header strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-popover header button {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-fields label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-fields span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-fields input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn,
|
||||||
|
.apply-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn {
|
||||||
|
border: 0;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 10px 24px rgba(5, 150, 105, 0.2);
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn.secondary {
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 14px 28px rgba(5, 150, 105, 0.24);
|
||||||
|
filter: saturate(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-request-btn.secondary:hover {
|
||||||
|
border-color: rgba(16, 185, 129, .32);
|
||||||
|
color: #047857;
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-status-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dropdown-filter {
|
||||||
|
min-width: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filter-trigger {
|
||||||
|
min-width: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filter-trigger > .mdi:first-child {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filter-menu {
|
||||||
|
min-width: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
min-height: 400px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap.is-empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap table {
|
||||||
|
width: 100%;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 28px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state .mdi {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state p {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state.error {
|
||||||
|
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state.error .mdi {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #f1c5c5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1320px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-id { width: 11%; }
|
||||||
|
.col-created { width: 10%; }
|
||||||
|
.col-stay { width: 9%; }
|
||||||
|
.col-doc-type { width: 9%; }
|
||||||
|
.col-scene { width: 10%; }
|
||||||
|
.col-title { width: 18%; }
|
||||||
|
.col-amount { width: 9%; }
|
||||||
|
.col-node { width: 12%; }
|
||||||
|
.col-status { width: 8%; }
|
||||||
|
.col-updated { width: 9%; }
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 13px 12px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
color: #24324a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f7fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: linear-gradient(90deg, rgba(16, 185, 129, .08), rgba(16, 185, 129, .03));
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-id {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag,
|
||||||
|
.type-tag,
|
||||||
|
.status-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag.reimbursement {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-kind-tag.application {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.travel,
|
||||||
|
.type-tag.hotel,
|
||||||
|
.type-tag.transport {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.entertainment,
|
||||||
|
.type-tag.meal {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.office {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.meeting,
|
||||||
|
.type-tag.training {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag.other {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.info {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.success,
|
||||||
|
.status-tag.archived {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.warning,
|
||||||
|
.status-tag.draft {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.danger {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.neutral {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-foot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-summary {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button:hover:not(.active) {
|
||||||
|
background: #fff;
|
||||||
|
color: #059669;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button.active {
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button:disabled {
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-wrap {
|
||||||
|
position: relative;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size {
|
||||||
|
min-width: 112px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-dropdown {
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-dropdown button {
|
||||||
|
border-radius: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-dropdown button:hover {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-dropdown button.active {
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.document-toolbar,
|
||||||
|
.list-foot {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.documents-list {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs {
|
||||||
|
gap: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-set,
|
||||||
|
.document-actions,
|
||||||
|
.document-status-filter,
|
||||||
|
.list-search,
|
||||||
|
.filter-btn,
|
||||||
|
.page-size {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-status-filter {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-foot {
|
||||||
|
display: grid;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,11 +260,13 @@
|
|||||||
border-bottom: 1px solid #eef3f8;
|
border-bottom: 1px solid #eef3f8;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-align: top;
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-cell {
|
.summary-cell {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-cell strong,
|
.summary-cell strong,
|
||||||
@@ -301,6 +303,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-note {
|
.status-note {
|
||||||
@@ -632,52 +635,60 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 56px;
|
padding: 3px 8px;
|
||||||
padding: 4px 9px;
|
border-radius: 6px;
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.success {
|
.status-pill.success {
|
||||||
background: rgba(22, 163, 74, 0.12);
|
background: rgba(22, 163, 74, 0.12);
|
||||||
|
border-color: rgba(22, 163, 74, 0.2);
|
||||||
color: #166534;
|
color: #166534;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.warning {
|
.status-pill.warning {
|
||||||
background: rgba(245, 158, 11, 0.14);
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
border-color: rgba(245, 158, 11, 0.25);
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.danger {
|
.status-pill.danger {
|
||||||
background: rgba(239, 68, 68, 0.14);
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
border-color: rgba(239, 68, 68, 0.25);
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.muted {
|
.status-pill.muted {
|
||||||
background: rgba(148, 163, 184, 0.14);
|
background: rgba(148, 163, 184, 0.14);
|
||||||
|
border-color: rgba(148, 163, 184, 0.25);
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.info,
|
.status-pill.info,
|
||||||
.level-pill.info {
|
.level-pill.info {
|
||||||
background: rgba(37, 99, 235, 0.12);
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
border-color: rgba(37, 99, 235, 0.25);
|
||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-pill.warning {
|
.level-pill.warning {
|
||||||
background: rgba(245, 158, 11, 0.14);
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
border-color: rgba(245, 158, 11, 0.25);
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-pill.danger {
|
.level-pill.danger {
|
||||||
background: rgba(239, 68, 68, 0.14);
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
border-color: rgba(239, 68, 68, 0.25);
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-pill.muted {
|
.level-pill.muted {
|
||||||
background: rgba(148, 163, 184, 0.14);
|
background: rgba(148, 163, 184, 0.14);
|
||||||
|
border-color: rgba(148, 163, 184, 0.25);
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const {
|
|||||||
const sidebarMeta = {
|
const sidebarMeta = {
|
||||||
overview: { label: '财务总览' },
|
overview: { label: '财务总览' },
|
||||||
workbench: { label: '个人工作台' },
|
workbench: { label: '个人工作台' },
|
||||||
|
documents: { label: '单据中心' },
|
||||||
requests: { label: '报销中心' },
|
requests: { label: '报销中心' },
|
||||||
approval: { label: '审批中心' },
|
approval: { label: '审批中心' },
|
||||||
archive: { label: '归档中心' },
|
archive: { label: '归档中心' },
|
||||||
|
|||||||
@@ -109,6 +109,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="isDocuments">
|
||||||
|
<div class="kpi-chips">
|
||||||
|
<div v-for="kpi in documentKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||||
|
<span class="chip-value">{{ kpi.value }}<small>单</small></span>
|
||||||
|
<span class="chip-label">{{ kpi.label }}</span>
|
||||||
|
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="isRequests">
|
<template v-else-if="isRequests">
|
||||||
<div class="kpi-chips">
|
<div class="kpi-chips">
|
||||||
<div v-for="kpi in requestKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
<div v-for="kpi in requestKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||||
@@ -192,6 +202,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
|
documentSummary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
workbenchSummary: {
|
workbenchSummary: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
@@ -226,7 +240,8 @@ const emit = defineEmits([
|
|||||||
const isChat = computed(() => props.activeView === 'chat')
|
const isChat = computed(() => props.activeView === 'chat')
|
||||||
const isOverview = computed(() => props.activeView === 'overview')
|
const isOverview = computed(() => props.activeView === 'overview')
|
||||||
const isWorkbench = computed(() => props.activeView === 'workbench')
|
const isWorkbench = computed(() => props.activeView === 'workbench')
|
||||||
const isRequestDetail = computed(() => props.activeView === 'requests' && props.detailMode)
|
const isRequestDetail = computed(() => ['requests', 'documents'].includes(props.activeView) && props.detailMode)
|
||||||
|
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
|
||||||
const isRequests = computed(() => props.activeView === 'requests')
|
const isRequests = computed(() => props.activeView === 'requests')
|
||||||
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
|
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
|
||||||
const isApproval = computed(() => props.activeView === 'approval')
|
const isApproval = computed(() => props.activeView === 'approval')
|
||||||
@@ -291,6 +306,21 @@ const requestKpis = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const documentKpis = computed(() => {
|
||||||
|
const summary = props.documentSummary ?? {}
|
||||||
|
const total = Number(summary.total ?? 0)
|
||||||
|
const toSubmit = Number(summary.toSubmit ?? 0)
|
||||||
|
const toProcess = Number(summary.toProcess ?? 0)
|
||||||
|
const archived = Number(summary.archived ?? 0)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: '#10b981' },
|
||||||
|
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
|
||||||
|
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
|
||||||
|
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const logsKpis = computed(() => {
|
const logsKpis = computed(() => {
|
||||||
const summary = props.logsSummary ?? {}
|
const summary = props.logsSummary ?? {}
|
||||||
const total = Number(summary.total ?? 0)
|
const total = Number(summary.total ?? 0)
|
||||||
|
|||||||
185
web/src/components/shared/ExpenseApplicationDialog.vue
Normal file
185
web/src/components/shared/ExpenseApplicationDialog.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="expense-application-mask" @click.self="emit('close')">
|
||||||
|
<section class="expense-application-dialog" role="dialog" aria-modal="true" aria-labelledby="expense-application-title">
|
||||||
|
<header class="application-dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="application-dialog-eyebrow">费用申请</span>
|
||||||
|
<h2 id="expense-application-title">发起申请</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="application-dialog-body">
|
||||||
|
<section class="application-input-panel">
|
||||||
|
<label class="application-field-label" for="application-intent-input">申请意图</label>
|
||||||
|
<textarea
|
||||||
|
id="application-intent-input"
|
||||||
|
v-model="draft"
|
||||||
|
rows="5"
|
||||||
|
placeholder="例如:申请下周去北京做客户现场验收,差旅预算18000元"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="application-example-row">
|
||||||
|
<button
|
||||||
|
v-for="example in APPLICATION_EXAMPLES"
|
||||||
|
:key="example"
|
||||||
|
class="application-example-chip"
|
||||||
|
type="button"
|
||||||
|
@click="applyExample(example)"
|
||||||
|
>
|
||||||
|
{{ example }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary-parse-btn" type="button" :disabled="parsing || !draft.trim()" @click="parseApplication">
|
||||||
|
<i :class="parsing ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-brain'"></i>
|
||||||
|
<span>{{ parsing ? '识别中' : '识别申请字段' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="error" class="application-error">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="application-ontology-panel">
|
||||||
|
<div class="ontology-panel-head">
|
||||||
|
<span>本体识别结果</span>
|
||||||
|
<strong>{{ confidenceLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!fields" class="ontology-empty-state">
|
||||||
|
<i class="mdi mdi-file-search-outline"></i>
|
||||||
|
<p>输入申请事项后,系统会调用本体解析费用场景、金额、时间、地点和附件要求。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="ontology-chip-row">
|
||||||
|
<span class="ontology-chip">scenario: {{ ontology?.scenario || '-' }}</span>
|
||||||
|
<span class="ontology-chip">intent: {{ ontology?.intent || '-' }}</span>
|
||||||
|
<span class="ontology-chip">{{ fields.documentTypeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="application-field-grid">
|
||||||
|
<div class="application-field-card">
|
||||||
|
<span>费用场景</span>
|
||||||
|
<strong>{{ fields.expenseTypeLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="application-field-card">
|
||||||
|
<span>申请金额</span>
|
||||||
|
<strong>{{ fields.amountDisplay }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="application-field-card">
|
||||||
|
<span>业务时间</span>
|
||||||
|
<strong>{{ fields.timeRange }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="application-field-card">
|
||||||
|
<span>业务地点</span>
|
||||||
|
<strong>{{ fields.location }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="application-field-card">
|
||||||
|
<span>申请人</span>
|
||||||
|
<strong>{{ fields.applicant }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="application-field-card">
|
||||||
|
<span>所属部门</span>
|
||||||
|
<strong>{{ fields.department }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="application-policy-strip" :class="fields.attachmentPolicy.level">
|
||||||
|
<i class="mdi mdi-paperclip-check"></i>
|
||||||
|
<div>
|
||||||
|
<strong>附件要求:{{ fields.attachmentPolicy.label }}</strong>
|
||||||
|
<p>{{ fields.attachmentPolicy.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="application-reason-block">
|
||||||
|
<span>申请事由</span>
|
||||||
|
<p>{{ fields.reason }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="fields.missingSlots.length" class="application-missing-block">
|
||||||
|
<span>待补充字段</span>
|
||||||
|
<div>
|
||||||
|
<em v-for="slot in fields.missingSlots" :key="slot.key">{{ slot.label }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="application-dialog-footer">
|
||||||
|
<button class="secondary-btn" type="button" @click="emit('close')">取消</button>
|
||||||
|
<button class="confirm-btn" type="button" :disabled="!fields" @click="confirmApplication">
|
||||||
|
<i class="mdi mdi-check-circle-outline"></i>
|
||||||
|
<span>确认本体字段</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
|
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||||
|
import {
|
||||||
|
APPLICATION_EXAMPLES,
|
||||||
|
buildApplicationFieldsFromOntology,
|
||||||
|
buildExpenseApplicationOntologyContext
|
||||||
|
} from '../../utils/expenseApplicationOntology.js'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'confirmed'])
|
||||||
|
|
||||||
|
const { currentUser } = useSystemState()
|
||||||
|
const draft = ref(APPLICATION_EXAMPLES[0])
|
||||||
|
const parsing = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const ontology = ref(null)
|
||||||
|
const fields = ref(null)
|
||||||
|
|
||||||
|
const confidenceLabel = computed(() => {
|
||||||
|
if (!ontology.value) return '待识别'
|
||||||
|
return `${Math.round(Number(ontology.value.confidence || 0) * 100)}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyExample(example) {
|
||||||
|
draft.value = example
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseApplication() {
|
||||||
|
const query = draft.value.trim()
|
||||||
|
if (!query) return
|
||||||
|
|
||||||
|
parsing.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const payload = await fetchOntologyParse({
|
||||||
|
query,
|
||||||
|
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
|
||||||
|
context_json: buildExpenseApplicationOntologyContext(currentUser.value || {})
|
||||||
|
})
|
||||||
|
ontology.value = payload
|
||||||
|
fields.value = buildApplicationFieldsFromOntology(payload, query, currentUser.value || {})
|
||||||
|
} catch (err) {
|
||||||
|
ontology.value = null
|
||||||
|
fields.value = null
|
||||||
|
error.value = err?.message || '申请字段识别失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
parsing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmApplication() {
|
||||||
|
if (!fields.value) return
|
||||||
|
emit('confirmed', {
|
||||||
|
ontology: ontology.value,
|
||||||
|
fields: fields.value,
|
||||||
|
sourceText: draft.value.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../../assets/styles/components/expense-application-dialog.css"></style>
|
||||||
@@ -46,18 +46,115 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="risk-rule-flow-svg"
|
class="risk-rule-flow-svg-viewport"
|
||||||
role="img"
|
@mousedown="onDragStart"
|
||||||
aria-label="风险规则流程说明"
|
@touchstart="onTouchStart"
|
||||||
v-html="displaySvg"
|
@dblclick="resetZoom"
|
||||||
></div>
|
>
|
||||||
|
<div
|
||||||
|
class="risk-rule-flow-svg-canvas"
|
||||||
|
:style="transformStyle"
|
||||||
|
v-html="displaySvg"
|
||||||
|
></div>
|
||||||
|
<div class="diagram-zoom-controls" @mousedown.stop @touchstart.stop>
|
||||||
|
<button class="zoom-btn" @click="zoomIn" title="放大">
|
||||||
|
<i class="mdi mdi-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button class="zoom-btn" @click="zoomOut" title="缩小">
|
||||||
|
<i class="mdi mdi-minus"></i>
|
||||||
|
</button>
|
||||||
|
<button class="zoom-btn" @click="resetZoom" title="重置">
|
||||||
|
<i class="mdi mdi-arrow-expand-all"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const scale = ref(1)
|
||||||
|
const translateX = ref(0)
|
||||||
|
const translateY = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
const transformStyle = computed(() => ({
|
||||||
|
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
transition: isDragging.value ? 'none' : 'transform 0.15s ease-out'
|
||||||
|
}))
|
||||||
|
|
||||||
|
function onDragStart(e) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.x = e.clientX - translateX.value
|
||||||
|
dragStart.y = e.clientY - translateY.value
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onDragging)
|
||||||
|
window.addEventListener('mouseup', onDragEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragging(e) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
translateX.value = e.clientX - dragStart.x
|
||||||
|
translateY.value = e.clientY - dragStart.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
isDragging.value = false
|
||||||
|
window.removeEventListener('mousemove', onDragging)
|
||||||
|
window.removeEventListener('mouseup', onDragEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e) {
|
||||||
|
if (e.touches.length !== 1) return
|
||||||
|
isDragging.value = true
|
||||||
|
const touch = e.touches[0]
|
||||||
|
dragStart.x = touch.clientX - translateX.value
|
||||||
|
dragStart.y = touch.clientY - translateY.value
|
||||||
|
|
||||||
|
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
window.addEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e) {
|
||||||
|
if (!isDragging.value || e.touches.length !== 1) return
|
||||||
|
e.preventDefault()
|
||||||
|
const touch = e.touches[0]
|
||||||
|
translateX.value = touch.clientX - dragStart.x
|
||||||
|
translateY.value = touch.clientY - dragStart.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
isDragging.value = false
|
||||||
|
window.removeEventListener('touchmove', onTouchMove)
|
||||||
|
window.removeEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
scale.value = Math.min(scale.value + 0.15, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
scale.value = Math.max(scale.value - 0.15, 0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
scale.value = 1
|
||||||
|
translateX.value = 0
|
||||||
|
translateY.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousemove', onDragging)
|
||||||
|
window.removeEventListener('mouseup', onDragEnd)
|
||||||
|
window.removeEventListener('touchmove', onTouchMove)
|
||||||
|
window.removeEventListener('touchend', onTouchEnd)
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
svg: { type: String, default: '' },
|
svg: { type: String, default: '' },
|
||||||
@@ -68,8 +165,7 @@ const props = defineProps({
|
|||||||
severityLabel: { type: String, default: '中风险' }
|
severityLabel: { type: String, default: '中风险' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const FONT =
|
const FONT = "Helvetica, Arial, sans-serif"
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, 'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
|
|
||||||
const TEXT = '#0d0d0d'
|
const TEXT = '#0d0d0d'
|
||||||
const MUTED = '#6e6e80'
|
const MUTED = '#6e6e80'
|
||||||
const NEUTRAL_LINE = '#cbd5e1'
|
const NEUTRAL_LINE = '#cbd5e1'
|
||||||
@@ -95,6 +191,16 @@ const PALETTES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DRAWIO_PALETTES = {
|
||||||
|
neutral: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||||
|
blue: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||||
|
yellow: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||||
|
green: { fill: '#ffffff', stroke: '#e2e8f0' },
|
||||||
|
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
|
||||||
|
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
|
||||||
|
high: { fill: '#fef2f2', stroke: '#fecaca' }
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeText(value, fallback = '') {
|
function normalizeText(value, fallback = '') {
|
||||||
return String(value || fallback || '').trim()
|
return String(value || fallback || '').trim()
|
||||||
}
|
}
|
||||||
@@ -156,15 +262,12 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
|
|||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function node(title, body, x, y, width, height, currentPalette = null) {
|
function node(title, body, x, y, width, height, type = 'blue') {
|
||||||
const border = currentPalette?.border || NEUTRAL_BORDER
|
const palette = DRAWIO_PALETTES[type] || DRAWIO_PALETTES.blue
|
||||||
const stripe = currentPalette?.accent || NEUTRAL_LINE
|
return `<g class="drawio-node">
|
||||||
const surface = currentPalette?.surface || '#ffffff'
|
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="3" ry="3" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
|
||||||
return `<g>
|
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
|
||||||
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="7" ry="7" fill="${surface}" stroke="${border}" stroke-width="1.2"/>
|
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', '#475569', 11)}
|
||||||
<rect x="${x}" y="${y}" width="3.5" height="${height}" rx="1.75" ry="1.75" fill="${stripe}"/>
|
|
||||||
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
|
|
||||||
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', MUTED, 11)}
|
|
||||||
</g>`
|
</g>`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,18 +275,19 @@ function diamond(title, body, x, y, width, height) {
|
|||||||
const cx = x + width / 2
|
const cx = x + width / 2
|
||||||
const cy = y + height / 2
|
const cy = y + height / 2
|
||||||
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
|
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
|
||||||
return `<g>
|
const palette = DRAWIO_PALETTES.yellow
|
||||||
<polygon points="${points}" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1.25"/>
|
return `<g class="drawio-node">
|
||||||
<text x="${cx}" y="${cy - 10}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
|
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
|
||||||
${textLines(wrapText(body, 8, 2), cx, cy + 11, 'middle', MUTED, 10.2)}
|
<text x="${cx}" y="${cy - 8}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
|
||||||
|
${textLines(wrapText(body, 8, 2), cx, cy + 12, 'middle', '#475569', 10.2)}
|
||||||
</g>`
|
</g>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function note(body) {
|
function note(body) {
|
||||||
return `<g>
|
return `<g class="drawio-node note">
|
||||||
<rect x="214" y="218" width="290" height="36" rx="7" ry="7" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
|
<rect x="214" y="218" width="290" height="36" rx="3" ry="3" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.2" stroke-dasharray="3,3" filter="url(#shadow)"/>
|
||||||
<text x="226" y="240" fill="${MUTED}" font-family="${FONT}" font-size="10" font-weight="500">BASIS</text>
|
<text x="226" y="240" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="700">BASIS</text>
|
||||||
${textLines(wrapText(body, 22, 1), 268, 240, 'start', TEXT, 10.2)}
|
${textLines(wrapText(body, 22, 1), 272, 240, 'start', '#334155', 10.5)}
|
||||||
</g>`
|
</g>`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,29 +366,47 @@ const displaySvg = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flow = flowModel.value
|
const flow = flowModel.value
|
||||||
const currentPalette = palette.value
|
const severity = props.severity
|
||||||
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
<pattern id="grid" width="16" height="16" patternUnits="userSpaceOnUse">
|
||||||
<polygon points="0 0, 10 3.5, 0 7" fill="${NEUTRAL_LINE}"/>
|
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#e8ecef" stroke-width="0.75"/>
|
||||||
|
</pattern>
|
||||||
|
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||||
|
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#666666"/>
|
||||||
</marker>
|
</marker>
|
||||||
|
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.08" />
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="760" height="280" fill="#ffffff"/>
|
<rect width="760" height="280" fill="#ffffff"/>
|
||||||
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
|
<rect width="760" height="280" fill="url(#grid)"/>
|
||||||
<text x="34" y="43" fill="${MUTED}" font-family="${FONT}" font-size="11" font-weight="500">RULE FLOW</text>
|
<rect x="0.5" y="0.5" width="759.5" height="279.5" rx="6" fill="none" stroke="#cbd5e1" stroke-width="1"/>
|
||||||
${node('业务输入', flow.start, 48, 118, 124, 60)}
|
|
||||||
${node('字段取数', '读取字段证据', 214, 118, 132, 60)}
|
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing="0.05em">RULE FLOW CANVAS</text>
|
||||||
|
|
||||||
|
${node('业务输入', flow.start, 48, 118, 124, 60, 'neutral')}
|
||||||
|
${node('字段取数', '读取字段证据', 214, 118, 132, 60, 'blue')}
|
||||||
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
|
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
|
||||||
${node('继续流转', flow.pass, 562, 74, 126, 60)}
|
${node('继续流转', flow.pass, 562, 74, 126, 60, 'green')}
|
||||||
${node('进入复核', flow.fail, 562, 190, 126, 62, currentPalette)}
|
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
|
||||||
${note(flow.basis)}
|
${note(flow.basis)}
|
||||||
<line x1="172" y1="148" x2="214" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
|
|
||||||
<line x1="346" y1="148" x2="392" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
|
<line x1="172" y1="148" x2="214" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||||
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
|
<line x1="346" y1="148" x2="392" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||||
<text x="534" y="119" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="400">否</text>
|
|
||||||
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
|
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||||
<text x="534" y="195" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="600">是</text>
|
<g>
|
||||||
|
<rect x="521" y="108" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
|
||||||
|
<text x="532" y="120" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">否</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||||
|
<g>
|
||||||
|
<rect x="521" y="187" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
|
||||||
|
<text x="532" y="199" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">是</text>
|
||||||
|
</g>
|
||||||
</svg>`
|
</svg>`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -294,10 +416,9 @@ const displaySvg = computed(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #e2e8f0;
|
border: none;
|
||||||
border-radius: 8px;
|
background: transparent;
|
||||||
background: #ffffff;
|
padding: 0;
|
||||||
padding: 14px;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,30 +563,84 @@ const displaySvg = computed(() => {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-rule-flow-image,
|
.risk-rule-flow-svg-viewport {
|
||||||
.risk-rule-flow-svg {
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-flow-svg-viewport:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-flow-svg-canvas {
|
||||||
|
width: 760px;
|
||||||
|
height: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-flow-svg-canvas :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rule-flow-image {
|
||||||
width: min(760px, 100%);
|
width: min(760px, 100%);
|
||||||
display: block;
|
display: block;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
|
||||||
|
|
||||||
.risk-rule-flow-image {
|
|
||||||
height: auto;
|
height: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-rule-flow-svg :deep(svg) {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-rule-flow-svg :deep(*) {
|
|
||||||
pointer-events: none !important;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.risk-rule-flow-content {
|
.risk-rule-flow-content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
792
web/src/components/shared/RiskRuleTestDialog.vue
Normal file
792
web/src/components/shared/RiskRuleTestDialog.vue
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="risk-sim-dialog">
|
||||||
|
<div v-if="open" class="risk-sim-backdrop" @click.self="handleClose">
|
||||||
|
<section class="risk-sim-modal" role="dialog" aria-modal="true" aria-label="风险规则仿真测试">
|
||||||
|
<header class="risk-sim-head">
|
||||||
|
<div class="risk-sim-title">
|
||||||
|
<span>独立仿真测试</span>
|
||||||
|
<h3>{{ rule?.name || '风险规则' }}</h3>
|
||||||
|
<p>临时对话只做单据识别和风险规则执行,不创建报销单,不写入主工作台会话。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="risk-sim-icon-btn" aria-label="关闭" @click="handleClose">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="risk-sim-meta">
|
||||||
|
<span>版本:{{ testVersion }}</span>
|
||||||
|
<span :class="severityTone">{{ rule?.riskRuleSeverityLabel || '中风险' }}</span>
|
||||||
|
<span>{{ requiresAttachment ? '测试需附件' : '文字测试' }}</span>
|
||||||
|
<span>{{ latestSummary?.test_passed ? '测试结论已保存' : '测试结论未保存' }}</span>
|
||||||
|
<span>Session:{{ sessionShortId }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="risk-sim-main">
|
||||||
|
<section class="risk-sim-dialog-panel">
|
||||||
|
<div ref="messageListRef" class="risk-sim-message-list" aria-live="polite">
|
||||||
|
<article
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="risk-sim-message-row"
|
||||||
|
:class="message.role"
|
||||||
|
>
|
||||||
|
<span class="risk-sim-avatar" aria-hidden="true">
|
||||||
|
<i :class="message.role === 'assistant' ? 'mdi mdi-shield-search-outline' : 'mdi mdi-account-outline'"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="risk-sim-bubble">
|
||||||
|
<header>
|
||||||
|
<strong>{{ message.role === 'assistant' ? '风险仿真助手' : '我' }}</strong>
|
||||||
|
<time>{{ message.time }}</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="message.text">{{ message.text }}</p>
|
||||||
|
|
||||||
|
<div v-if="message.attachments?.length" class="risk-sim-message-files">
|
||||||
|
<span v-for="file in message.attachments" :key="`${message.id}-${file.id}`">
|
||||||
|
<i class="mdi mdi-file-document-outline"></i>
|
||||||
|
{{ file.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.result" class="risk-sim-result-card" :class="message.result.severity">
|
||||||
|
<div class="risk-sim-result-head">
|
||||||
|
<div>
|
||||||
|
<span>{{ message.result.ready === false ? '流程状态' : '识别结果' }}</span>
|
||||||
|
<strong>
|
||||||
|
{{ message.result.ready === false ? '待补充后再判断' : (message.result.hit ? '命中风险' : '未命中风险') }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<b>{{ message.result.severity_label }}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="message.result.blocking_reason" class="risk-sim-blocking-message">
|
||||||
|
{{ message.result.blocking_reason }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="message.result.message" class="risk-sim-result-message">
|
||||||
|
{{ message.result.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="risk-sim-field-grid">
|
||||||
|
<div v-for="item in buildResultFields(message.result)" :key="item.key">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="buildEvidenceItems(message.result).length" class="risk-sim-evidence">
|
||||||
|
<span>判断依据</span>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in buildEvidenceItems(message.result)" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.result.missing_fields?.length" class="risk-sim-missing-fields">
|
||||||
|
<span>待补充字段</span>
|
||||||
|
<b v-for="field in message.result.missing_fields" :key="field.key">
|
||||||
|
{{ formatFieldLabel(field) }}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-if="busyAction === 'simulate' || recognitionBusy" class="risk-sim-message-row assistant">
|
||||||
|
<span class="risk-sim-avatar" aria-hidden="true">
|
||||||
|
<i class="mdi mdi-shield-search-outline"></i>
|
||||||
|
</span>
|
||||||
|
<div class="risk-sim-bubble">
|
||||||
|
<header>
|
||||||
|
<strong>风险仿真助手</strong>
|
||||||
|
<time>{{ currentTime }}</time>
|
||||||
|
</header>
|
||||||
|
<p class="risk-sim-thinking">
|
||||||
|
<i class="mdi mdi-loading mdi-spin"></i>
|
||||||
|
{{ recognitionBusy ? '正在识别临时单据...' : '正在调用规则执行器识别风险...' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="requiresAttachment && uploadedFiles.length" class="risk-sim-file-strip">
|
||||||
|
<span>本轮临时附件</span>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
v-for="file in uploadedFiles"
|
||||||
|
:key="file.id"
|
||||||
|
type="button"
|
||||||
|
class="risk-sim-file-chip"
|
||||||
|
:class="file.status"
|
||||||
|
:title="`${file.name} · ${formatFileSize(file.size)}`"
|
||||||
|
@click="removeFile(file.id)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-file-document-outline"></i>
|
||||||
|
<span>{{ file.name }}</span>
|
||||||
|
<em>{{ resolveFileStatusLabel(file) }}</em>
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="risk-sim-composer" :class="{ 'text-only': !requiresAttachment }">
|
||||||
|
<input
|
||||||
|
v-if="requiresAttachment"
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="risk-sim-file-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="requiresAttachment"
|
||||||
|
type="button"
|
||||||
|
class="risk-sim-tool-btn"
|
||||||
|
:disabled="busy"
|
||||||
|
aria-label="上传临时单据"
|
||||||
|
@click="triggerFilePick"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-paperclip"></i>
|
||||||
|
</button>
|
||||||
|
<div class="risk-sim-composer-shell">
|
||||||
|
<textarea
|
||||||
|
ref="composerRef"
|
||||||
|
v-model="draft"
|
||||||
|
:disabled="busy"
|
||||||
|
rows="1"
|
||||||
|
:placeholder="composerPlaceholder"
|
||||||
|
@keydown.enter.exact.prevent="sendMessage"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="risk-sim-send-btn"
|
||||||
|
:disabled="busy || !canSend"
|
||||||
|
:title="sendBlockedReason"
|
||||||
|
aria-label="执行风险识别"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<i :class="busyAction === 'simulate' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="risk-sim-context-panel">
|
||||||
|
<section>
|
||||||
|
<span>仿真流程</span>
|
||||||
|
<div class="risk-sim-step-list">
|
||||||
|
<div
|
||||||
|
v-for="step in simulationSteps"
|
||||||
|
:key="step.key"
|
||||||
|
class="risk-sim-step"
|
||||||
|
:class="step.status"
|
||||||
|
>
|
||||||
|
<i :class="step.icon"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ step.label }}</strong>
|
||||||
|
<p>{{ step.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="recognizedDocuments.length || recognitionError">
|
||||||
|
<span>单据识别</span>
|
||||||
|
<div class="risk-sim-recognition-list">
|
||||||
|
<article v-for="item in recognizedDocuments" :key="item.filename">
|
||||||
|
<strong>{{ item.document_type_label || item.scene_label || '已识别单据' }}</strong>
|
||||||
|
<p>{{ item.filename }}</p>
|
||||||
|
<small>{{ buildDocumentBrief(item) }}</small>
|
||||||
|
</article>
|
||||||
|
<p v-if="recognitionError" class="risk-sim-error-text">{{ recognitionError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span>规则边界</span>
|
||||||
|
<strong>{{ requiresAttachment ? '附件和文字合并判断' : '仅使用文字事实判断' }}</strong>
|
||||||
|
<p>{{ boundaryDescription }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span>使用字段</span>
|
||||||
|
<div class="risk-sim-field-list">
|
||||||
|
<b v-for="field in displayFields" :key="field.key">{{ field.label }}</b>
|
||||||
|
<em v-if="!displayFields.length">当前规则未声明字段</em>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span>关闭后清理</span>
|
||||||
|
<p>聊天记录、临时附件、识别结果会从弹窗内存中清空。</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="risk-sim-foot">
|
||||||
|
<span>{{ lastSimulationHint }}</span>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="risk-sim-secondary-btn" :disabled="busy" @click="resetConversation">
|
||||||
|
<i class="mdi mdi-refresh"></i>
|
||||||
|
<span>清空本轮</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="risk-sim-primary-btn"
|
||||||
|
:disabled="busy || !activeSimulationResult?.ready"
|
||||||
|
@click="saveSimulationConclusion"
|
||||||
|
>
|
||||||
|
<i :class="busyAction === 'report' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check-decagram-outline'"></i>
|
||||||
|
<span>{{ busyAction === 'report' ? '保存中' : '确认测试通过' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
confirmRiskRuleTestReport,
|
||||||
|
runRiskRuleSampleTest,
|
||||||
|
simulateRiskRuleTest
|
||||||
|
} from '../../services/agentAssets.js'
|
||||||
|
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||||
|
import { useToast } from '../../composables/useToast.js'
|
||||||
|
import {
|
||||||
|
createId,
|
||||||
|
formatFileSize,
|
||||||
|
formatTestError,
|
||||||
|
formatTime
|
||||||
|
} from './riskRuleTestDialogUtils.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
rule: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'report-saved'])
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const messageListRef = ref(null)
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
const composerRef = ref(null)
|
||||||
|
const sessionId = ref('')
|
||||||
|
const draft = ref('')
|
||||||
|
const messages = ref([])
|
||||||
|
const uploadedFiles = ref([])
|
||||||
|
const recognizedDocuments = ref([])
|
||||||
|
const recognitionError = ref('')
|
||||||
|
const activeSimulationResult = ref(null)
|
||||||
|
const latestSummary = ref(null)
|
||||||
|
const busyAction = ref('')
|
||||||
|
|
||||||
|
const requiresAttachment = computed(() => Boolean(props.rule?.riskRuleRequiresAttachment))
|
||||||
|
const recognitionBusy = computed(() => busyAction.value === 'recognize')
|
||||||
|
const busy = computed(() => Boolean(busyAction.value))
|
||||||
|
const hasPendingFiles = computed(() => uploadedFiles.value.some((file) => file.status === 'recognizing'))
|
||||||
|
const hasRecognizedFiles = computed(() => uploadedFiles.value.some((file) => file.status === 'recognized'))
|
||||||
|
const hasFailedOnlyFiles = computed(() => uploadedFiles.value.length > 0 && uploadedFiles.value.every((file) => file.status === 'failed'))
|
||||||
|
const canSend = computed(() => {
|
||||||
|
if (hasPendingFiles.value) return false
|
||||||
|
const hasText = Boolean(draft.value.trim())
|
||||||
|
if (requiresAttachment.value) {
|
||||||
|
return hasText && uploadedFiles.value.length > 0
|
||||||
|
}
|
||||||
|
return hasText
|
||||||
|
})
|
||||||
|
const fields = computed(() => (Array.isArray(props.rule?.riskRuleFields) ? props.rule.riskRuleFields : []))
|
||||||
|
const displayFields = computed(() => fields.value.map((field) => ({
|
||||||
|
key: field.key,
|
||||||
|
label: formatFieldLabel(field)
|
||||||
|
})))
|
||||||
|
const composerPlaceholder = computed(() => requiresAttachment.value
|
||||||
|
? '填写测试意图并上传附件,例如:请检查这张酒店发票是否与行程城市一致'
|
||||||
|
: '描述测试事实,例如:酒店发票城市上海,申报目的地北京,金额580元')
|
||||||
|
const boundaryDescription = computed(() => requiresAttachment.value
|
||||||
|
? '附件选择后不会立即识别,点击发送时才会和输入内容一起进入仿真;不会创建报销草稿或写入风险标记。'
|
||||||
|
: '这条规则不需要上传附件,测试窗口只根据输入文字执行规则;不会创建报销草稿或影响审批流。')
|
||||||
|
const testVersion = computed(() => props.rule?.workingVersion || props.rule?.displayVersion || props.rule?.version || '-')
|
||||||
|
const sessionShortId = computed(() => sessionId.value ? sessionId.value.slice(-8).toUpperCase() : '-')
|
||||||
|
const currentTime = computed(() => formatTime())
|
||||||
|
const severityTone = computed(() => `tone-${props.rule?.riskRuleSeverity || 'medium'}`)
|
||||||
|
const sendBlockedReason = computed(() => {
|
||||||
|
if (hasPendingFiles.value) return '单据识别中,请稍后执行风险识别。'
|
||||||
|
if (requiresAttachment.value && !uploadedFiles.value.length) return '这条规则要求上传测试附件。'
|
||||||
|
if (requiresAttachment.value && !draft.value.trim()) return '请填写测试意图或关键事实,附件会和文字一起判断。'
|
||||||
|
if (!draft.value.trim()) return '请先描述测试单据或测试事实。'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const simulationSteps = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'recognize',
|
||||||
|
label: '1. 单据识别',
|
||||||
|
description: buildRecognitionStepDescription(),
|
||||||
|
status: resolveRecognitionStepStatus(),
|
||||||
|
icon: recognitionBusy.value ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-text-recognition'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fields',
|
||||||
|
label: '2. 字段确认',
|
||||||
|
description: buildFieldStepDescription(),
|
||||||
|
status: resolveFieldStepStatus(),
|
||||||
|
icon: 'mdi mdi-format-list-checks'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'execute',
|
||||||
|
label: '3. 规则执行',
|
||||||
|
description: activeSimulationResult.value?.ready
|
||||||
|
? '已使用规则执行器完成判断。'
|
||||||
|
: '字段齐备后才会执行规则。',
|
||||||
|
status: activeSimulationResult.value?.ready ? 'done' : 'pending',
|
||||||
|
icon: 'mdi mdi-shield-check-outline'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const lastSimulationHint = computed(() => {
|
||||||
|
if (!activeSimulationResult.value) {
|
||||||
|
return '本窗口是独立临时会话,关闭后会清空聊天记录和上传文件。'
|
||||||
|
}
|
||||||
|
if (activeSimulationResult.value.ready === false) {
|
||||||
|
return `最近一次仿真:${activeSimulationResult.value.summary || '待补充字段'}`
|
||||||
|
}
|
||||||
|
return activeSimulationResult.value.hit
|
||||||
|
? `最近一次仿真:命中${activeSimulationResult.value.severity_label}`
|
||||||
|
: '最近一次仿真:未命中风险'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
initializeSession()
|
||||||
|
} else {
|
||||||
|
destroySession()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function initializeSession() {
|
||||||
|
sessionId.value = createId()
|
||||||
|
latestSummary.value = props.rule?.latestTestSummary || null
|
||||||
|
draft.value = ''
|
||||||
|
uploadedFiles.value = []
|
||||||
|
recognizedDocuments.value = []
|
||||||
|
recognitionError.value = ''
|
||||||
|
activeSimulationResult.value = null
|
||||||
|
busyAction.value = ''
|
||||||
|
messages.value = [buildMessage('assistant', buildWelcomeMessage())]
|
||||||
|
clearFileInput()
|
||||||
|
nextTick(() => {
|
||||||
|
scrollMessagesToBottom()
|
||||||
|
composerRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroySession() {
|
||||||
|
draft.value = ''
|
||||||
|
messages.value = []
|
||||||
|
uploadedFiles.value = []
|
||||||
|
recognizedDocuments.value = []
|
||||||
|
recognitionError.value = ''
|
||||||
|
activeSimulationResult.value = null
|
||||||
|
busyAction.value = ''
|
||||||
|
sessionId.value = ''
|
||||||
|
clearFileInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConversation() {
|
||||||
|
initializeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
destroySession()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFilePick() {
|
||||||
|
if (!requiresAttachment.value) return
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(event) {
|
||||||
|
if (!requiresAttachment.value) {
|
||||||
|
clearFileInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const input = event.target
|
||||||
|
const incoming = Array.from(input?.files || [])
|
||||||
|
if (!incoming.length) return
|
||||||
|
const nextFiles = incoming.map((file) => ({
|
||||||
|
id: createId(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
contentType: file.type || '',
|
||||||
|
status: 'pending',
|
||||||
|
statusText: '待发送',
|
||||||
|
ocrDocument: null,
|
||||||
|
error: '',
|
||||||
|
file
|
||||||
|
}))
|
||||||
|
uploadedFiles.value = [...uploadedFiles.value, ...nextFiles].slice(0, 12)
|
||||||
|
clearFileInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(fileId) {
|
||||||
|
uploadedFiles.value = uploadedFiles.value.filter((file) => file.id !== fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!props.rule?.id || !canSend.value || busy.value) return
|
||||||
|
const activeSessionId = sessionId.value
|
||||||
|
const text = draft.value.trim()
|
||||||
|
const runFiles = requiresAttachment.value ? uploadedFiles.value.slice() : []
|
||||||
|
messages.value.push(buildMessage('user', text, { attachments: runFiles }))
|
||||||
|
draft.value = ''
|
||||||
|
await nextTick()
|
||||||
|
scrollMessagesToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let attachments = []
|
||||||
|
if (requiresAttachment.value) {
|
||||||
|
const filesForRecognition = runFiles.filter((file) => file.status !== 'recognized')
|
||||||
|
if (filesForRecognition.length) {
|
||||||
|
busyAction.value = 'recognize'
|
||||||
|
await recognizeTemporaryFiles(filesForRecognition, activeSessionId)
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
}
|
||||||
|
attachments = runFiles.map(toAttachmentPayload)
|
||||||
|
}
|
||||||
|
busyAction.value = 'simulate'
|
||||||
|
const result = await simulateRiskRuleTest(props.rule.id, {
|
||||||
|
version: testVersion.value,
|
||||||
|
message: text,
|
||||||
|
attachments
|
||||||
|
})
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
activeSimulationResult.value = result
|
||||||
|
messages.value.push(buildMessage('assistant', result.summary, { result }))
|
||||||
|
} catch (error) {
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
messages.value.push(buildMessage('assistant', formatTestError(error, '仿真识别失败,请稍后重试。')))
|
||||||
|
} finally {
|
||||||
|
if (isActiveSession(activeSessionId)) {
|
||||||
|
busyAction.value = ''
|
||||||
|
await nextTick()
|
||||||
|
scrollMessagesToBottom()
|
||||||
|
composerRef.value?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSimulationConclusion() {
|
||||||
|
if (!props.rule?.id || !activeSimulationResult.value?.ready || busy.value) return
|
||||||
|
const activeSessionId = sessionId.value
|
||||||
|
busyAction.value = 'report'
|
||||||
|
try {
|
||||||
|
const sample = await runRiskRuleSampleTest(props.rule.id, {
|
||||||
|
version: testVersion.value,
|
||||||
|
cases: []
|
||||||
|
})
|
||||||
|
if (!sample?.passed) {
|
||||||
|
messages.value.push(buildMessage('assistant', '系统样例复核未通过,暂不能保存测试通过结论。请调整规则后重新仿真。'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const report = await confirmRiskRuleTestReport(props.rule.id, {
|
||||||
|
version: testVersion.value,
|
||||||
|
confirm_passed: true,
|
||||||
|
note: '通过独立对话仿真后确认测试通过;聊天记录和临时附件不保存。'
|
||||||
|
})
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
latestSummary.value = {
|
||||||
|
...(latestSummary.value || {}),
|
||||||
|
sample,
|
||||||
|
report,
|
||||||
|
test_passed: true
|
||||||
|
}
|
||||||
|
emit('report-saved', latestSummary.value)
|
||||||
|
messages.value.push(buildMessage('assistant', '测试通过结论已保存。这个动作只更新规则生命周期状态,不保存本轮聊天记录或上传文件。'))
|
||||||
|
} catch (error) {
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
messages.value.push(buildMessage('assistant', formatTestError(error, '测试结论保存失败,请稍后重试。')))
|
||||||
|
} finally {
|
||||||
|
if (isActiveSession(activeSessionId)) {
|
||||||
|
busyAction.value = ''
|
||||||
|
await nextTick()
|
||||||
|
scrollMessagesToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recognizeTemporaryFiles(files, activeSessionId) {
|
||||||
|
if (!files.length) return
|
||||||
|
recognitionError.value = ''
|
||||||
|
files.forEach((file) => {
|
||||||
|
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||||||
|
if (!target) return
|
||||||
|
target.status = 'recognizing'
|
||||||
|
target.statusText = '识别中'
|
||||||
|
target.error = ''
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const payload = await recognizeOcrFiles(files.map((file) => file.file), {
|
||||||
|
timeoutMs: 90000,
|
||||||
|
timeoutMessage: '单据 OCR 识别超时,请补充关键字段后再执行规则。'
|
||||||
|
})
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
const documents = normalizeOcrDocuments(payload)
|
||||||
|
recognizedDocuments.value = mergeRecognizedDocuments(recognizedDocuments.value, documents)
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
const document = documents[index] || null
|
||||||
|
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||||||
|
if (!target) return
|
||||||
|
if (document && documentHasMeaningfulText(document)) {
|
||||||
|
target.status = 'recognized'
|
||||||
|
target.statusText = '已识别'
|
||||||
|
target.ocrDocument = document
|
||||||
|
target.error = ''
|
||||||
|
} else {
|
||||||
|
target.status = 'failed'
|
||||||
|
target.statusText = '识别不足'
|
||||||
|
target.ocrDocument = document
|
||||||
|
target.error = '未提取到足够文本'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const recognizedCount = files.filter((file) => {
|
||||||
|
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||||||
|
return target?.status === 'recognized'
|
||||||
|
}).length
|
||||||
|
messages.value.push(buildMessage(
|
||||||
|
'assistant',
|
||||||
|
recognizedCount
|
||||||
|
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
|
||||||
|
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
|
||||||
|
))
|
||||||
|
} catch (error) {
|
||||||
|
if (!isActiveSession(activeSessionId)) return
|
||||||
|
recognitionError.value = formatTestError(error, '单据识别失败,请补充关键字段后再执行规则。')
|
||||||
|
files.forEach((file) => {
|
||||||
|
const target = uploadedFiles.value.find((item) => item.id === file.id)
|
||||||
|
if (!target) return
|
||||||
|
target.status = 'failed'
|
||||||
|
target.statusText = '识别失败'
|
||||||
|
target.error = recognitionError.value
|
||||||
|
})
|
||||||
|
messages.value.push(buildMessage('assistant', recognitionError.value))
|
||||||
|
} finally {
|
||||||
|
if (isActiveSession(activeSessionId)) {
|
||||||
|
await nextTick()
|
||||||
|
scrollMessagesToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage(role, text, extra = {}) {
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
role,
|
||||||
|
text,
|
||||||
|
time: formatTime(),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultFields(result) {
|
||||||
|
const values = result?.field_values && typeof result.field_values === 'object'
|
||||||
|
? result.field_values
|
||||||
|
: {}
|
||||||
|
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: formatFieldLabel(fields.value.find((field) => field.key === key) || { key }),
|
||||||
|
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvidenceItems(result) {
|
||||||
|
const evidence = result?.evidence && typeof result.evidence === 'object'
|
||||||
|
? result.evidence
|
||||||
|
: {}
|
||||||
|
const items = []
|
||||||
|
if (Array.isArray(evidence.failed_conditions)) {
|
||||||
|
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
|
||||||
|
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
|
||||||
|
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
|
||||||
|
items.push(`${formatFieldName(condition.left)}:${left};${formatFieldName(condition.right)}:${right}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (Array.isArray(evidence.missing_fields)) {
|
||||||
|
evidence.missing_fields.slice(0, 5).forEach((field) => {
|
||||||
|
items.push(`${formatFieldName(field)} 缺失`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (Array.isArray(evidence.keyword_hits)) {
|
||||||
|
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
|
||||||
|
}
|
||||||
|
if (evidence.condition_summary) {
|
||||||
|
items.push(String(evidence.condition_summary))
|
||||||
|
}
|
||||||
|
return [...new Set(items)].slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldLabel(field) {
|
||||||
|
const key = String(field?.key || '').trim()
|
||||||
|
const label = String(field?.display || field?.label || '').trim()
|
||||||
|
if (!key) return label || '-'
|
||||||
|
if (!label || label === key) return key
|
||||||
|
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldName(key) {
|
||||||
|
return formatFieldLabel(fields.value.find((field) => field.key === key) || { key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAttachmentPayload(file) {
|
||||||
|
const document = file.ocrDocument || {}
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
content_type: file.contentType,
|
||||||
|
note: file.error || '',
|
||||||
|
recognition_status: file.status,
|
||||||
|
ocr_text: document.text || '',
|
||||||
|
summary: document.summary || '',
|
||||||
|
document_type: document.document_type || '',
|
||||||
|
document_type_label: document.document_type_label || '',
|
||||||
|
scene_code: document.scene_code || '',
|
||||||
|
scene_label: document.scene_label || '',
|
||||||
|
avg_score: document.avg_score || 0,
|
||||||
|
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOcrDocuments(payload) {
|
||||||
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||||
|
return documents.map((item) => ({
|
||||||
|
filename: String(item?.filename || '').trim(),
|
||||||
|
summary: String(item?.summary || '').trim(),
|
||||||
|
text: String(item?.text || '').trim(),
|
||||||
|
avg_score: Number(item?.avg_score || 0),
|
||||||
|
document_type: String(item?.document_type || 'other').trim() || 'other',
|
||||||
|
document_type_label: String(item?.document_type_label || '').trim(),
|
||||||
|
scene_code: String(item?.scene_code || 'other').trim() || 'other',
|
||||||
|
scene_label: String(item?.scene_label || '').trim(),
|
||||||
|
document_fields: Array.isArray(item?.document_fields)
|
||||||
|
? item.document_fields
|
||||||
|
.map((field) => ({
|
||||||
|
key: String(field?.key || '').trim(),
|
||||||
|
label: String(field?.label || '').trim(),
|
||||||
|
value: String(field?.value || '').trim()
|
||||||
|
}))
|
||||||
|
.filter((field) => field.key && field.label && field.value)
|
||||||
|
: [],
|
||||||
|
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRecognizedDocuments(current, incoming) {
|
||||||
|
const next = [...current]
|
||||||
|
incoming.forEach((document) => {
|
||||||
|
const index = next.findIndex((item) => item.filename === document.filename)
|
||||||
|
if (index >= 0) {
|
||||||
|
next.splice(index, 1, document)
|
||||||
|
} else {
|
||||||
|
next.push(document)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentHasMeaningfulText(document) {
|
||||||
|
return Boolean(
|
||||||
|
String(document?.text || document?.summary || '').trim() ||
|
||||||
|
(Array.isArray(document?.document_fields) && document.document_fields.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentBrief(document) {
|
||||||
|
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||||
|
if (fields.length) {
|
||||||
|
return fields.slice(0, 4).map((field) => `${field.label}:${field.value}`).join(';')
|
||||||
|
}
|
||||||
|
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileStatusLabel(file) {
|
||||||
|
return file.statusText || {
|
||||||
|
pending: '待发送',
|
||||||
|
recognizing: '识别中',
|
||||||
|
recognized: '已识别',
|
||||||
|
failed: '识别失败'
|
||||||
|
}[file.status] || '待识别'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecognitionStepDescription() {
|
||||||
|
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
|
||||||
|
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
|
||||||
|
if (hasRecognizedFiles.value) return `已识别 ${recognizedDocuments.value.length} 份临时单据。`
|
||||||
|
if (hasFailedOnlyFiles.value) return '识别不足,请在对话中补充字段。'
|
||||||
|
if (uploadedFiles.value.length) return '附件已加入本轮,点击发送后会和文字一起识别。'
|
||||||
|
return '需要上传测试附件,并填写测试意图后执行。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecognitionStepStatus() {
|
||||||
|
if (!requiresAttachment.value) return 'done'
|
||||||
|
if (recognitionBusy.value) return 'running'
|
||||||
|
if (hasRecognizedFiles.value) return 'done'
|
||||||
|
if (hasFailedOnlyFiles.value || recognitionError.value) return 'warning'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldStepDescription() {
|
||||||
|
if (activeSimulationResult.value?.recognized_fields?.length) {
|
||||||
|
return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。`
|
||||||
|
}
|
||||||
|
if (draft.value.trim()) return '将使用你输入的文字抽取测试字段。'
|
||||||
|
return '识别完成或补充字段后进入确认。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldStepStatus() {
|
||||||
|
if (activeSimulationResult.value?.ready) return 'done'
|
||||||
|
if (activeSimulationResult.value?.missing_fields?.length) return 'warning'
|
||||||
|
if (hasRecognizedFiles.value || draft.value.trim()) return 'running'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWelcomeMessage() {
|
||||||
|
if (requiresAttachment.value) {
|
||||||
|
return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。'
|
||||||
|
}
|
||||||
|
return '这条规则不需要上传附件。你可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFileInput() {
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollMessagesToBottom() {
|
||||||
|
const target = messageListRef.value
|
||||||
|
if (target) {
|
||||||
|
target.scrollTop = target.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveSession(activeSessionId) {
|
||||||
|
return props.open && activeSessionId && activeSessionId === sessionId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style src="../../assets/styles/components/risk-rule-test-dialog.css"></style>
|
||||||
|
|
||||||
28
web/src/components/shared/riskRuleTestDialogUtils.js
Normal file
28
web/src/components/shared/riskRuleTestDialogUtils.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export function createId() {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(size) {
|
||||||
|
const value = Number(size || 0)
|
||||||
|
if (value < 1024) return `${value}B`
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTestError(error, fallback) {
|
||||||
|
const message = String(error?.message || '').trim()
|
||||||
|
if (/not\s*found/i.test(message)) {
|
||||||
|
return '测试接口暂未加载或规则详情已失效,请刷新规则详情后再试。'
|
||||||
|
}
|
||||||
|
return message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime() {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date())
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export function useAppShell() {
|
|||||||
})
|
})
|
||||||
const smartEntrySessionId = ref(0)
|
const smartEntrySessionId = ref(0)
|
||||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||||
|
const selectedRequestSnapshot = ref(null)
|
||||||
|
|
||||||
const { activeView, currentView, setView } = useNavigation()
|
const { activeView, currentView, setView } = useNavigation()
|
||||||
const {
|
const {
|
||||||
@@ -60,14 +61,32 @@ export function useAppShell() {
|
|||||||
const rawRequest = requests.value.find(
|
const rawRequest = requests.value.find(
|
||||||
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
|
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
|
||||||
)
|
)
|
||||||
return normalizeRequestForUi(rawRequest)
|
const normalizedRequest = normalizeRequestForUi(rawRequest)
|
||||||
|
if (normalizedRequest) {
|
||||||
|
return normalizedRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value)
|
||||||
|
if (
|
||||||
|
snapshot
|
||||||
|
&& (
|
||||||
|
String(snapshot.claimId || '').trim() === requestId
|
||||||
|
|| String(snapshot.id || '').trim() === requestId
|
||||||
|
|| String(snapshot.documentNo || '').trim() === requestId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const detailMode = computed(() => route.name === 'app-request-detail')
|
const detailMode = computed(() => ['app-request-detail', 'app-document-detail'].includes(route.name))
|
||||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||||
|
|
||||||
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
|
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
|
||||||
|
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
|
||||||
const workbenchActive = computed(() => activeView.value === 'workbench')
|
const workbenchActive = computed(() => activeView.value === 'workbench')
|
||||||
|
|
||||||
watch(requestsListActive, (isActive, wasActive) => {
|
watch(requestsListActive, (isActive, wasActive) => {
|
||||||
@@ -76,6 +95,12 @@ export function useAppShell() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(documentsListActive, (isActive, wasActive) => {
|
||||||
|
if (isActive && !wasActive) {
|
||||||
|
void reloadRequests()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(workbenchActive, (isActive, wasActive) => {
|
watch(workbenchActive, (isActive, wasActive) => {
|
||||||
if (isActive && !wasActive) {
|
if (isActive && !wasActive) {
|
||||||
void reloadRequests()
|
void reloadRequests()
|
||||||
@@ -237,21 +262,23 @@ export function useAppShell() {
|
|||||||
smartEntryOpen.value = false
|
smartEntryOpen.value = false
|
||||||
void refreshApprovalInbox()
|
void refreshApprovalInbox()
|
||||||
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||||
router.push({ name: 'app-requests' })
|
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
|
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRequestDetail(request) {
|
function openRequestDetail(request) {
|
||||||
|
selectedRequestSnapshot.value = request || null
|
||||||
|
const routeName = activeView.value === 'documents' ? 'app-document-detail' : 'app-request-detail'
|
||||||
router.push({
|
router.push({
|
||||||
name: 'app-request-detail',
|
name: routeName,
|
||||||
params: { requestId: request.claimId || request.id }
|
params: { requestId: request.claimId || request.id }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRequestDetail() {
|
function closeRequestDetail() {
|
||||||
router.push({ name: 'app-requests' })
|
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRequestUpdated() {
|
async function handleRequestUpdated() {
|
||||||
@@ -268,7 +295,8 @@ export function useAppShell() {
|
|||||||
|
|
||||||
await reloadRequests()
|
await reloadRequests()
|
||||||
void refreshApprovalInbox()
|
void refreshApprovalInbox()
|
||||||
router.push({ name: 'app-requests' })
|
selectedRequestSnapshot.value = null
|
||||||
|
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import { icons } from '../data/icons.js'
|
import { icons } from '../data/icons.js'
|
||||||
|
|
||||||
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
|
export const appViews = ['overview', 'workbench', 'documents', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{
|
{
|
||||||
@@ -22,6 +22,14 @@ export const navItems = [
|
|||||||
title: '个人工作台',
|
title: '个人工作台',
|
||||||
desc: '聚焦当前待办、快捷操作与助手入口。'
|
desc: '聚焦当前待办、快捷操作与助手入口。'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'documents',
|
||||||
|
label: '单据中心',
|
||||||
|
navHint: '统一查看申请、报销、审批与归档',
|
||||||
|
icon: icons.file,
|
||||||
|
title: '单据中心',
|
||||||
|
desc: '统一查看申请、报销、审批与归档。'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'requests',
|
id: 'requests',
|
||||||
label: '报销中心',
|
label: '报销中心',
|
||||||
@@ -91,6 +99,7 @@ export const navItems = [
|
|||||||
const viewRouteNames = {
|
const viewRouteNames = {
|
||||||
overview: 'app-overview',
|
overview: 'app-overview',
|
||||||
workbench: 'app-workbench',
|
workbench: 'app-workbench',
|
||||||
|
documents: 'app-documents',
|
||||||
requests: 'app-requests',
|
requests: 'app-requests',
|
||||||
approval: 'app-approval',
|
approval: 'app-approval',
|
||||||
archive: 'app-archive',
|
archive: 'app-archive',
|
||||||
@@ -106,6 +115,7 @@ const routeNameViews = Object.fromEntries(
|
|||||||
)
|
)
|
||||||
|
|
||||||
routeNameViews['app-request-detail'] = 'requests'
|
routeNameViews['app-request-detail'] = 'requests'
|
||||||
|
routeNameViews['app-document-detail'] = 'documents'
|
||||||
routeNameViews['app-log-detail'] = 'logs'
|
routeNameViews['app-log-detail'] = 'logs'
|
||||||
|
|
||||||
export function resolveAppViewFromRoute(route) {
|
export function resolveAppViewFromRoute(route) {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function useSettings() {
|
|||||||
adminForm: { ...pageState.value.adminForm },
|
adminForm: { ...pageState.value.adminForm },
|
||||||
sessionForm: { ...pageState.value.sessionForm },
|
sessionForm: { ...pageState.value.sessionForm },
|
||||||
llmForm: buildLlmPayload(pageState.value.llmForm),
|
llmForm: buildLlmPayload(pageState.value.llmForm),
|
||||||
|
hermesForm: { ...pageState.value.hermesForm },
|
||||||
renderForm: buildRenderPayload(pageState.value.renderForm),
|
renderForm: buildRenderPayload(pageState.value.renderForm),
|
||||||
logForm: { ...pageState.value.logForm },
|
logForm: { ...pageState.value.logForm },
|
||||||
mailForm: { ...pageState.value.mailForm }
|
mailForm: { ...pageState.value.mailForm }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import LoginRouteView from '../views/LoginRouteView.vue'
|
|||||||
import SetupRouteView from '../views/SetupRouteView.vue'
|
import SetupRouteView from '../views/SetupRouteView.vue'
|
||||||
|
|
||||||
const appChildRoutes = appViews
|
const appChildRoutes = appViews
|
||||||
.filter((view) => view !== 'requests')
|
.filter((view) => view !== 'requests' && view !== 'documents')
|
||||||
.map((view) => ({
|
.map((view) => ({
|
||||||
path: view,
|
path: view,
|
||||||
name: `app-${view}`,
|
name: `app-${view}`,
|
||||||
@@ -51,6 +51,24 @@ const router = createRouter({
|
|||||||
path: '/app',
|
path: '/app',
|
||||||
redirect: { name: 'app-overview' }
|
redirect: { name: 'app-overview' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/documents',
|
||||||
|
name: 'app-documents',
|
||||||
|
component: AppShellRouteView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
appView: 'documents'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/documents/:requestId',
|
||||||
|
name: 'app-document-detail',
|
||||||
|
component: AppShellRouteView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
appView: 'documents'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/app/requests',
|
path: '/app/requests',
|
||||||
name: 'app-requests',
|
name: 'app-requests',
|
||||||
|
|||||||
@@ -154,6 +154,72 @@ export function generateRiskRuleAsset(payload, options = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchRiskRuleLatestTest(assetId) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/latest`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRiskRuleSampleTest(assetId, payload, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/sample`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateRiskRuleTest(assetId, payload, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/simulate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRiskRuleScenarioTest(assetId, payload, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/scenario`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmRiskRuleTestReport(assetId, payload, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/report`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAgentAsset(assetId, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function returnRiskRuleAsset(assetId, payload, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/return`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishRiskRuleAsset(assetId, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
|
||||||
|
return apiRequest(`/agent-assets/${assetId}/risk-rule-enabled`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ enabled: Boolean(enabled) }),
|
||||||
|
headers: buildWriteHeaders(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
|
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
|
||||||
return apiRequest(
|
return apiRequest(
|
||||||
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`
|
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const DEFAULT_APP_VIEW_ORDER = [
|
export const DEFAULT_APP_VIEW_ORDER = [
|
||||||
'overview',
|
'overview',
|
||||||
'workbench',
|
'workbench',
|
||||||
|
'documents',
|
||||||
'requests',
|
'requests',
|
||||||
'approval',
|
'approval',
|
||||||
'archive',
|
'archive',
|
||||||
@@ -11,7 +12,7 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
|||||||
'settings'
|
'settings'
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'requests', 'policies'])
|
||||||
const VIEW_ROLE_RULES = {
|
const VIEW_ROLE_RULES = {
|
||||||
overview: ['finance', 'executive'],
|
overview: ['finance', 'executive'],
|
||||||
approval: ['approver', 'finance', 'executive'],
|
approval: ['approver', 'finance', 'executive'],
|
||||||
|
|||||||
76
web/src/utils/documentCenterTime.js
Normal file
76
web/src/utils/documentCenterTime.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
function toDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDate = new Date(value)
|
||||||
|
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDateText(value) {
|
||||||
|
const matched = String(value || '').match(/\d{4}-\d{2}-\d{2}/)
|
||||||
|
return matched ? matched[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDocumentListTime(value) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw || raw === '待补充') {
|
||||||
|
return '待补充'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = toDate(raw)
|
||||||
|
if (date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${month}-${day} ${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.replace(/^\d{4}-/, '').slice(0, 11)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentSortTime(value) {
|
||||||
|
const date = toDate(value)
|
||||||
|
return date ? date.getTime() : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDocumentDurationSince(value, now = Date.now()) {
|
||||||
|
const startAt = toDate(value)
|
||||||
|
if (!startAt) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Math.max(0, Number(now) - startAt.getTime())
|
||||||
|
const totalMinutes = Math.floor(diffMs / (60 * 1000))
|
||||||
|
if (totalMinutes < 1) {
|
||||||
|
return '刚刚'
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(totalMinutes / (24 * 60))
|
||||||
|
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||||
|
const minutes = totalMinutes % 60
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentStayTimeDisplay(row, now = Date.now()) {
|
||||||
|
const currentStep = Array.isArray(row?.progressSteps)
|
||||||
|
? row.progressSteps.find((step) => step?.current)
|
||||||
|
: null
|
||||||
|
const stepTime = String(currentStep?.time || '').trim()
|
||||||
|
if (stepTime.startsWith('停留')) {
|
||||||
|
return stepTime.replace(/^停留\s*/, '') || '待计算'
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = row?.updatedAt || row?.submittedAt || row?.createdAt || row?.applyTime
|
||||||
|
return formatDocumentDurationSince(startedAt, now) || '待计算'
|
||||||
|
}
|
||||||
158
web/src/utils/expenseApplicationOntology.js
Normal file
158
web/src/utils/expenseApplicationOntology.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
const EXPENSE_TYPE_LABELS = {
|
||||||
|
travel: '差旅费',
|
||||||
|
hotel: '住宿费',
|
||||||
|
transport: '交通费',
|
||||||
|
meal: '业务招待费',
|
||||||
|
entertainment: '业务招待费',
|
||||||
|
meeting: '会务费',
|
||||||
|
office: '办公用品费',
|
||||||
|
training: '培训费',
|
||||||
|
communication: '通讯费',
|
||||||
|
welfare: '福利费',
|
||||||
|
other: '其他费用'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_LABELS = {
|
||||||
|
expense_type: '费用场景',
|
||||||
|
amount: '申请金额',
|
||||||
|
time_range: '业务时间',
|
||||||
|
reason: '申请事由',
|
||||||
|
attachments: '附件说明',
|
||||||
|
customer_name: '客户名称',
|
||||||
|
participants: '参与人员'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training'])
|
||||||
|
const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training'])
|
||||||
|
|
||||||
|
export const APPLICATION_EXAMPLES = [
|
||||||
|
'申请下周去北京做客户现场验收,差旅预算18000元',
|
||||||
|
'申请上海产品发布会会务费32000元,需要场地和物料',
|
||||||
|
'申请部门集中采购办公用品4800元,用于新员工入职'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function buildExpenseApplicationOntologyContext(currentUser = {}) {
|
||||||
|
return {
|
||||||
|
document_type: 'expense_application',
|
||||||
|
application_stage: 'pre_approval',
|
||||||
|
conversation_scenario: 'expense',
|
||||||
|
entry_source: 'documents_application',
|
||||||
|
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
|
||||||
|
is_admin: Boolean(currentUser.isAdmin),
|
||||||
|
name: currentUser.name || '',
|
||||||
|
role: currentUser.role || '',
|
||||||
|
department: currentUser.department || currentUser.departmentName || '',
|
||||||
|
department_name: currentUser.department || currentUser.departmentName || '',
|
||||||
|
position: currentUser.position || '',
|
||||||
|
grade: currentUser.grade || '',
|
||||||
|
employee_no: currentUser.employeeNo || currentUser.employee_no || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEntity(ontology, type) {
|
||||||
|
const entities = Array.isArray(ontology?.entities) ? ontology.entities : []
|
||||||
|
return entities.find((item) => item?.type === type) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConstraint(ontology, field) {
|
||||||
|
const constraints = Array.isArray(ontology?.constraints) ? ontology.constraints : []
|
||||||
|
return constraints.find((item) => item?.field === field) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExpenseTypeCode(ontology) {
|
||||||
|
const entity = resolveEntity(ontology, 'expense_type')
|
||||||
|
return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExpenseTypeLabel(code) {
|
||||||
|
return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApplicationAmount(ontology) {
|
||||||
|
const amountEntity = resolveEntity(ontology, 'amount')
|
||||||
|
const amountConstraint = resolveConstraint(ontology, 'amount')
|
||||||
|
const rawValue = amountEntity?.normalized_value || amountEntity?.value || amountConstraint?.value || ''
|
||||||
|
const numericValue = Number(String(rawValue).replace(/[^\d.]/g, ''))
|
||||||
|
return {
|
||||||
|
raw: String(rawValue || '').trim(),
|
||||||
|
value: Number.isFinite(numericValue) ? numericValue : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimeRangeText(ontology) {
|
||||||
|
const range = ontology?.time_range || {}
|
||||||
|
if (range.start_date && range.end_date) {
|
||||||
|
return range.start_date === range.end_date
|
||||||
|
? range.start_date
|
||||||
|
: `${range.start_date} 至 ${range.end_date}`
|
||||||
|
}
|
||||||
|
return String(range.raw || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
||||||
|
const code = String(expenseTypeCode || '').trim()
|
||||||
|
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
|
||||||
|
return {
|
||||||
|
level: 'required',
|
||||||
|
label: '必须提交',
|
||||||
|
description: code === 'meeting'
|
||||||
|
? '需补充会议通知、议程、参会范围或预算说明。'
|
||||||
|
: '需补充培训通知、课程说明、报价或审批依据。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code === 'office' && amount >= 5000) {
|
||||||
|
return {
|
||||||
|
level: 'required',
|
||||||
|
label: '必须提交',
|
||||||
|
description: '办公采购金额较高,需补充采购清单、报价或预算说明。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code === 'travel') {
|
||||||
|
return {
|
||||||
|
level: 'optional',
|
||||||
|
label: '说明可选',
|
||||||
|
description: '可先提交出差目的、时间和预算;行程或邀请材料可作为补充说明。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: 'none',
|
||||||
|
label: '无需附件',
|
||||||
|
description: '当前申请事项可先不提交附件,后续报销阶段再按票据要求补充。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
|
||||||
|
const expenseTypeCode = resolveExpenseTypeCode(ontology)
|
||||||
|
const amount = resolveApplicationAmount(ontology)
|
||||||
|
const locationEntity = resolveEntity(ontology, 'location')
|
||||||
|
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
||||||
|
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
||||||
|
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
||||||
|
documentTypeLabel: documentTypeEntity?.value || '费用申请',
|
||||||
|
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
|
||||||
|
workflowStageLabel: workflowStageEntity?.value || '前置申请',
|
||||||
|
expenseTypeCode,
|
||||||
|
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
||||||
|
amount: amount.value,
|
||||||
|
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
||||||
|
timeRange: resolveTimeRangeText(ontology) || '待补充',
|
||||||
|
location: locationEntity?.normalized_value || locationEntity?.value || '待补充',
|
||||||
|
reason: String(prompt || '').trim() || '待补充',
|
||||||
|
applicant: currentUser.name || currentUser.username || '当前用户',
|
||||||
|
department: currentUser.department || currentUser.departmentName || '待补充',
|
||||||
|
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
|
||||||
|
attachmentPolicy,
|
||||||
|
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMissingSlots(slots = []) {
|
||||||
|
const normalized = Array.isArray(slots) ? slots : []
|
||||||
|
return normalized.map((item) => ({
|
||||||
|
key: String(item || '').trim(),
|
||||||
|
label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()
|
||||||
|
})).filter((item) => item.key)
|
||||||
|
}
|
||||||
@@ -1,69 +1,26 @@
|
|||||||
/** 数字员工设置:面向管理员的简明任务列表(频率固定,仅可调执行时间) */
|
/** 数字员工设置:面向管理员的简明任务列表(频率固定,仅可调执行时间) */
|
||||||
export const HERMES_SIMPLE_TASKS = [
|
export const HERMES_SIMPLE_TASKS = [
|
||||||
{
|
{
|
||||||
id: 'knowledgeAggregation',
|
id: 'global_risk_scan',
|
||||||
label: '知识库同步',
|
|
||||||
hint: '同步制度文档与知识索引',
|
|
||||||
frequency: 'daily',
|
|
||||||
frequencyLabel: '每天'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ruleReviewDigest',
|
|
||||||
label: '规则待审提醒',
|
|
||||||
hint: '汇总待审规则并推送管理员',
|
|
||||||
frequency: 'daily',
|
|
||||||
frequencyLabel: '每天'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'riskSummary',
|
|
||||||
label: '风险每日巡检',
|
label: '风险每日巡检',
|
||||||
hint: '扫描报销、付款等风险信号',
|
hint: '扫描报销、付款等风险信号',
|
||||||
frequency: 'daily',
|
frequency: 'daily',
|
||||||
frequencyLabel: '每天'
|
frequencyLabel: '每天'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'archiveDigest',
|
id: 'weekly_expense_report',
|
||||||
label: '归档周报',
|
label: '费控洞察周报',
|
||||||
hint: '汇总已归档报销单',
|
hint: '聚合生成财务总结简报',
|
||||||
frequency: 'weekly',
|
frequency: 'weekly',
|
||||||
frequencyLabel: '每周一',
|
frequencyLabel: '每周一',
|
||||||
weekday: 1
|
weekday: 1
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dailyStats',
|
|
||||||
label: '日报统计',
|
|
||||||
hint: '生成昨日报销与审批数据',
|
|
||||||
frequency: 'daily',
|
|
||||||
frequencyLabel: '每天'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'monthlyStats',
|
|
||||||
label: '月报统计',
|
|
||||||
hint: '每月 1 号生成上月汇总',
|
|
||||||
frequency: 'monthly',
|
|
||||||
frequencyLabel: '每月 1 日',
|
|
||||||
monthDay: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'yearlyStats',
|
|
||||||
label: '年报统计',
|
|
||||||
hint: '每年 1 月 1 号生成上年汇总',
|
|
||||||
frequency: 'yearly',
|
|
||||||
frequencyLabel: '每年 1 月 1 日',
|
|
||||||
month: 1,
|
|
||||||
monthDay: 1
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
function buildDefaultSchedules() {
|
function buildDefaultSchedules() {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
knowledgeAggregation: { enabled: true, frequency: 'daily', time: '00:00', weekday: 1, monthDay: 1, month: 1 },
|
global_risk_scan: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
|
||||||
ruleReviewDigest: { enabled: true, frequency: 'daily', time: '18:00', weekday: 5, monthDay: 1, month: 1 },
|
weekly_expense_report: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 }
|
||||||
riskSummary: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
|
|
||||||
archiveDigest: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 },
|
|
||||||
dailyStats: { enabled: true, frequency: 'daily', time: '08:30', weekday: 1, monthDay: 1, month: 1 },
|
|
||||||
monthlyStats: { enabled: true, frequency: 'monthly', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
|
|
||||||
yearlyStats: { enabled: false, frequency: 'yearly', time: '10:00', weekday: 1, monthDay: 1, month: 1 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of HERMES_SIMPLE_TASKS) {
|
for (const task of HERMES_SIMPLE_TASKS) {
|
||||||
@@ -91,13 +48,8 @@ export function buildDefaultHermesEmployeeForm() {
|
|||||||
masterEnabled: true,
|
masterEnabled: true,
|
||||||
notifyOnFailure: true,
|
notifyOnFailure: true,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
knowledgeAggregation: true,
|
global_risk_scan: true,
|
||||||
ruleReviewDigest: true,
|
weekly_expense_report: false
|
||||||
riskSummary: true,
|
|
||||||
archiveDigest: false,
|
|
||||||
dailyStats: true,
|
|
||||||
monthlyStats: true,
|
|
||||||
yearlyStats: false
|
|
||||||
},
|
},
|
||||||
schedules: buildDefaultSchedules()
|
schedules: buildDefaultSchedules()
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user