feat(ai): add expense application feedback ledger
This commit is contained in:
@@ -514,3 +514,8 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
|||||||
- 2026-07-14(历史旧单接入):新增独立 `ExpenseCaseLegacyBackfillService` 和 `backfill_legacy_expense_claim_cases.py`。命令只读取显式 `DATABASE_URL`,默认 dry-run;apply 强制目标核验、精确确认、迁移 head、advisory lock 和批次事务。每张旧单只创建一条 `historical_claim_imported` 系统快照,保留源时间和指纹、明确不重建历史,并以 `suppressed` 阻止实时投递。
|
- 2026-07-14(历史旧单接入):新增独立 `ExpenseCaseLegacyBackfillService` 和 `backfill_legacy_expense_claim_cases.py`。命令只读取显式 `DATABASE_URL`,默认 dry-run;apply 强制目标核验、精确确认、迁移 head、advisory lock 和批次事务。每张旧单只创建一条 `historical_claim_imported` 系统快照,保留源时间和指纹、明确不重建历史,并以 `suppressed` 阻止实时投递。
|
||||||
- 2026-07-14(克隆库联调):将持久开发库以只读 `pg_dump` 流式恢复到一次性 tmpfs PostgreSQL 17,原 40 张表、4 张费用单、105 名员工、248 条预算和 62 个 Agent 资产完整保留。升级到 `20260713_0002` 后首次 dry-run 识别 4 张旧单,apply 创建 4 组 Case/Link/Event,重复 apply 创建 0 条;隔离后端完成登录、`/auth/me`、旧单时间线 200 和登出,内部回填字段未出现在 API。临时数据库和隔离进程已清理,持久库复查仍为原数据签名且没有 migration-owned 表。
|
- 2026-07-14(克隆库联调):将持久开发库以只读 `pg_dump` 流式恢复到一次性 tmpfs PostgreSQL 17,原 40 张表、4 张费用单、105 名员工、248 条预算和 62 个 Agent 资产完整保留。升级到 `20260713_0002` 后首次 dry-run 识别 4 张旧单,apply 创建 4 组 Case/Link/Event,重复 apply 创建 0 条;隔离后端完成登录、`/auth/me`、旧单时间线 200 和登出,内部回填字段未出现在 API。临时数据库和隔离进程已清理,持久库复查仍为原数据签名且没有 migration-owned 表。
|
||||||
- 2026-07-14(安全复核与质量验证):交叉审查后补齐超长租户幂等键稳定哈希、Link/Case 租户一致性、孤立 Case 冲突、URL 路由参数覆盖防护和部分批次失败进度摘要;前端将快照语义明确为“纳入时状态/节点”。容器内 65 项后端定向测试、11 项前端测试、Ruff 和 Vite 生产构建通过。全库代码体积门禁仍被本次未修改的 `RiskRuleGenerationService` 807 行存量问题阻断,未混入当前功能提交。
|
- 2026-07-14(安全复核与质量验证):交叉审查后补齐超长租户幂等键稳定哈希、Link/Case 租户一致性、孤立 Case 冲突、URL 路由参数覆盖防护和部分批次失败进度摘要;前端将快照语义明确为“纳入时状态/节点”。容器内 65 项后端定向测试、11 项前端测试、Ruff 和 Vite 生产构建通过。全库代码体积门禁仍被本次未修改的 `RiskRuleGenerationService` 807 行存量问题阻断,未混入当前功能提交。
|
||||||
|
- 2026-07-14(AI 行为闭环首个切片):新增 `AIDecision`、`AIDecisionFeedback` 和 `WorkflowOutcome` 三类独立事实,把申请预填建议、用户确认或显式字段纠正、草稿保存或申请提交结果通过稳定 `decision_id` 关联。只有已认证的申请预览快速入口显式传入服务端 `CurrentUserContext` 时才写账本;通用 User Agent、模板预览和单据详情编辑路径均不写入,避免伪造身份和非 AI 操作污染样本。
|
||||||
|
- 2026-07-14(字段纠正与隐私边界):申请表编辑器在当前会话中维护首次建议值与最终显式编辑值,多次修改保留最初建议,改回原值会移除差异;日期调整同时记录 `time` 与派生 `days`。服务端最终值始终从解析后的申请 facts 重建,不信任客户端 `finalValue`;持久化层只保存字段名和 SHA-256 值指纹,不复制事由、地点、人员、金额等原始值,原始对话、Prompt、模型全文、附件和支付敏感信息也不进入学习账本。
|
||||||
|
- 2026-07-14(可信度边界):浏览器编辑轨迹统一标记为 `verification_status=client_observed`、`trust_level=behavioral_analytics_only` 且 `training_eligible=false`,只能用于受限的产品行为统计和后续人工核验,不能直接作为模型训练、审计结论、风险自动放行或企业记忆激活依据。后续需增加服务端预览登记并返回不可伪造的 `decision_id`,再实现拒绝/忽略、审批/退回/付款结果追踪、记忆候选与下次建议变化。
|
||||||
|
- 2026-07-14(事务与一致性):学习三表与申请、预算及 Business Event 使用同一 Session,在最终 commit 前 `flush`,失败整体回滚;提交场景通过提交服务的事务内回调关联真实 `application_submitted` 事件。Case、Business Event 和 Decision 使用包含 `tenant_id + expense_case_id` 的复合外键,既拒绝跨租户,也拒绝同租户跨 Case 串联。前端估算使用消息级请求版本和输入指纹,只合并估算派生字段,旧响应不会覆盖后续编辑。
|
||||||
|
- 2026-07-14(0003 迁移与验证):新增 `20260714_0003_ai_learning_loop.py`,三张学习账本表纳入集中迁移所有权;AgentRun 与旧 ExpenseClaim 保持带索引软引用以兼容空库迁移顺序。一次性 tmpfs PostgreSQL 17 的升级、重复升级、跨租户/同租户跨 Case 外键拒绝、降级和再次升级 4 项通过;容器内学习账本、入口边界和迁移所有权定向 29 项,日期联动及异步估算关键场景 5 项通过。持久开发库只读复查仍为 `40|4|105|248|62`,7 张 migration-owned 表数量为 0。
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
- [ ] [CONCEPT: 费用领域与编排] 定义 `Expense Case`、申请、票据、报销、审批、付款、凭证和归档的领域边界与迁移关系。
|
- [ ] [CONCEPT: 费用领域与编排] 定义 `Expense Case`、申请、票据、报销、审批、付款、凭证和归档的领域边界与迁移关系。
|
||||||
- [ ] [CONCEPT: 数据与契约] 定义 `expense_cases`、`expense_case_links` 和最小状态机,明确非法状态跃迁。
|
- [ ] [CONCEPT: 数据与契约] 定义 `expense_cases`、`expense_case_links` 和最小状态机,明确非法状态跃迁。
|
||||||
- [ ] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略。
|
- [ ] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略。
|
||||||
- [ ] [CONCEPT: 业务事件与 AI 决策] 定义 `ai_decisions`、`ai_decision_feedback` 和 `workflow_outcomes` 契约。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 定义 `ai_decisions`、`ai_decision_feedback` 和 `workflow_outcomes` 契约。
|
||||||
|
证据:`ai_learning.py`、`expense_application_learning.py`、`20260714_0003_ai_learning_loop.py`;三类事实分别表达 AI 建议、用户采纳/显式字段纠正和业务结果,技术执行成功、用户反馈与工作流结果不复用同一状态。
|
||||||
- [ ] [CONCEPT: 记忆与学习] 定义 `memory_entries`、证据链接、优先级、有效期、敏感等级、撤销和遗忘契约。
|
- [ ] [CONCEPT: 记忆与学习] 定义 `memory_entries`、证据链接、优先级、有效期、敏感等级、撤销和遗忘契约。
|
||||||
- [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。
|
- [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。
|
||||||
- [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。
|
- [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。
|
||||||
@@ -64,7 +65,8 @@
|
|||||||
证据:`server/src/app/services/expense_cases.py`、`server/src/app/api/v1/endpoints/expense_cases.py`、`GET /api/v1/expense-cases/by-claim/{claim_id}`;容器 OpenAPI 校验通过。
|
证据:`server/src/app/services/expense_cases.py`、`server/src/app/api/v1/endpoints/expense_cases.py`、`GET /api/v1/expense-cases/by-claim/{claim_id}`;容器 OpenAPI 校验通过。
|
||||||
- [x] [CONCEPT: 数据与契约] 新增 `expense_cases`、`expense_case_links` 和 `business_events` 表及迁移。
|
- [x] [CONCEPT: 数据与契约] 新增 `expense_cases`、`expense_case_links` 和 `business_events` 表及迁移。
|
||||||
证据:`20260713_0001_expense_case_business_events.py`;一次性 PostgreSQL 17 已验证表、唯一约束、复合索引、外键级联、降级和再次升级。
|
证据:`20260713_0001_expense_case_business_events.py`;一次性 PostgreSQL 17 已验证表、唯一约束、复合索引、外键级联、降级和再次升级。
|
||||||
- [ ] [CONCEPT: 业务事件与 AI 决策] 新增 `ai_decisions`、`ai_decision_feedback`、`workflow_outcomes` 表及迁移。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 新增 `ai_decisions`、`ai_decision_feedback`、`workflow_outcomes` 表及迁移。
|
||||||
|
证据:`20260714_0003_ai_learning_loop.py`、`schema_ownership.py`、`migration_preflight.py`;三表具备租户幂等约束,Case/Event/Decision 使用包含费用 Case 的复合租户外键。一次性 PostgreSQL 17 完整升级、重复升级、跨租户及同租户跨 Case 外键拒绝、降级和再次升级 4 项通过,持久开发库未迁移。
|
||||||
- [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。
|
- [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。
|
||||||
- [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。
|
- [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。
|
||||||
- [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。
|
- [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。
|
||||||
@@ -109,6 +111,8 @@
|
|||||||
- [ ] [CONCEPT: 风险与预审] 统一风险输出为事实、规则、证据、判断、建议动作和降级原因。
|
- [ ] [CONCEPT: 风险与预审] 统一风险输出为事实、规则、证据、判断、建议动作和降级原因。
|
||||||
- [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。
|
- [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。
|
||||||
- [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
|
- [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
|
||||||
|
- [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。
|
||||||
|
证据:`expenseApplicationDecisionFeedback.js`、`useApplicationPreviewEditor.js`、`expense_application_learning.py`;改回原建议会清除字段差异,日期联动同步记录天数,最终值由服务端 facts 重建,客户端 `finalValue` 不作为事实源。账本只保存字段名和值指纹,证据标记为 `client_observed` 且 `training_eligible=false`,尚不直接训练模型或激活记忆。
|
||||||
- [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。
|
- [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。
|
||||||
- [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。
|
- [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。
|
||||||
- [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。
|
- [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。
|
||||||
@@ -165,6 +169,8 @@
|
|||||||
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
||||||
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
||||||
- [ ] [CONCEPT: 测试方案] 跑通 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
|
- [ ] [CONCEPT: 测试方案] 跑通 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
|
||||||
|
- [x] [CONCEPT: 测试方案] 跑通首个行为采集切片:AI 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。
|
||||||
|
证据:`test_user_agent_application_draft_events.py`、`test_reimbursement_endpoints.py`、`expense-application-decision-feedback.test.mjs`、`expense-application-fast-preview.test.mjs`;覆盖可信入口、模板/详情排除、隐私指纹、同事务事件关联、日期联动及异步乱序响应。容器内学习账本与迁移所有权定向 29 项、一次性 PostgreSQL 迁移 4 项和前端关键场景 5 项通过。
|
||||||
- [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。
|
- [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。
|
||||||
- [ ] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。
|
- [ ] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。
|
||||||
- [x] [CONCEPT: 测试方案] 为已有 Expense Case 事件时间线补充视图模型、404 降级、详情页接入及相关响应式回归,并完成前端生产构建。
|
- [x] [CONCEPT: 测试方案] 为已有 Expense Case 事件时间线补充视图模型、404 降级、详情页接入及相关响应式回归,并完成前端生产构建。
|
||||||
|
|||||||
309
server/alembic/versions/20260714_0003_ai_learning_loop.py
Normal file
309
server/alembic/versions/20260714_0003_ai_learning_loop.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""add AI decision feedback and workflow outcome ledger
|
||||||
|
|
||||||
|
Revision ID: 20260714_0003
|
||||||
|
Revises: 20260713_0002
|
||||||
|
Create Date: 2026-07-14 10:30:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260714_0003"
|
||||||
|
down_revision: str | None = "20260713_0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 为新账本的复合外键补充租户级唯一目标,阻止跨租户关联。
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_expense_cases_tenant_id",
|
||||||
|
"expense_cases",
|
||||||
|
["tenant_id", "id"],
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_business_events_tenant_case_id",
|
||||||
|
"business_events",
|
||||||
|
["tenant_id", "expense_case_id", "id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_decisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("business_event_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("expense_claim_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("agent_run_id", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("subject_type", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("decision_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("decision_source", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("automation_mode", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("confidence", sa.Numeric(precision=5, scale=4), nullable=True),
|
||||||
|
sa.Column("suggestion_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("evidence_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("version_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"training_eligible",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.false(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("content_fingerprint", sa.String(length=71), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"confidence IS NULL OR (confidence >= 0 AND confidence <= 1)",
|
||||||
|
name="ck_ai_decisions_confidence",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('suggested', 'accepted', 'edited', 'rejected', "
|
||||||
|
"'ignored', 'executed', 'rolled_back')",
|
||||||
|
name="ck_ai_decisions_status",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id", "business_event_id"],
|
||||||
|
[
|
||||||
|
"business_events.tenant_id",
|
||||||
|
"business_events.expense_case_id",
|
||||||
|
"business_events.id",
|
||||||
|
],
|
||||||
|
name="fk_ai_decisions_tenant_event",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id"],
|
||||||
|
["expense_cases.tenant_id", "expense_cases.id"],
|
||||||
|
name="fk_ai_decisions_tenant_case",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "id", name="uq_ai_decisions_tenant_id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"id",
|
||||||
|
name="uq_ai_decisions_tenant_case_id",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_ai_decisions_tenant_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ai_decisions_tenant_case_time",
|
||||||
|
"ai_decisions",
|
||||||
|
["tenant_id", "expense_case_id", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ai_decisions_tenant_subject",
|
||||||
|
"ai_decisions",
|
||||||
|
["tenant_id", "subject_type", "subject_id"],
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_decisions_expense_claim_id", "ai_decisions", ["expense_claim_id"])
|
||||||
|
op.create_index("ix_ai_decisions_agent_run_id", "ai_decisions", ["agent_run_id"])
|
||||||
|
op.create_index("ix_ai_decisions_correlation_id", "ai_decisions", ["correlation_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_decision_feedback",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("decision_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("expense_claim_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("feedback_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("action_type", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("actor_type", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("evidence_source", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("verification_status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"training_eligible",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.false(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("final_value_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("changed_fields_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("content_fingerprint", sa.String(length=71), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"feedback_type IN ('accepted', 'edited', 'rejected', 'ignored')",
|
||||||
|
name="ck_ai_decision_feedback_type",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"verification_status IN ('client_observed', 'server_verified', "
|
||||||
|
"'human_verified', 'invalidated')",
|
||||||
|
name="ck_ai_decision_feedback_verification",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"NOT training_eligible OR verification_status IN "
|
||||||
|
"('server_verified', 'human_verified')",
|
||||||
|
name="ck_ai_decision_feedback_training_eligibility",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id", "decision_id"],
|
||||||
|
["ai_decisions.tenant_id", "ai_decisions.id"],
|
||||||
|
name="fk_ai_decision_feedback_tenant_decision",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_ai_decision_feedback_tenant_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ai_decision_feedback_tenant_decision_time",
|
||||||
|
"ai_decision_feedback",
|
||||||
|
["tenant_id", "decision_id", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ai_decision_feedback_tenant_type_time",
|
||||||
|
"ai_decision_feedback",
|
||||||
|
["tenant_id", "feedback_type", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ai_decision_feedback_expense_claim_id",
|
||||||
|
"ai_decision_feedback",
|
||||||
|
["expense_claim_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ai_decision_feedback_correlation_id",
|
||||||
|
"ai_decision_feedback",
|
||||||
|
["correlation_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"workflow_outcomes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("decision_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("business_event_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("expense_claim_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("outcome_type", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("outcome_status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("actor_type", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("result_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("content_fingerprint", sa.String(length=71), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"effective_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"outcome_status IN ('recorded', 'verified', 'reversed')",
|
||||||
|
name="ck_workflow_outcomes_status",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id", "business_event_id"],
|
||||||
|
[
|
||||||
|
"business_events.tenant_id",
|
||||||
|
"business_events.expense_case_id",
|
||||||
|
"business_events.id",
|
||||||
|
],
|
||||||
|
name="fk_workflow_outcomes_tenant_event",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id", "decision_id"],
|
||||||
|
[
|
||||||
|
"ai_decisions.tenant_id",
|
||||||
|
"ai_decisions.expense_case_id",
|
||||||
|
"ai_decisions.id",
|
||||||
|
],
|
||||||
|
name="fk_workflow_outcomes_tenant_decision",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id"],
|
||||||
|
["expense_cases.tenant_id", "expense_cases.id"],
|
||||||
|
name="fk_workflow_outcomes_tenant_case",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_workflow_outcomes_tenant_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_workflow_outcomes_tenant_case_time",
|
||||||
|
"workflow_outcomes",
|
||||||
|
["tenant_id", "expense_case_id", "effective_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_workflow_outcomes_tenant_type_time",
|
||||||
|
"workflow_outcomes",
|
||||||
|
["tenant_id", "outcome_type", "effective_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_workflow_outcomes_expense_claim_id",
|
||||||
|
"workflow_outcomes",
|
||||||
|
["expense_claim_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_workflow_outcomes_correlation_id",
|
||||||
|
"workflow_outcomes",
|
||||||
|
["correlation_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_workflow_outcomes_correlation_id", table_name="workflow_outcomes")
|
||||||
|
op.drop_index("ix_workflow_outcomes_expense_claim_id", table_name="workflow_outcomes")
|
||||||
|
op.drop_index("ix_workflow_outcomes_tenant_type_time", table_name="workflow_outcomes")
|
||||||
|
op.drop_index("ix_workflow_outcomes_tenant_case_time", table_name="workflow_outcomes")
|
||||||
|
op.drop_table("workflow_outcomes")
|
||||||
|
|
||||||
|
op.drop_index("ix_ai_decision_feedback_correlation_id", table_name="ai_decision_feedback")
|
||||||
|
op.drop_index("ix_ai_decision_feedback_expense_claim_id", table_name="ai_decision_feedback")
|
||||||
|
op.drop_index("ix_ai_decision_feedback_tenant_type_time", table_name="ai_decision_feedback")
|
||||||
|
op.drop_index("ix_ai_decision_feedback_tenant_decision_time", table_name="ai_decision_feedback")
|
||||||
|
op.drop_table("ai_decision_feedback")
|
||||||
|
|
||||||
|
op.drop_index("ix_ai_decisions_correlation_id", table_name="ai_decisions")
|
||||||
|
op.drop_index("ix_ai_decisions_agent_run_id", table_name="ai_decisions")
|
||||||
|
op.drop_index("ix_ai_decisions_expense_claim_id", table_name="ai_decisions")
|
||||||
|
op.drop_index("ix_ai_decisions_tenant_subject", table_name="ai_decisions")
|
||||||
|
op.drop_index("ix_ai_decisions_tenant_case_time", table_name="ai_decisions")
|
||||||
|
op.drop_table("ai_decisions")
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_business_events_tenant_case_id",
|
||||||
|
"business_events",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
op.drop_constraint("uq_expense_cases_tenant_id", "expense_cases", type_="unique")
|
||||||
@@ -163,6 +163,7 @@ def run_application_preview_action(
|
|||||||
user_agent_response = UserAgentService(db)._build_expense_application_response(
|
user_agent_response = UserAgentService(db)._build_expense_application_response(
|
||||||
request,
|
request,
|
||||||
risk_flags=[],
|
risk_flags=[],
|
||||||
|
learning_current_user=current_user,
|
||||||
)
|
)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models.agent_asset import (
|
|||||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||||
from app.models.agent_feedback import AgentOperationFeedback
|
from app.models.agent_feedback import AgentOperationFeedback
|
||||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||||
|
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||||
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
|
||||||
from app.models.auth_session import AuthSession
|
from app.models.auth_session import AuthSession
|
||||||
@@ -52,6 +53,8 @@ __all__ = [
|
|||||||
"AgentRun",
|
"AgentRun",
|
||||||
"AgentToolCall",
|
"AgentToolCall",
|
||||||
"AgentTraceEvent",
|
"AgentTraceEvent",
|
||||||
|
"AIDecision",
|
||||||
|
"AIDecisionFeedback",
|
||||||
"ApprovalRecord",
|
"ApprovalRecord",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"AuthSession",
|
"AuthSession",
|
||||||
@@ -82,4 +85,5 @@ __all__ = [
|
|||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"SystemSettingSecret",
|
"SystemSettingSecret",
|
||||||
"UserSessionMetric",
|
"UserSessionMetric",
|
||||||
|
"WorkflowOutcome",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
|||||||
"auth_sessions",
|
"auth_sessions",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
"20260714_0003": frozenset(
|
||||||
|
{
|
||||||
|
"expense_cases",
|
||||||
|
"expense_case_links",
|
||||||
|
"business_events",
|
||||||
|
"auth_sessions",
|
||||||
|
"ai_decisions",
|
||||||
|
"ai_decision_feedback",
|
||||||
|
"workflow_outcomes",
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] != MIGRATION_OWNED_TABLES:
|
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] != MIGRATION_OWNED_TABLES:
|
||||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ from app.db.base import Base
|
|||||||
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"auth_sessions",
|
"auth_sessions",
|
||||||
|
"ai_decisions",
|
||||||
|
"ai_decision_feedback",
|
||||||
"expense_cases",
|
"expense_cases",
|
||||||
"expense_case_links",
|
"expense_case_links",
|
||||||
"business_events",
|
"business_events",
|
||||||
|
"workflow_outcomes",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.models.agent_asset import (
|
|||||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||||
from app.models.agent_feedback import AgentOperationFeedback
|
from app.models.agent_feedback import AgentOperationFeedback
|
||||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||||
|
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||||
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
|
||||||
from app.models.auth_session import AuthSession
|
from app.models.auth_session import AuthSession
|
||||||
@@ -51,6 +52,8 @@ __all__ = [
|
|||||||
"ApprovalRecord",
|
"ApprovalRecord",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"AuthSession",
|
"AuthSession",
|
||||||
|
"AIDecision",
|
||||||
|
"AIDecisionFeedback",
|
||||||
"BudgetAllocation",
|
"BudgetAllocation",
|
||||||
"BudgetReservation",
|
"BudgetReservation",
|
||||||
"BudgetTransaction",
|
"BudgetTransaction",
|
||||||
@@ -78,4 +81,5 @@ __all__ = [
|
|||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"SystemSettingSecret",
|
"SystemSettingSecret",
|
||||||
"UserSessionMetric",
|
"UserSessionMetric",
|
||||||
|
"WorkflowOutcome",
|
||||||
]
|
]
|
||||||
|
|||||||
260
server/src/app/models/ai_learning.py
Normal file
260
server/src/app/models/ai_learning.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class AIDecision(Base):
|
||||||
|
"""AI 在费用流程中给出的结构化建议事实。"""
|
||||||
|
|
||||||
|
__tablename__ = "ai_decisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "id", name="uq_ai_decisions_tenant_id"),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"id",
|
||||||
|
name="uq_ai_decisions_tenant_case_id",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_ai_decisions_tenant_idempotency",
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id"],
|
||||||
|
["expense_cases.tenant_id", "expense_cases.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
name="fk_ai_decisions_tenant_case",
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id", "business_event_id"],
|
||||||
|
[
|
||||||
|
"business_events.tenant_id",
|
||||||
|
"business_events.expense_case_id",
|
||||||
|
"business_events.id",
|
||||||
|
],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
name="fk_ai_decisions_tenant_event",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"confidence IS NULL OR (confidence >= 0 AND confidence <= 1)",
|
||||||
|
name="ck_ai_decisions_confidence",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('suggested', 'accepted', 'edited', 'rejected', "
|
||||||
|
"'ignored', 'executed', 'rolled_back')",
|
||||||
|
name="ck_ai_decisions_status",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_ai_decisions_tenant_case_time",
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_ai_decisions_tenant_subject",
|
||||||
|
"tenant_id",
|
||||||
|
"subject_type",
|
||||||
|
"subject_id",
|
||||||
|
),
|
||||||
|
Index("ix_ai_decisions_agent_run_id", "agent_run_id"),
|
||||||
|
Index("ix_ai_decisions_correlation_id", "correlation_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
agent_run_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
subject_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
decision_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
decision_source: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
automation_mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True)
|
||||||
|
suggestion_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
evidence_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
version_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
training_eligible: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
content_fingerprint: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AIDecisionFeedback(Base):
|
||||||
|
"""用户对 AI 建议的采纳或显式字段纠正事实。"""
|
||||||
|
|
||||||
|
__tablename__ = "ai_decision_feedback"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_ai_decision_feedback_tenant_idempotency",
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "decision_id"],
|
||||||
|
["ai_decisions.tenant_id", "ai_decisions.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
name="fk_ai_decision_feedback_tenant_decision",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"feedback_type IN ('accepted', 'edited', 'rejected', 'ignored')",
|
||||||
|
name="ck_ai_decision_feedback_type",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"verification_status IN ('client_observed', 'server_verified', "
|
||||||
|
"'human_verified', 'invalidated')",
|
||||||
|
name="ck_ai_decision_feedback_verification",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"NOT training_eligible OR verification_status IN "
|
||||||
|
"('server_verified', 'human_verified')",
|
||||||
|
name="ck_ai_decision_feedback_training_eligibility",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_ai_decision_feedback_tenant_decision_time",
|
||||||
|
"tenant_id",
|
||||||
|
"decision_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_ai_decision_feedback_tenant_type_time",
|
||||||
|
"tenant_id",
|
||||||
|
"feedback_type",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
Index("ix_ai_decision_feedback_correlation_id", "correlation_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
decision_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
feedback_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
action_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(30), nullable=False, default="user")
|
||||||
|
evidence_source: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
verification_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
training_eligible: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
final_value_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
changed_fields_json: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
JSON, nullable=False, default=list
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
content_fingerprint: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowOutcome(Base):
|
||||||
|
"""与 AI 决策关联、但独立于技术执行状态的业务结果。"""
|
||||||
|
|
||||||
|
__tablename__ = "workflow_outcomes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_workflow_outcomes_tenant_idempotency",
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id"],
|
||||||
|
["expense_cases.tenant_id", "expense_cases.id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
name="fk_workflow_outcomes_tenant_case",
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id", "decision_id"],
|
||||||
|
[
|
||||||
|
"ai_decisions.tenant_id",
|
||||||
|
"ai_decisions.expense_case_id",
|
||||||
|
"ai_decisions.id",
|
||||||
|
],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
name="fk_workflow_outcomes_tenant_decision",
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "expense_case_id", "business_event_id"],
|
||||||
|
[
|
||||||
|
"business_events.tenant_id",
|
||||||
|
"business_events.expense_case_id",
|
||||||
|
"business_events.id",
|
||||||
|
],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
name="fk_workflow_outcomes_tenant_event",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"outcome_status IN ('recorded', 'verified', 'reversed')",
|
||||||
|
name="ck_workflow_outcomes_status",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_workflow_outcomes_tenant_case_time",
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"effective_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_workflow_outcomes_tenant_type_time",
|
||||||
|
"tenant_id",
|
||||||
|
"outcome_type",
|
||||||
|
"effective_at",
|
||||||
|
),
|
||||||
|
Index("ix_workflow_outcomes_correlation_id", "correlation_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
decision_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
outcome_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
outcome_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(30), nullable=False, default="user")
|
||||||
|
result_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
content_fingerprint: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||||
|
effective_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()
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ def _new_id() -> str:
|
|||||||
class ExpenseCase(Base):
|
class ExpenseCase(Base):
|
||||||
__tablename__ = "expense_cases"
|
__tablename__ = "expense_cases"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "id", name="uq_expense_cases_tenant_id"),
|
||||||
UniqueConstraint("tenant_id", "case_no", name="uq_expense_cases_tenant_case_no"),
|
UniqueConstraint("tenant_id", "case_no", name="uq_expense_cases_tenant_case_no"),
|
||||||
Index("ix_expense_cases_tenant_stage", "tenant_id", "current_stage"),
|
Index("ix_expense_cases_tenant_stage", "tenant_id", "current_stage"),
|
||||||
Index("ix_expense_cases_tenant_status", "tenant_id", "status"),
|
Index("ix_expense_cases_tenant_status", "tenant_id", "status"),
|
||||||
@@ -73,6 +74,12 @@ class ExpenseCaseLink(Base):
|
|||||||
class BusinessEvent(Base):
|
class BusinessEvent(Base):
|
||||||
__tablename__ = "business_events"
|
__tablename__ = "business_events"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"id",
|
||||||
|
name="uq_business_events_tenant_case_id",
|
||||||
|
),
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"tenant_id",
|
"tenant_id",
|
||||||
"aggregate_type",
|
"aggregate_type",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.models.expense_case import BusinessEvent
|
from app.models.expense_case import BusinessEvent, ExpenseCase
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.user_agent import UserAgentRequest
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
from app.services.expense_cases import ExpenseCaseService
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
@@ -30,8 +30,8 @@ class ExpenseApplicationDraftEventService:
|
|||||||
event_type: str,
|
event_type: str,
|
||||||
previous_status: str = "",
|
previous_status: str = "",
|
||||||
previous_approval_stage: str = "",
|
previous_approval_stage: str = "",
|
||||||
) -> None:
|
) -> tuple[ExpenseCase, BusinessEvent]:
|
||||||
ExpenseCaseService(self.db).record_claim_event(
|
return ExpenseCaseService(self.db).record_claim_event(
|
||||||
claim,
|
claim,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
actor_id=current_user.username,
|
actor_id=current_user.username,
|
||||||
|
|||||||
382
server/src/app/services/expense_application_learning.py
Normal file
382
server/src/app/services/expense_application_learning.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||||
|
from app.models.expense_case import BusinessEvent
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
|
from app.services.expense_cases import ExpenseCaseService
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExpenseApplicationLearningRecords:
|
||||||
|
decision: AIDecision
|
||||||
|
feedback: AIDecisionFeedback
|
||||||
|
outcome: WorkflowOutcome
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseApplicationLearningService:
|
||||||
|
"""把 AI 申请预填、显式用户编辑和工作流结果原子写入学习账本。"""
|
||||||
|
|
||||||
|
_FACT_KEYS = (
|
||||||
|
"application_type",
|
||||||
|
"time",
|
||||||
|
"location",
|
||||||
|
"reason",
|
||||||
|
"days",
|
||||||
|
"transport_mode",
|
||||||
|
"amount",
|
||||||
|
"grade",
|
||||||
|
"department",
|
||||||
|
"position",
|
||||||
|
"manager_name",
|
||||||
|
"lodging_daily_cap",
|
||||||
|
"subsidy_daily_cap",
|
||||||
|
"transport_policy",
|
||||||
|
"policy_estimate",
|
||||||
|
"matched_city",
|
||||||
|
"rule_name",
|
||||||
|
"rule_version",
|
||||||
|
"hotel_amount",
|
||||||
|
"allowance_amount",
|
||||||
|
"transport_estimated_amount",
|
||||||
|
"transport_estimate_source",
|
||||||
|
"transport_estimate_confidence",
|
||||||
|
"policy_total_amount",
|
||||||
|
)
|
||||||
|
_FIELD_ALIASES = {
|
||||||
|
"applicationType": "application_type",
|
||||||
|
"application_type": "application_type",
|
||||||
|
"time": "time",
|
||||||
|
"time_return": "time",
|
||||||
|
"location": "location",
|
||||||
|
"reason": "reason",
|
||||||
|
"days": "days",
|
||||||
|
"transportMode": "transport_mode",
|
||||||
|
"transport_mode": "transport_mode",
|
||||||
|
"amount": "amount",
|
||||||
|
"grade": "grade",
|
||||||
|
"department": "department",
|
||||||
|
"position": "position",
|
||||||
|
"managerName": "manager_name",
|
||||||
|
"manager_name": "manager_name",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def record_action(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
*,
|
||||||
|
action_type: str,
|
||||||
|
business_event: BusinessEvent,
|
||||||
|
) -> ExpenseApplicationLearningRecords | None:
|
||||||
|
normalized_action = self._normalize_action_type(action_type)
|
||||||
|
preview = self._application_preview(payload)
|
||||||
|
if not self._is_learning_eligible_preview(preview):
|
||||||
|
return None
|
||||||
|
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||||
|
expense_case = ExpenseCaseService(self.db).ensure_case_for_claim(
|
||||||
|
claim,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
business_event.tenant_id != tenant_id
|
||||||
|
or business_event.expense_case_id != expense_case.id
|
||||||
|
):
|
||||||
|
raise PermissionError("AI 学习记录不能关联其他租户或费用事件的业务事件。")
|
||||||
|
|
||||||
|
final_values = self._safe_fact_snapshot(facts)
|
||||||
|
changed_fields = self._safe_changed_fields(payload, final_values)
|
||||||
|
suggested_values = dict(final_values)
|
||||||
|
for item in changed_fields:
|
||||||
|
suggested_values[item["field_key"]] = item["suggested_value"]
|
||||||
|
|
||||||
|
feedback_type = "edited" if changed_fields else "accepted"
|
||||||
|
correlation_id = ExpenseCaseService.normalize_correlation_id(payload.run_id)
|
||||||
|
fingerprint_payload = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"expense_claim_id": claim.id,
|
||||||
|
"action_type": normalized_action,
|
||||||
|
"suggested_values": suggested_values,
|
||||||
|
"final_values": final_values,
|
||||||
|
"changed_fields": changed_fields,
|
||||||
|
}
|
||||||
|
content_fingerprint = self._fingerprint(fingerprint_payload)
|
||||||
|
idempotency_key = self._idempotency_key(
|
||||||
|
normalized_action,
|
||||||
|
claim.id,
|
||||||
|
content_fingerprint,
|
||||||
|
)
|
||||||
|
existing = self._find_existing(tenant_id, idempotency_key)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
decision_id = self._stable_id("decision", tenant_id, idempotency_key)
|
||||||
|
decision = AIDecision(
|
||||||
|
id=decision_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
expense_case_id=expense_case.id,
|
||||||
|
business_event_id=business_event.id,
|
||||||
|
expense_claim_id=claim.id,
|
||||||
|
agent_run_id=str(payload.run_id or "").strip() or None,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
subject_type="expense_claim",
|
||||||
|
subject_id=claim.id,
|
||||||
|
decision_type="expense_application_prefill",
|
||||||
|
decision_source=self._decision_source(preview, facts),
|
||||||
|
status="executed",
|
||||||
|
automation_mode="prefill",
|
||||||
|
confidence=self._confidence(payload),
|
||||||
|
suggestion_json=self._snapshot_reference(suggested_values),
|
||||||
|
evidence_json={
|
||||||
|
"evidence_source": "client_preview_edit_trace",
|
||||||
|
"trust_level": "behavioral_analytics_only",
|
||||||
|
"model_refined": bool(preview.get("modelRefined")),
|
||||||
|
"model_review_status": self._safe_text(preview.get("modelReviewStatus"), 40),
|
||||||
|
},
|
||||||
|
version_json={
|
||||||
|
"schema_version": 1,
|
||||||
|
"parse_strategy": self._safe_text(preview.get("parseStrategy"), 60),
|
||||||
|
"rule_name": self._safe_text(facts.get("rule_name"), 120),
|
||||||
|
"rule_version": self._safe_text(facts.get("rule_version"), 80),
|
||||||
|
},
|
||||||
|
schema_version=1,
|
||||||
|
training_eligible=False,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
content_fingerprint=content_fingerprint,
|
||||||
|
)
|
||||||
|
feedback = AIDecisionFeedback(
|
||||||
|
id=self._stable_id("feedback", tenant_id, idempotency_key),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
decision_id=decision_id,
|
||||||
|
expense_claim_id=claim.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
action_type=normalized_action,
|
||||||
|
actor_id=str(current_user.username or "anonymous").strip() or "anonymous",
|
||||||
|
actor_type="user",
|
||||||
|
evidence_source=(
|
||||||
|
"client_explicit_field_edit"
|
||||||
|
if changed_fields
|
||||||
|
else "client_action_confirmation"
|
||||||
|
),
|
||||||
|
verification_status="client_observed",
|
||||||
|
training_eligible=False,
|
||||||
|
final_value_json=self._snapshot_reference(final_values),
|
||||||
|
changed_fields_json=self._changed_field_references(changed_fields),
|
||||||
|
idempotency_key=f"feedback:{idempotency_key}"[:120],
|
||||||
|
content_fingerprint=self._fingerprint(
|
||||||
|
{
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"feedback_type": feedback_type,
|
||||||
|
"final_values": final_values,
|
||||||
|
"changed_fields": changed_fields,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outcome_type = (
|
||||||
|
"application_submitted" if normalized_action == "submit" else "draft_saved"
|
||||||
|
)
|
||||||
|
outcome = WorkflowOutcome(
|
||||||
|
id=self._stable_id("outcome", tenant_id, idempotency_key),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
expense_case_id=expense_case.id,
|
||||||
|
decision_id=decision_id,
|
||||||
|
business_event_id=business_event.id,
|
||||||
|
expense_claim_id=claim.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
outcome_type=outcome_type,
|
||||||
|
outcome_status="recorded",
|
||||||
|
actor_id=str(current_user.username or "anonymous").strip() or "anonymous",
|
||||||
|
actor_type="user",
|
||||||
|
result_json={
|
||||||
|
"claim_status": "submitted" if normalized_action == "submit" else "draft",
|
||||||
|
"approval_stage": (
|
||||||
|
"直属领导审批" if normalized_action == "submit" else "待提交"
|
||||||
|
),
|
||||||
|
"feedback_type": feedback_type,
|
||||||
|
},
|
||||||
|
idempotency_key=f"outcome:{idempotency_key}"[:120],
|
||||||
|
content_fingerprint=self._fingerprint(
|
||||||
|
{
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"outcome_type": outcome_type,
|
||||||
|
"feedback_type": feedback_type,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.add_all([decision, feedback, outcome])
|
||||||
|
self.db.flush()
|
||||||
|
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
|
||||||
|
|
||||||
|
def _find_existing(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ExpenseApplicationLearningRecords | None:
|
||||||
|
decision = self.db.scalar(
|
||||||
|
select(AIDecision).where(
|
||||||
|
AIDecision.tenant_id == tenant_id,
|
||||||
|
AIDecision.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if decision is None:
|
||||||
|
return None
|
||||||
|
feedback = self.db.scalar(
|
||||||
|
select(AIDecisionFeedback).where(
|
||||||
|
AIDecisionFeedback.tenant_id == tenant_id,
|
||||||
|
AIDecisionFeedback.decision_id == decision.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
outcome = self.db.scalar(
|
||||||
|
select(WorkflowOutcome).where(
|
||||||
|
WorkflowOutcome.tenant_id == tenant_id,
|
||||||
|
WorkflowOutcome.decision_id == decision.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if feedback is None or outcome is None:
|
||||||
|
raise RuntimeError("AI 学习账本幂等状态不完整,请重试。")
|
||||||
|
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _safe_fact_snapshot(cls, facts: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
key: cls._safe_text(facts.get(key), 500)
|
||||||
|
for key in cls._FACT_KEYS
|
||||||
|
if cls._safe_text(facts.get(key), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _safe_changed_fields(
|
||||||
|
cls,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
final_values: dict[str, str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
raw_feedback = cls._application_preview(payload).get("aiDecisionFeedback")
|
||||||
|
if not isinstance(raw_feedback, dict):
|
||||||
|
return []
|
||||||
|
raw_changes = raw_feedback.get("changedFields")
|
||||||
|
if not isinstance(raw_changes, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
changes: dict[str, dict[str, str]] = {}
|
||||||
|
for raw_item in raw_changes[:30]:
|
||||||
|
if not isinstance(raw_item, dict):
|
||||||
|
continue
|
||||||
|
alias = cls._safe_text(raw_item.get("fieldKey"), 60)
|
||||||
|
field_key = cls._FIELD_ALIASES.get(alias)
|
||||||
|
if not field_key or field_key not in final_values:
|
||||||
|
continue
|
||||||
|
suggested_value = cls._safe_text(raw_item.get("suggestedValue"), 500)
|
||||||
|
final_value = cls._safe_text(final_values.get(field_key), 500)
|
||||||
|
if suggested_value == final_value:
|
||||||
|
continue
|
||||||
|
changes[field_key] = {
|
||||||
|
"field_key": field_key,
|
||||||
|
"suggested_value": suggested_value,
|
||||||
|
"final_value": final_value,
|
||||||
|
}
|
||||||
|
return [changes[key] for key in sorted(changes)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _application_preview(payload: UserAgentRequest) -> dict[str, Any]:
|
||||||
|
context = payload.context_json if isinstance(payload.context_json, dict) else {}
|
||||||
|
preview = context.get("application_preview")
|
||||||
|
return preview if isinstance(preview, dict) else {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_learning_eligible_preview(preview: dict[str, Any]) -> bool:
|
||||||
|
fields = preview.get("fields")
|
||||||
|
if not isinstance(fields, dict) or not fields:
|
||||||
|
return False
|
||||||
|
if bool(preview.get("applicationEditMode") or preview.get("application_edit_mode")):
|
||||||
|
return False
|
||||||
|
return str(preview.get("modelReviewStatus") or "").strip().lower() != "template"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _snapshot_reference(cls, values: dict[str, str]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"field_keys": sorted(values),
|
||||||
|
"value_fingerprint": cls._fingerprint(values),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _changed_field_references(
|
||||||
|
cls,
|
||||||
|
changes: list[dict[str, str]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"field_key": item["field_key"],
|
||||||
|
"suggested_value_fingerprint": cls._fingerprint(item["suggested_value"]),
|
||||||
|
"final_value_fingerprint": cls._fingerprint(item["final_value"]),
|
||||||
|
}
|
||||||
|
for item in changes
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decision_source(preview: dict[str, Any], facts: dict[str, str]) -> str:
|
||||||
|
if preview.get("modelRefined") and facts.get("rule_version"):
|
||||||
|
return "hybrid"
|
||||||
|
if preview.get("modelRefined"):
|
||||||
|
return "model"
|
||||||
|
if facts.get("rule_version"):
|
||||||
|
return "rule"
|
||||||
|
return "heuristic"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _confidence(payload: UserAgentRequest) -> Decimal | None:
|
||||||
|
raw_value = getattr(payload.ontology, "confidence", None)
|
||||||
|
if raw_value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = Decimal(str(raw_value))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return max(Decimal("0"), min(Decimal("1"), value)).quantize(Decimal("0.0001"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_action_type(action_type: str) -> str:
|
||||||
|
normalized = str(action_type or "").strip().lower()
|
||||||
|
if normalized not in {"save_draft", "submit"}:
|
||||||
|
raise ValueError("不支持的 AI 申请动作。")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_text(value: object, limit: int) -> str:
|
||||||
|
return str(value or "").strip()[:limit]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fingerprint(payload: object) -> str:
|
||||||
|
serialized = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
return f"sha256:{hashlib.sha256(serialized.encode('utf-8')).hexdigest()}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _idempotency_key(action_type: str, claim_id: str, fingerprint: str) -> str:
|
||||||
|
digest = fingerprint.removeprefix("sha256:")
|
||||||
|
return f"ai-application:{action_type}:{claim_id}:{digest}"[:120]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stable_id(record_type: str, tenant_id: str, idempotency_key: str) -> str:
|
||||||
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{record_type}:{tenant_id}:{idempotency_key}"))
|
||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@@ -20,6 +21,7 @@ from app.api.deps import CurrentUserContext
|
|||||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||||
from app.models.agent_asset import AgentAsset
|
from app.models.agent_asset import AgentAsset
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
|
from app.models.expense_case import BusinessEvent
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.models.hermes_report import HermesRiskReport
|
from app.models.hermes_report import HermesRiskReport
|
||||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||||
@@ -563,7 +565,14 @@ class ExpenseClaimItemActionMixin:
|
|||||||
"item_id": item.id,
|
"item_id": item.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def submit_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
def submit_claim(
|
||||||
|
self,
|
||||||
|
claim_id: str,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
before_commit: Callable[[BusinessEvent], None] | None = None,
|
||||||
|
) -> ExpenseClaim | None:
|
||||||
claim = self.get_claim(claim_id, current_user)
|
claim = self.get_claim(claim_id, current_user)
|
||||||
if claim is None:
|
if claim is None:
|
||||||
return None
|
return None
|
||||||
@@ -646,11 +655,12 @@ class ExpenseClaimItemActionMixin:
|
|||||||
|
|
||||||
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
|
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
|
||||||
|
|
||||||
self._expense_cases.record_claim_event(
|
_, submission_event = self._expense_cases.record_claim_event(
|
||||||
claim,
|
claim,
|
||||||
event_type=("application_submitted" if is_application_claim else "claim_submitted"),
|
event_type=("application_submitted" if is_application_claim else "claim_submitted"),
|
||||||
actor_id=current_user.username,
|
actor_id=current_user.username,
|
||||||
tenant_id=getattr(current_user, "tenant_id", None),
|
tenant_id=getattr(current_user, "tenant_id", None),
|
||||||
|
correlation_id=correlation_id,
|
||||||
idempotency_key=(
|
idempotency_key=(
|
||||||
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
||||||
if claim.submitted_at is not None
|
if claim.submitted_at is not None
|
||||||
@@ -659,6 +669,8 @@ class ExpenseClaimItemActionMixin:
|
|||||||
previous_status=str(before_json.get("status") or ""),
|
previous_status=str(before_json.get("status") or ""),
|
||||||
previous_approval_stage=str(before_json.get("approval_stage") or ""),
|
previous_approval_stage=str(before_json.get("approval_stage") or ""),
|
||||||
)
|
)
|
||||||
|
if before_commit is not None:
|
||||||
|
before_commit(submission_event)
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import or_, select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.models.expense_case import BusinessEvent
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||||
from app.schemas.user_agent import (
|
from app.schemas.user_agent import (
|
||||||
@@ -28,6 +29,7 @@ from app.services.document_numbering import (
|
|||||||
from app.services.expense_application_draft_events import (
|
from app.services.expense_application_draft_events import (
|
||||||
ExpenseApplicationDraftEventService,
|
ExpenseApplicationDraftEventService,
|
||||||
)
|
)
|
||||||
|
from app.services.expense_application_learning import ExpenseApplicationLearningService
|
||||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
@@ -635,6 +637,27 @@ class UserAgentApplicationSlotMixin:
|
|||||||
|
|
||||||
|
|
||||||
class UserAgentApplicationPersistenceMixin:
|
class UserAgentApplicationPersistenceMixin:
|
||||||
|
def _record_expense_application_learning(
|
||||||
|
self,
|
||||||
|
payload: UserAgentRequest,
|
||||||
|
facts: dict[str, str],
|
||||||
|
claim: ExpenseClaim,
|
||||||
|
trusted_current_user: CurrentUserContext | None,
|
||||||
|
*,
|
||||||
|
action_type: str,
|
||||||
|
business_event: BusinessEvent,
|
||||||
|
) -> None:
|
||||||
|
if trusted_current_user is None:
|
||||||
|
return
|
||||||
|
ExpenseApplicationLearningService(self.db).record_action(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
trusted_current_user,
|
||||||
|
action_type=action_type,
|
||||||
|
business_event=business_event,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
|
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
|
||||||
if not isinstance(context_json, dict):
|
if not isinstance(context_json, dict):
|
||||||
@@ -703,6 +726,7 @@ class UserAgentApplicationPersistenceMixin:
|
|||||||
claim: ExpenseClaim,
|
claim: ExpenseClaim,
|
||||||
*,
|
*,
|
||||||
submit: bool,
|
submit: bool,
|
||||||
|
learning_current_user: CurrentUserContext | None = None,
|
||||||
) -> ExpenseClaim:
|
) -> ExpenseClaim:
|
||||||
current_user = self._build_application_current_user(payload)
|
current_user = self._build_application_current_user(payload)
|
||||||
previous_status = str(claim.status or "").strip()
|
previous_status = str(claim.status or "").strip()
|
||||||
@@ -732,7 +756,7 @@ class UserAgentApplicationPersistenceMixin:
|
|||||||
claim.approval_stage = "待提交"
|
claim.approval_stage = "待提交"
|
||||||
claim.submitted_at = None
|
claim.submitted_at = None
|
||||||
try:
|
try:
|
||||||
ExpenseApplicationDraftEventService(self.db).record(
|
_, draft_event = ExpenseApplicationDraftEventService(self.db).record(
|
||||||
payload,
|
payload,
|
||||||
claim,
|
claim,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -740,6 +764,14 @@ class UserAgentApplicationPersistenceMixin:
|
|||||||
previous_status=previous_status,
|
previous_status=previous_status,
|
||||||
previous_approval_stage=previous_approval_stage,
|
previous_approval_stage=previous_approval_stage,
|
||||||
)
|
)
|
||||||
|
self._record_expense_application_learning(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
learning_current_user,
|
||||||
|
action_type="save_draft",
|
||||||
|
business_event=draft_event,
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
return claim
|
return claim
|
||||||
@@ -749,10 +781,28 @@ class UserAgentApplicationPersistenceMixin:
|
|||||||
|
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
|
||||||
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
try:
|
||||||
|
submitted = ExpenseClaimService(self.db).submit_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
correlation_id=payload.run_id,
|
||||||
|
before_commit=(
|
||||||
|
lambda event: self._record_expense_application_learning(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
learning_current_user,
|
||||||
|
action_type="submit",
|
||||||
|
business_event=event,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
if submitted is None:
|
if submitted is None:
|
||||||
raise ValueError("未找到可修改的申请单。")
|
raise ValueError("未找到可修改的申请单。")
|
||||||
return submitted
|
return submitted
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
def _create_expense_application_record(
|
def _create_expense_application_record(
|
||||||
self,
|
self,
|
||||||
@@ -760,6 +810,7 @@ class UserAgentApplicationPersistenceMixin:
|
|||||||
facts: dict[str, str],
|
facts: dict[str, str],
|
||||||
*,
|
*,
|
||||||
submit: bool,
|
submit: bool,
|
||||||
|
learning_current_user: CurrentUserContext | None = None,
|
||||||
) -> ExpenseClaim:
|
) -> ExpenseClaim:
|
||||||
current_user = self._build_application_current_user(payload)
|
current_user = self._build_application_current_user(payload)
|
||||||
access_policy = ExpenseClaimAccessPolicy(self.db)
|
access_policy = ExpenseClaimAccessPolicy(self.db)
|
||||||
@@ -821,19 +872,41 @@ class UserAgentApplicationPersistenceMixin:
|
|||||||
return existing
|
return existing
|
||||||
raise
|
raise
|
||||||
if not submit:
|
if not submit:
|
||||||
draft_event_service.record(
|
_, draft_event = draft_event_service.record(
|
||||||
payload,
|
payload,
|
||||||
claim,
|
claim,
|
||||||
current_user,
|
current_user,
|
||||||
event_type="claim_draft_created",
|
event_type="claim_draft_created",
|
||||||
)
|
)
|
||||||
|
self._record_expense_application_learning(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
learning_current_user,
|
||||||
|
action_type="save_draft",
|
||||||
|
business_event=draft_event,
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
|
||||||
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
submitted = ExpenseClaimService(self.db).submit_claim(
|
||||||
|
claim.id,
|
||||||
|
current_user,
|
||||||
|
correlation_id=payload.run_id,
|
||||||
|
before_commit=(
|
||||||
|
lambda event: self._record_expense_application_learning(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
learning_current_user,
|
||||||
|
action_type="submit",
|
||||||
|
business_event=event,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
if submitted is None:
|
if submitted is None:
|
||||||
raise ValueError("未找到可提交的申请单。")
|
raise ValueError("未找到可提交的申请单。")
|
||||||
return submitted
|
return submitted
|
||||||
@@ -1212,6 +1285,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
|||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
*,
|
*,
|
||||||
risk_flags: list[str],
|
risk_flags: list[str],
|
||||||
|
learning_current_user: CurrentUserContext | None = None,
|
||||||
) -> UserAgentResponse:
|
) -> UserAgentResponse:
|
||||||
facts = self._resolve_expense_application_facts(payload)
|
facts = self._resolve_expense_application_facts(payload)
|
||||||
step = self._resolve_expense_application_step(payload, facts)
|
step = self._resolve_expense_application_step(payload, facts)
|
||||||
@@ -1224,6 +1298,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
|||||||
facts,
|
facts,
|
||||||
editable_claim,
|
editable_claim,
|
||||||
submit=step == "submitted",
|
submit=step == "submitted",
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
)
|
)
|
||||||
facts["application_edit_mode"] = "true"
|
facts["application_edit_mode"] = "true"
|
||||||
elif step == "submitted":
|
elif step == "submitted":
|
||||||
@@ -1236,12 +1311,14 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
|||||||
payload,
|
payload,
|
||||||
facts,
|
facts,
|
||||||
submit=True,
|
submit=True,
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
application_claim = self._create_expense_application_record(
|
application_claim = self._create_expense_application_record(
|
||||||
payload,
|
payload,
|
||||||
facts,
|
facts,
|
||||||
submit=False,
|
submit=False,
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
)
|
)
|
||||||
if application_claim is not None:
|
if application_claim is not None:
|
||||||
facts["application_no"] = application_claim.claim_no
|
facts["application_no"] = application_claim.claim_no
|
||||||
|
|||||||
@@ -9,19 +9,21 @@ import pytest
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from sqlalchemy import create_engine, inspect, text
|
from sqlalchemy import create_engine, inspect, text
|
||||||
from sqlalchemy.engine import Engine, make_url
|
from sqlalchemy.engine import Engine, make_url
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.db.migration_preflight import MigrationPreflightError, validate_migration_state
|
from app.db.migration_preflight import MigrationPreflightError, validate_migration_state
|
||||||
from app.db.schema_ownership import MIGRATION_OWNED_TABLES
|
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
|
||||||
|
|
||||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||||
HEAD_REVISION = "20260713_0002"
|
HEAD_REVISION = "20260714_0003"
|
||||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_probe_component(value: str) -> str:
|
def _normalize_probe_component(value: str) -> str:
|
||||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||||
|
|
||||||
@@ -143,6 +145,25 @@ def _assert_cascade_foreign_key(engine: Engine, table_name: str) -> None:
|
|||||||
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "CASCADE"
|
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "CASCADE"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_composite_foreign_key(
|
||||||
|
engine: Engine,
|
||||||
|
table_name: str,
|
||||||
|
constrained_columns: tuple[str, ...],
|
||||||
|
referred_table: str,
|
||||||
|
referred_columns: tuple[str, ...] = ("tenant_id", "id"),
|
||||||
|
) -> None:
|
||||||
|
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
|
||||||
|
matching = [
|
||||||
|
item
|
||||||
|
for item in foreign_keys
|
||||||
|
if tuple(item["constrained_columns"]) == constrained_columns
|
||||||
|
and item["referred_table"] == referred_table
|
||||||
|
and tuple(item["referred_columns"]) == referred_columns
|
||||||
|
]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "RESTRICT"
|
||||||
|
|
||||||
|
|
||||||
def _assert_head_schema(engine: Engine) -> None:
|
def _assert_head_schema(engine: Engine) -> None:
|
||||||
names = _table_names(engine)
|
names = _table_names(engine)
|
||||||
assert MIGRATION_OWNED_TABLES.issubset(names)
|
assert MIGRATION_OWNED_TABLES.issubset(names)
|
||||||
@@ -163,6 +184,12 @@ def _assert_head_schema(engine: Engine) -> None:
|
|||||||
"uq_expense_case_links_resource",
|
"uq_expense_case_links_resource",
|
||||||
("resource_type", "resource_id"),
|
("resource_type", "resource_id"),
|
||||||
)
|
)
|
||||||
|
_assert_unique_constraint(
|
||||||
|
engine,
|
||||||
|
"business_events",
|
||||||
|
"uq_business_events_tenant_case_id",
|
||||||
|
("tenant_id", "expense_case_id", "id"),
|
||||||
|
)
|
||||||
_assert_unique_constraint(
|
_assert_unique_constraint(
|
||||||
engine,
|
engine,
|
||||||
"business_events",
|
"business_events",
|
||||||
@@ -175,6 +202,30 @@ def _assert_head_schema(engine: Engine) -> None:
|
|||||||
"uq_auth_sessions_token_hash",
|
"uq_auth_sessions_token_hash",
|
||||||
("token_hash",),
|
("token_hash",),
|
||||||
)
|
)
|
||||||
|
_assert_unique_constraint(
|
||||||
|
engine,
|
||||||
|
"ai_decisions",
|
||||||
|
"uq_ai_decisions_tenant_case_id",
|
||||||
|
("tenant_id", "expense_case_id", "id"),
|
||||||
|
)
|
||||||
|
_assert_unique_constraint(
|
||||||
|
engine,
|
||||||
|
"ai_decisions",
|
||||||
|
"uq_ai_decisions_tenant_idempotency",
|
||||||
|
("tenant_id", "idempotency_key"),
|
||||||
|
)
|
||||||
|
_assert_unique_constraint(
|
||||||
|
engine,
|
||||||
|
"ai_decision_feedback",
|
||||||
|
"uq_ai_decision_feedback_tenant_idempotency",
|
||||||
|
("tenant_id", "idempotency_key"),
|
||||||
|
)
|
||||||
|
_assert_unique_constraint(
|
||||||
|
engine,
|
||||||
|
"workflow_outcomes",
|
||||||
|
"uq_workflow_outcomes_tenant_idempotency",
|
||||||
|
("tenant_id", "idempotency_key"),
|
||||||
|
)
|
||||||
|
|
||||||
_assert_indexes(
|
_assert_indexes(
|
||||||
engine,
|
engine,
|
||||||
@@ -214,8 +265,85 @@ def _assert_head_schema(engine: Engine) -> None:
|
|||||||
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
|
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_assert_indexes(
|
||||||
|
engine,
|
||||||
|
"ai_decisions",
|
||||||
|
{
|
||||||
|
"ix_ai_decisions_tenant_case_time": (
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
"ix_ai_decisions_tenant_subject": (
|
||||||
|
"tenant_id",
|
||||||
|
"subject_type",
|
||||||
|
"subject_id",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_assert_indexes(
|
||||||
|
engine,
|
||||||
|
"ai_decision_feedback",
|
||||||
|
{
|
||||||
|
"ix_ai_decision_feedback_tenant_decision_time": (
|
||||||
|
"tenant_id",
|
||||||
|
"decision_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_assert_indexes(
|
||||||
|
engine,
|
||||||
|
"workflow_outcomes",
|
||||||
|
{
|
||||||
|
"ix_workflow_outcomes_tenant_case_time": (
|
||||||
|
"tenant_id",
|
||||||
|
"expense_case_id",
|
||||||
|
"effective_at",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
_assert_cascade_foreign_key(engine, "expense_case_links")
|
_assert_cascade_foreign_key(engine, "expense_case_links")
|
||||||
_assert_cascade_foreign_key(engine, "business_events")
|
_assert_cascade_foreign_key(engine, "business_events")
|
||||||
|
_assert_composite_foreign_key(
|
||||||
|
engine,
|
||||||
|
"ai_decisions",
|
||||||
|
("tenant_id", "expense_case_id"),
|
||||||
|
"expense_cases",
|
||||||
|
)
|
||||||
|
_assert_composite_foreign_key(
|
||||||
|
engine,
|
||||||
|
"ai_decisions",
|
||||||
|
("tenant_id", "expense_case_id", "business_event_id"),
|
||||||
|
"business_events",
|
||||||
|
("tenant_id", "expense_case_id", "id"),
|
||||||
|
)
|
||||||
|
_assert_composite_foreign_key(
|
||||||
|
engine,
|
||||||
|
"ai_decision_feedback",
|
||||||
|
("tenant_id", "decision_id"),
|
||||||
|
"ai_decisions",
|
||||||
|
)
|
||||||
|
_assert_composite_foreign_key(
|
||||||
|
engine,
|
||||||
|
"workflow_outcomes",
|
||||||
|
("tenant_id", "expense_case_id"),
|
||||||
|
"expense_cases",
|
||||||
|
)
|
||||||
|
_assert_composite_foreign_key(
|
||||||
|
engine,
|
||||||
|
"workflow_outcomes",
|
||||||
|
("tenant_id", "expense_case_id", "decision_id"),
|
||||||
|
"ai_decisions",
|
||||||
|
("tenant_id", "expense_case_id", "id"),
|
||||||
|
)
|
||||||
|
_assert_composite_foreign_key(
|
||||||
|
engine,
|
||||||
|
"workflow_outcomes",
|
||||||
|
("tenant_id", "expense_case_id", "business_event_id"),
|
||||||
|
"business_events",
|
||||||
|
("tenant_id", "expense_case_id", "id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _assert_runtime_cascade(engine: Engine) -> None:
|
def _assert_runtime_cascade(engine: Engine) -> None:
|
||||||
@@ -271,6 +399,194 @@ def _assert_runtime_cascade(engine: Engine) -> None:
|
|||||||
) == 0
|
) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_learning_ledger_tenant_boundary(engine: Engine) -> None:
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO expense_cases (
|
||||||
|
id, tenant_id, case_no, scene_code, title, current_stage, status
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'learning-probe-case', 'learning-probe', 'CASE-LEARNING-PROBE',
|
||||||
|
'travel', '学习闭环迁移验证', 'claiming', 'active'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'learning-probe-case-b', 'learning-probe', 'CASE-LEARNING-PROBE-B',
|
||||||
|
'travel', '学习闭环同租户第二费用单', 'claiming', 'active'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO business_events (
|
||||||
|
id, tenant_id, expense_case_id, aggregate_type, aggregate_id,
|
||||||
|
event_type, event_version, idempotency_key, correlation_id,
|
||||||
|
actor_id, actor_type, payload_json, delivery_status, delivery_attempts
|
||||||
|
) VALUES (
|
||||||
|
'learning-probe-event', 'learning-probe', 'learning-probe-case',
|
||||||
|
'expense_claim', 'learning-probe-claim', 'claim_draft_created', 1,
|
||||||
|
'learning-probe-event-key', 'learning-probe-correlation',
|
||||||
|
'learning-probe-user', 'user', '{}', 'pending', 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_decisions (
|
||||||
|
id, tenant_id, expense_case_id, business_event_id,
|
||||||
|
expense_claim_id, agent_run_id, correlation_id, subject_type,
|
||||||
|
subject_id, decision_type, decision_source, status,
|
||||||
|
automation_mode, confidence, suggestion_json, evidence_json,
|
||||||
|
version_json, schema_version, idempotency_key, content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'learning-probe-decision', 'learning-probe', 'learning-probe-case',
|
||||||
|
'learning-probe-event', 'learning-probe-claim', NULL,
|
||||||
|
'learning-probe-correlation', 'expense_claim', 'learning-probe-claim',
|
||||||
|
'expense_application_prefill', 'hybrid', 'executed', 'prefill', 0.9,
|
||||||
|
'{}', '{}', '{}', 1, 'learning-probe-decision-key',
|
||||||
|
'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_decision_feedback (
|
||||||
|
id, tenant_id, decision_id, expense_claim_id, correlation_id,
|
||||||
|
feedback_type, action_type, actor_id, actor_type, evidence_source,
|
||||||
|
verification_status, final_value_json, changed_fields_json, idempotency_key,
|
||||||
|
content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'learning-probe-feedback', 'learning-probe', 'learning-probe-decision',
|
||||||
|
'learning-probe-claim', 'learning-probe-correlation', 'accepted',
|
||||||
|
'save_draft', 'learning-probe-user', 'user',
|
||||||
|
'client_action_confirmation', 'client_observed', '{}', '[]',
|
||||||
|
'learning-probe-feedback-key',
|
||||||
|
'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO workflow_outcomes (
|
||||||
|
id, tenant_id, expense_case_id, decision_id, business_event_id,
|
||||||
|
expense_claim_id, correlation_id, outcome_type, outcome_status,
|
||||||
|
actor_id, actor_type, result_json, idempotency_key,
|
||||||
|
content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'learning-probe-outcome', 'learning-probe', 'learning-probe-case',
|
||||||
|
'learning-probe-decision', 'learning-probe-event',
|
||||||
|
'learning-probe-claim', 'learning-probe-correlation', 'draft_saved',
|
||||||
|
'recorded', 'learning-probe-user', 'user', '{}',
|
||||||
|
'learning-probe-outcome-key',
|
||||||
|
'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_decisions (
|
||||||
|
id, tenant_id, expense_case_id, expense_claim_id,
|
||||||
|
correlation_id, subject_type, subject_id, decision_type,
|
||||||
|
decision_source, status, automation_mode, suggestion_json,
|
||||||
|
evidence_json, version_json, schema_version, idempotency_key,
|
||||||
|
content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'cross-tenant-decision', 'other-tenant', 'learning-probe-case',
|
||||||
|
'cross-tenant-claim', 'cross-tenant-correlation', 'expense_claim',
|
||||||
|
'cross-tenant-claim', 'expense_application_prefill', 'heuristic',
|
||||||
|
'executed', 'prefill', '{}', '{}', '{}', 1,
|
||||||
|
'cross-tenant-key',
|
||||||
|
'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_decision_feedback (
|
||||||
|
id, tenant_id, decision_id, expense_claim_id, correlation_id,
|
||||||
|
feedback_type, action_type, actor_id, actor_type, evidence_source,
|
||||||
|
verification_status, training_eligible, final_value_json,
|
||||||
|
changed_fields_json, idempotency_key, content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'unverified-training-feedback', 'learning-probe',
|
||||||
|
'learning-probe-decision', 'learning-probe-claim',
|
||||||
|
'unverified-training-correlation', 'accepted', 'save_draft',
|
||||||
|
'learning-probe-user', 'user', 'client_action_confirmation',
|
||||||
|
'client_observed', TRUE, '{}', '[]',
|
||||||
|
'unverified-training-feedback-key',
|
||||||
|
'sha256:9999999999999999999999999999999999999999999999999999999999999999'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_decisions (
|
||||||
|
id, tenant_id, expense_case_id, business_event_id,
|
||||||
|
expense_claim_id, correlation_id, subject_type, subject_id,
|
||||||
|
decision_type, decision_source, status, automation_mode,
|
||||||
|
suggestion_json, evidence_json, version_json, schema_version,
|
||||||
|
idempotency_key, content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'cross-case-event-decision', 'learning-probe',
|
||||||
|
'learning-probe-case-b', 'learning-probe-event',
|
||||||
|
'cross-case-event-claim', 'cross-case-event-correlation',
|
||||||
|
'expense_claim', 'cross-case-event-claim',
|
||||||
|
'expense_application_prefill', 'heuristic', 'executed',
|
||||||
|
'prefill', '{}', '{}', '{}', 1,
|
||||||
|
'cross-case-event-key',
|
||||||
|
'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO workflow_outcomes (
|
||||||
|
id, tenant_id, expense_case_id, decision_id,
|
||||||
|
expense_claim_id, correlation_id, outcome_type, outcome_status,
|
||||||
|
actor_id, actor_type, result_json, idempotency_key,
|
||||||
|
content_fingerprint
|
||||||
|
) VALUES (
|
||||||
|
'cross-case-decision-outcome', 'learning-probe',
|
||||||
|
'learning-probe-case-b', 'learning-probe-decision',
|
||||||
|
'cross-case-decision-claim', 'cross-case-decision-correlation',
|
||||||
|
'draft_saved', 'recorded', 'learning-probe-user', 'user', '{}',
|
||||||
|
'cross-case-decision-key',
|
||||||
|
'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_legacy_sentinel(engine: Engine) -> None:
|
def _create_legacy_sentinel(engine: Engine) -> None:
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
@@ -337,9 +653,12 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
|||||||
_upgrade_head(migration_database_url)
|
_upgrade_head(migration_database_url)
|
||||||
_assert_head_schema(engine)
|
_assert_head_schema(engine)
|
||||||
assert validate_migration_state(engine).revision == HEAD_REVISION
|
assert validate_migration_state(engine).revision == HEAD_REVISION
|
||||||
|
create_legacy_schema(engine)
|
||||||
|
assert "expense_claims" in _table_names(engine)
|
||||||
|
|
||||||
_upgrade_head(migration_database_url)
|
_upgrade_head(migration_database_url)
|
||||||
_assert_head_schema(engine)
|
_assert_head_schema(engine)
|
||||||
|
_assert_learning_ledger_tenant_boundary(engine)
|
||||||
_assert_runtime_cascade(engine)
|
_assert_runtime_cascade(engine)
|
||||||
|
|
||||||
_create_legacy_sentinel(engine)
|
_create_legacy_sentinel(engine)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"owned_table",
|
"owned_table",
|
||||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"]),
|
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"]),
|
||||||
)
|
)
|
||||||
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
|
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
@@ -87,6 +87,10 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
|||||||
"20260713_0002",
|
"20260713_0002",
|
||||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] - {"auth_sessions"},
|
MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] - {"auth_sessions"},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"20260714_0003",
|
||||||
|
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] - {"ai_decisions"},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||||
@@ -982,6 +983,23 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
|
|||||||
assert event.expense_case_id == case_link.expense_case_id
|
assert event.expense_case_id == case_link.expense_case_id
|
||||||
assert event.payload_json["previous_status"] == "draft"
|
assert event.payload_json["previous_status"] == "draft"
|
||||||
assert event.payload_json["next_status"] == "submitted"
|
assert event.payload_json["next_status"] == "submitted"
|
||||||
|
decision = db.scalar(select(AIDecision).where(AIDecision.expense_claim_id == claim.id))
|
||||||
|
feedback = db.scalar(
|
||||||
|
select(AIDecisionFeedback).where(AIDecisionFeedback.expense_claim_id == claim.id)
|
||||||
|
)
|
||||||
|
outcome = db.scalar(
|
||||||
|
select(WorkflowOutcome).where(WorkflowOutcome.expense_claim_id == claim.id)
|
||||||
|
)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.expense_case_id == case_link.expense_case_id
|
||||||
|
assert decision.correlation_id == event.correlation_id
|
||||||
|
assert decision.business_event_id == event.id
|
||||||
|
assert feedback is not None
|
||||||
|
assert feedback.feedback_type == "accepted"
|
||||||
|
assert feedback.action_type == "submit"
|
||||||
|
assert outcome is not None
|
||||||
|
assert outcome.outcome_type == "application_submitted"
|
||||||
|
assert outcome.business_event_id == event.id
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict)
|
isinstance(flag, dict)
|
||||||
and flag.get("event_type") == "expense_application_submission"
|
and flag.get("event_type") == "expense_application_submission"
|
||||||
@@ -1033,6 +1051,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
|
|||||||
assert list(db.scalars(select(BudgetTransaction)).all()) == []
|
assert list(db.scalars(select(BudgetTransaction)).all()) == []
|
||||||
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
|
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
|
||||||
assert list(db.scalars(select(BusinessEvent)).all()) == []
|
assert list(db.scalars(select(BusinessEvent)).all()) == []
|
||||||
|
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||||
|
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
|
||||||
|
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||||
|
|
||||||
|
|
||||||
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
|
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
|||||||
|
|
||||||
assert MIGRATION_OWNED_TABLES == frozenset(
|
assert MIGRATION_OWNED_TABLES == frozenset(
|
||||||
{
|
{
|
||||||
|
"ai_decision_feedback",
|
||||||
|
"ai_decisions",
|
||||||
"auth_sessions",
|
"auth_sessions",
|
||||||
"business_events",
|
"business_events",
|
||||||
"expense_case_links",
|
"expense_case_links",
|
||||||
"expense_cases",
|
"expense_cases",
|
||||||
|
"workflow_outcomes",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert table_names
|
assert table_names
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
@@ -28,7 +29,12 @@ def build_session_factory() -> sessionmaker[Session]:
|
|||||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
def build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentRequest:
|
def build_request(
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
tenant_id: str = "tenant-a",
|
||||||
|
changed_fields: list[dict[str, str]] | None = None,
|
||||||
|
) -> UserAgentRequest:
|
||||||
return UserAgentRequest(
|
return UserAgentRequest(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
user_id="owner@example.com",
|
user_id="owner@example.com",
|
||||||
@@ -40,6 +46,20 @@ def build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentReque
|
|||||||
"name": "张三",
|
"name": "张三",
|
||||||
"employee_no": "E001",
|
"employee_no": "E001",
|
||||||
"department_name": "市场部",
|
"department_name": "市场部",
|
||||||
|
"application_preview": {
|
||||||
|
"modelReviewStatus": "completed",
|
||||||
|
"fields": {
|
||||||
|
"applicationType": "差旅费用申请",
|
||||||
|
"time": "2026-07-13 至 2026-07-14",
|
||||||
|
"location": "上海",
|
||||||
|
"reason": "客户现场差旅",
|
||||||
|
"amount": "880元",
|
||||||
|
},
|
||||||
|
"aiDecisionFeedback": {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"changedFields": changed_fields or [],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,11 +104,14 @@ def test_ai_application_draft_update_writes_tenant_scoped_event() -> None:
|
|||||||
db.add_all([owner, claim])
|
db.add_all([owner, claim])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
updated = UserAgentService(db)._update_expense_application_record(
|
service = UserAgentService(db)
|
||||||
build_request(run_id="application-draft-update"),
|
request = build_request(run_id="application-draft-update")
|
||||||
|
updated = service._update_expense_application_record(
|
||||||
|
request,
|
||||||
build_facts(reason="更新后的申请事由"),
|
build_facts(reason="更新后的申请事由"),
|
||||||
claim,
|
claim,
|
||||||
submit=False,
|
submit=False,
|
||||||
|
learning_current_user=service._build_application_current_user(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert updated.status == "draft"
|
assert updated.status == "draft"
|
||||||
@@ -103,6 +126,65 @@ def test_ai_application_draft_update_writes_tenant_scoped_event() -> None:
|
|||||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||||
assert link is not None
|
assert link is not None
|
||||||
assert link.tenant_id == "tenant-a"
|
assert link.tenant_id == "tenant-a"
|
||||||
|
decision = db.scalar(select(AIDecision).where(AIDecision.expense_claim_id == claim.id))
|
||||||
|
feedback = db.scalar(
|
||||||
|
select(AIDecisionFeedback).where(AIDecisionFeedback.expense_claim_id == claim.id)
|
||||||
|
)
|
||||||
|
outcome = db.scalar(
|
||||||
|
select(WorkflowOutcome).where(WorkflowOutcome.expense_claim_id == claim.id)
|
||||||
|
)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.business_event_id == event.id
|
||||||
|
assert feedback is not None
|
||||||
|
assert feedback.feedback_type == "accepted"
|
||||||
|
assert outcome is not None
|
||||||
|
assert outcome.outcome_type == "draft_saved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_untrusted_application_path_does_not_write_learning_ledger() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
claim = UserAgentService(db)._create_expense_application_record(
|
||||||
|
build_request(run_id="application-untrusted-path"),
|
||||||
|
build_facts(),
|
||||||
|
submit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db.get(ExpenseClaim, claim.id) is not None
|
||||||
|
assert db.scalar(
|
||||||
|
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||||
|
) is not None
|
||||||
|
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||||
|
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
|
||||||
|
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"preview_patch",
|
||||||
|
[
|
||||||
|
{"modelReviewStatus": "template"},
|
||||||
|
{"applicationEditMode": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_non_ai_or_detail_preview_is_not_recorded_as_ai_feedback(
|
||||||
|
preview_patch: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
request = build_request(run_id="application-ineligible-preview")
|
||||||
|
request.context_json["application_preview"].update(preview_patch)
|
||||||
|
service = UserAgentService(db)
|
||||||
|
claim = service._create_expense_application_record(
|
||||||
|
request,
|
||||||
|
build_facts(),
|
||||||
|
submit=False,
|
||||||
|
learning_current_user=service._build_application_current_user(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db.get(ExpenseClaim, claim.id) is not None
|
||||||
|
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||||
|
assert list(db.scalars(select(AIDecisionFeedback)).all()) == []
|
||||||
|
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||||
|
|
||||||
|
|
||||||
def test_ai_application_draft_creation_rolls_back_when_event_write_fails(
|
def test_ai_application_draft_creation_rolls_back_when_event_write_fails(
|
||||||
@@ -217,8 +299,21 @@ def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None:
|
|||||||
request = build_request(run_id="application-draft-idempotent")
|
request = build_request(run_id="application-draft-idempotent")
|
||||||
facts = build_facts(reason="同一份草稿")
|
facts = build_facts(reason="同一份草稿")
|
||||||
service = UserAgentService(db)
|
service = UserAgentService(db)
|
||||||
service._update_expense_application_record(request, facts, claim, submit=False)
|
learning_current_user = service._build_application_current_user(request)
|
||||||
service._update_expense_application_record(request, facts, claim, submit=False)
|
service._update_expense_application_record(
|
||||||
|
request,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
submit=False,
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
|
)
|
||||||
|
service._update_expense_application_record(
|
||||||
|
request,
|
||||||
|
facts,
|
||||||
|
claim,
|
||||||
|
submit=False,
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
|
)
|
||||||
|
|
||||||
events = list(
|
events = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
@@ -228,6 +323,9 @@ def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None:
|
|||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0].event_type == "claim_draft_updated"
|
assert events[0].event_type == "claim_draft_updated"
|
||||||
assert events[0].payload_json["previous_status"] == "returned"
|
assert events[0].payload_json["previous_status"] == "returned"
|
||||||
|
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||||||
|
assert len(list(db.scalars(select(AIDecisionFeedback)).all())) == 1
|
||||||
|
assert len(list(db.scalars(select(WorkflowOutcome)).all())) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> None:
|
def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> None:
|
||||||
@@ -262,17 +360,20 @@ def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> N
|
|||||||
|
|
||||||
request = build_request(run_id="application-draft-multi-version")
|
request = build_request(run_id="application-draft-multi-version")
|
||||||
service = UserAgentService(db)
|
service = UserAgentService(db)
|
||||||
|
learning_current_user = service._build_application_current_user(request)
|
||||||
service._update_expense_application_record(
|
service._update_expense_application_record(
|
||||||
request,
|
request,
|
||||||
build_facts(reason="第一版草稿"),
|
build_facts(reason="第一版草稿"),
|
||||||
claim,
|
claim,
|
||||||
submit=False,
|
submit=False,
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
)
|
)
|
||||||
service._update_expense_application_record(
|
service._update_expense_application_record(
|
||||||
request,
|
request,
|
||||||
build_facts(reason="第二版草稿"),
|
build_facts(reason="第二版草稿"),
|
||||||
claim,
|
claim,
|
||||||
submit=False,
|
submit=False,
|
||||||
|
learning_current_user=learning_current_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
events = list(
|
events = list(
|
||||||
@@ -283,3 +384,71 @@ def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> N
|
|||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
assert {event.event_type for event in events} == {"claim_draft_updated"}
|
assert {event.event_type for event in events} == {"claim_draft_updated"}
|
||||||
assert len({event.idempotency_key for event in events}) == 2
|
assert len({event.idempotency_key for event in events}) == 2
|
||||||
|
assert len(list(db.scalars(select(AIDecision)).all())) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_application_draft_records_explicit_field_correction() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
owner = Employee(
|
||||||
|
id="owner-1",
|
||||||
|
employee_no="E001",
|
||||||
|
name="张三",
|
||||||
|
email="owner@example.com",
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="application-1",
|
||||||
|
claim_no="AP-DRAFT-FEEDBACK-001",
|
||||||
|
employee_id=owner.id,
|
||||||
|
employee_name=owner.name,
|
||||||
|
department_name="市场部",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="原申请事由",
|
||||||
|
location="北京",
|
||||||
|
amount=Decimal("500.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||||
|
submitted_at=None,
|
||||||
|
status="returned",
|
||||||
|
approval_stage="退回补充",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add_all([owner, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = UserAgentService(db)
|
||||||
|
request = build_request(
|
||||||
|
run_id="application-draft-feedback",
|
||||||
|
changed_fields=[
|
||||||
|
{
|
||||||
|
"fieldKey": "reason",
|
||||||
|
"suggestedValue": "客户拜访",
|
||||||
|
"finalValue": "客户端伪造值不会直接入账",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
service._update_expense_application_record(
|
||||||
|
request,
|
||||||
|
build_facts(reason="客户现场实施"),
|
||||||
|
claim,
|
||||||
|
submit=False,
|
||||||
|
learning_current_user=service._build_application_current_user(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = db.scalar(select(AIDecision))
|
||||||
|
feedback = db.scalar(select(AIDecisionFeedback))
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.training_eligible is False
|
||||||
|
assert decision.suggestion_json["field_keys"]
|
||||||
|
assert decision.suggestion_json["value_fingerprint"].startswith("sha256:")
|
||||||
|
assert feedback is not None
|
||||||
|
assert feedback.feedback_type == "edited"
|
||||||
|
assert feedback.verification_status == "client_observed"
|
||||||
|
assert feedback.training_eligible is False
|
||||||
|
assert feedback.changed_fields_json[0]["field_key"] == "reason"
|
||||||
|
assert feedback.changed_fields_json[0]["suggested_value_fingerprint"].startswith(
|
||||||
|
"sha256:"
|
||||||
|
)
|
||||||
|
assert "客户拜访" not in str(feedback.changed_fields_json)
|
||||||
|
assert "客户现场实施" not in str(feedback.final_value_json)
|
||||||
|
|||||||
67
web/src/utils/expenseApplicationDecisionFeedback.js
Normal file
67
web/src/utils/expenseApplicationDecisionFeedback.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTrackedFieldKey(fieldKey = '') {
|
||||||
|
const normalized = normalizeText(fieldKey)
|
||||||
|
return normalized === 'time_return' ? 'time' : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExistingChanges(preview = {}) {
|
||||||
|
const feedback = preview?.aiDecisionFeedback
|
||||||
|
return Array.isArray(feedback?.changedFields)
|
||||||
|
? feedback.changedFields.filter((item) => item && typeof item === 'object')
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTrackedFieldKeys(fieldKeyOrKeys = '') {
|
||||||
|
const fieldKeys = Array.isArray(fieldKeyOrKeys) ? fieldKeyOrKeys : [fieldKeyOrKeys]
|
||||||
|
return [...new Set(fieldKeys.map(resolveTrackedFieldKey).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFeedbackRevision(preview = {}) {
|
||||||
|
const revision = Number(preview?.aiDecisionFeedback?.revision)
|
||||||
|
return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackApplicationPreviewFieldEdit(
|
||||||
|
previousPreview = {},
|
||||||
|
nextPreview = {},
|
||||||
|
fieldKeyOrKeys = ''
|
||||||
|
) {
|
||||||
|
const trackedFieldKeys = resolveTrackedFieldKeys(fieldKeyOrKeys)
|
||||||
|
if (!trackedFieldKeys.length) return nextPreview
|
||||||
|
|
||||||
|
const existingChanges = resolveExistingChanges(previousPreview)
|
||||||
|
const retainedChanges = existingChanges.filter(
|
||||||
|
(item) => !trackedFieldKeys.includes(resolveTrackedFieldKey(item.fieldKey))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const trackedFieldKey of trackedFieldKeys) {
|
||||||
|
const existingChange = existingChanges.find(
|
||||||
|
(item) => resolveTrackedFieldKey(item.fieldKey) === trackedFieldKey
|
||||||
|
)
|
||||||
|
const suggestedValue = existingChange
|
||||||
|
? normalizeText(existingChange.suggestedValue)
|
||||||
|
: normalizeText(previousPreview?.fields?.[trackedFieldKey])
|
||||||
|
const finalValue = normalizeText(nextPreview?.fields?.[trackedFieldKey])
|
||||||
|
if (suggestedValue !== finalValue) {
|
||||||
|
retainedChanges.push({
|
||||||
|
fieldKey: trackedFieldKey,
|
||||||
|
suggestedValue,
|
||||||
|
finalValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...nextPreview,
|
||||||
|
aiDecisionFeedback: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: resolveFeedbackRevision(previousPreview) + 1,
|
||||||
|
changedFields: retainedChanges.sort((left, right) => (
|
||||||
|
normalizeText(left.fieldKey).localeCompare(normalizeText(right.fieldKey))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,66 @@ import {
|
|||||||
resolveApplicationDaysFromDateRange,
|
resolveApplicationDaysFromDateRange,
|
||||||
refreshApplicationPreviewTransportEstimate
|
refreshApplicationPreviewTransportEstimate
|
||||||
} from '../../utils/expenseApplicationPreview.js'
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
|
import { trackApplicationPreviewFieldEdit } from '../../utils/expenseApplicationDecisionFeedback.js'
|
||||||
import {
|
import {
|
||||||
buildWorkbenchDateLabel,
|
buildWorkbenchDateLabel,
|
||||||
canApplyWorkbenchDateSelection,
|
canApplyWorkbenchDateSelection,
|
||||||
getTodayDateValue
|
getTodayDateValue
|
||||||
} from '../../utils/workbenchComposerDate.js'
|
} from '../../utils/workbenchComposerDate.js'
|
||||||
|
|
||||||
|
const APPLICATION_ESTIMATE_INPUT_FIELD_KEYS = [
|
||||||
|
'applicationType',
|
||||||
|
'time',
|
||||||
|
'location',
|
||||||
|
'days',
|
||||||
|
'transportMode',
|
||||||
|
'grade'
|
||||||
|
]
|
||||||
|
|
||||||
|
const APPLICATION_ESTIMATE_RESULT_FIELD_KEYS = [
|
||||||
|
'lodgingDailyCap',
|
||||||
|
'subsidyDailyCap',
|
||||||
|
'transportPolicy',
|
||||||
|
'policyEstimate',
|
||||||
|
'amount',
|
||||||
|
'matchedCity',
|
||||||
|
'ruleName',
|
||||||
|
'ruleVersion',
|
||||||
|
'hotelAmount',
|
||||||
|
'allowanceAmount',
|
||||||
|
'transportEstimatedAmount',
|
||||||
|
'transportEstimateDate',
|
||||||
|
'transportQueryLatencyMs',
|
||||||
|
'transportEstimateSource',
|
||||||
|
'transportEstimateConfidence',
|
||||||
|
'policyTotalAmount'
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildApplicationEstimateInputFingerprint(preview = {}) {
|
||||||
|
const fields = preview?.fields || {}
|
||||||
|
return JSON.stringify(
|
||||||
|
APPLICATION_ESTIMATE_INPUT_FIELD_KEYS.map((fieldKey) => String(fields[fieldKey] || '').trim())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeApplicationEstimateResult(currentPreview = {}, refreshedPreview = {}) {
|
||||||
|
const refreshedFields = refreshedPreview?.fields || {}
|
||||||
|
const estimateFields = Object.fromEntries(
|
||||||
|
APPLICATION_ESTIMATE_RESULT_FIELD_KEYS
|
||||||
|
.filter((fieldKey) => Object.hasOwn(refreshedFields, fieldKey))
|
||||||
|
.map((fieldKey) => [fieldKey, refreshedFields[fieldKey]])
|
||||||
|
)
|
||||||
|
return normalizeApplicationPreview({
|
||||||
|
...currentPreview,
|
||||||
|
fields: {
|
||||||
|
...(currentPreview?.fields || {}),
|
||||||
|
...estimateFields
|
||||||
|
},
|
||||||
|
policyEstimate: refreshedPreview?.policyEstimate,
|
||||||
|
policyEstimateStatus: refreshedPreview?.policyEstimateStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function parseEditorDateMatches(value) {
|
function parseEditorDateMatches(value) {
|
||||||
const text = String(value || '').trim()
|
const text = String(value || '').trim()
|
||||||
return [...text.matchAll(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g)]
|
return [...text.matchAll(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g)]
|
||||||
@@ -186,7 +240,14 @@ function validateApplicationPreviewDateRange(value = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldRefreshTransportEstimate(fieldKey) {
|
function shouldRefreshTransportEstimate(fieldKey) {
|
||||||
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
return [
|
||||||
|
'applicationType',
|
||||||
|
'transportMode',
|
||||||
|
'time',
|
||||||
|
'time_return',
|
||||||
|
'location',
|
||||||
|
'days'
|
||||||
|
].includes(fieldKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEditorCurrentUser(currentUser) {
|
function resolveEditorCurrentUser(currentUser) {
|
||||||
@@ -270,6 +331,7 @@ export function useApplicationPreviewEditor({
|
|||||||
currentUser
|
currentUser
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const applicationPreviewEditor = ref(buildEmptyEditor())
|
const applicationPreviewEditor = ref(buildEmptyEditor())
|
||||||
|
const estimateRequestVersions = new WeakMap()
|
||||||
|
|
||||||
async function refreshApplicationPreviewEstimate(preview = {}) {
|
async function refreshApplicationPreviewEstimate(preview = {}) {
|
||||||
const user = resolveEditorCurrentUser(currentUser)
|
const user = resolveEditorCurrentUser(currentUser)
|
||||||
@@ -406,14 +468,19 @@ export function useApplicationPreviewEditor({
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const nextPreview = normalizeApplicationPreview({
|
const isDateField = isApplicationPreviewDateField(editor.fieldKey)
|
||||||
|
const nextPreview = trackApplicationPreviewFieldEdit(
|
||||||
|
message.applicationPreview,
|
||||||
|
normalizeApplicationPreview({
|
||||||
...message.applicationPreview,
|
...message.applicationPreview,
|
||||||
fields: buildEditedApplicationPreviewFields(
|
fields: buildEditedApplicationPreviewFields(
|
||||||
message.applicationPreview.fields || {},
|
message.applicationPreview.fields || {},
|
||||||
editor,
|
editor,
|
||||||
nextValue
|
nextValue
|
||||||
)
|
)
|
||||||
})
|
}),
|
||||||
|
isDateField ? [editor.fieldKey, 'days'] : editor.fieldKey
|
||||||
|
)
|
||||||
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
|
||||||
message.applicationPreview = needRefreshEstimate
|
message.applicationPreview = needRefreshEstimate
|
||||||
? buildTransportEstimatePendingPreview(nextPreview)
|
? buildTransportEstimatePendingPreview(nextPreview)
|
||||||
@@ -422,9 +489,23 @@ export function useApplicationPreviewEditor({
|
|||||||
cancelApplicationPreviewEditor()
|
cancelApplicationPreviewEditor()
|
||||||
persistSessionState?.()
|
persistSessionState?.()
|
||||||
if (needRefreshEstimate) {
|
if (needRefreshEstimate) {
|
||||||
|
const requestVersion = (estimateRequestVersions.get(message) || 0) + 1
|
||||||
|
estimateRequestVersions.set(message, requestVersion)
|
||||||
|
const estimateInputFingerprint = buildApplicationEstimateInputFingerprint(nextPreview)
|
||||||
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
|
||||||
message.applicationPreview = refreshedPreview
|
const isLatestRequest = estimateRequestVersions.get(message) === requestVersion
|
||||||
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
|
const currentFingerprint = buildApplicationEstimateInputFingerprint(
|
||||||
|
message.applicationPreview
|
||||||
|
)
|
||||||
|
if (!isLatestRequest || currentFingerprint !== estimateInputFingerprint) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const mergedPreview = mergeApplicationEstimateResult(
|
||||||
|
message.applicationPreview,
|
||||||
|
refreshedPreview
|
||||||
|
)
|
||||||
|
message.applicationPreview = mergedPreview
|
||||||
|
message.text = buildLocalApplicationPreviewMessage(mergedPreview)
|
||||||
persistSessionState?.()
|
persistSessionState?.()
|
||||||
toast?.('已更新出行方式和费用测算。')
|
toast?.('已更新出行方式和费用测算。')
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
|
|||||||
days: '3天',
|
days: '3天',
|
||||||
transportMode: '火车',
|
transportMode: '火车',
|
||||||
amount: '1000元'
|
amount: '1000元'
|
||||||
|
},
|
||||||
|
aiDecisionFeedback: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
changedFields: [
|
||||||
|
{
|
||||||
|
fieldKey: 'transportMode',
|
||||||
|
suggestedValue: '飞机',
|
||||||
|
finalValue: '火车'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
currentUser: { username: 'zhangsan@example.com', name: '张三' },
|
currentUser: { username: 'zhangsan@example.com', name: '张三' },
|
||||||
@@ -54,6 +64,13 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
|
|||||||
assert.equal(body.context_json.session_type, 'application')
|
assert.equal(body.context_json.session_type, 'application')
|
||||||
assert.equal(body.context_json.application_stage, 'expense_application')
|
assert.equal(body.context_json.application_stage, 'expense_application')
|
||||||
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
||||||
|
assert.deepEqual(body.context_json.application_preview.aiDecisionFeedback.changedFields, [
|
||||||
|
{
|
||||||
|
fieldKey: 'transportMode',
|
||||||
|
suggestedValue: '飞机',
|
||||||
|
finalValue: '火车'
|
||||||
|
}
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
||||||
|
|||||||
140
web/tests/expense-application-decision-feedback.test.mjs
Normal file
140
web/tests/expense-application-decision-feedback.test.mjs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { trackApplicationPreviewFieldEdit } from '../src/utils/expenseApplicationDecisionFeedback.js'
|
||||||
|
|
||||||
|
function testTracksFirstExplicitEdit() {
|
||||||
|
const previous = {
|
||||||
|
fields: {
|
||||||
|
reason: '客户拜访',
|
||||||
|
location: '上海'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
...previous,
|
||||||
|
fields: {
|
||||||
|
...previous.fields,
|
||||||
|
reason: '客户现场实施'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked = trackApplicationPreviewFieldEdit(previous, next, 'reason')
|
||||||
|
|
||||||
|
assert.deepEqual(tracked.aiDecisionFeedback.changedFields, [
|
||||||
|
{
|
||||||
|
fieldKey: 'reason',
|
||||||
|
suggestedValue: '客户拜访',
|
||||||
|
finalValue: '客户现场实施'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
assert.equal(tracked.aiDecisionFeedback.revision, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function testKeepsOriginalSuggestionAcrossRepeatedEdits() {
|
||||||
|
const first = trackApplicationPreviewFieldEdit(
|
||||||
|
{ fields: { transportMode: '火车' } },
|
||||||
|
{ fields: { transportMode: '飞机' } },
|
||||||
|
'transportMode'
|
||||||
|
)
|
||||||
|
const second = trackApplicationPreviewFieldEdit(
|
||||||
|
first,
|
||||||
|
{ ...first, fields: { transportMode: '自驾' } },
|
||||||
|
'transportMode'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(second.aiDecisionFeedback.changedFields, [
|
||||||
|
{
|
||||||
|
fieldKey: 'transportMode',
|
||||||
|
suggestedValue: '火车',
|
||||||
|
finalValue: '自驾'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function testRemovesDiffWhenUserRestoresSuggestion() {
|
||||||
|
const edited = trackApplicationPreviewFieldEdit(
|
||||||
|
{ fields: { location: '上海' } },
|
||||||
|
{ fields: { location: '北京' } },
|
||||||
|
'location'
|
||||||
|
)
|
||||||
|
const restored = trackApplicationPreviewFieldEdit(
|
||||||
|
edited,
|
||||||
|
{ ...edited, fields: { location: '上海' } },
|
||||||
|
'location'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(restored.aiDecisionFeedback.changedFields, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function testReturnDateUsesCanonicalTimeField() {
|
||||||
|
const tracked = trackApplicationPreviewFieldEdit(
|
||||||
|
{ fields: { time: '2026-07-20 至 2026-07-22' } },
|
||||||
|
{ fields: { time: '2026-07-20 至 2026-07-24' } },
|
||||||
|
'time_return'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(tracked.aiDecisionFeedback.changedFields[0].fieldKey, 'time')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTracksDateAndDerivedDaysTogether() {
|
||||||
|
const previous = {
|
||||||
|
fields: {
|
||||||
|
time: '2026-02-20 至 2026-02-23',
|
||||||
|
days: '4天'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
fields: {
|
||||||
|
time: '2026-02-20 至 2026-02-24',
|
||||||
|
days: '5天'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked = trackApplicationPreviewFieldEdit(
|
||||||
|
previous,
|
||||||
|
next,
|
||||||
|
['time_return', 'days']
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(tracked.aiDecisionFeedback.changedFields, [
|
||||||
|
{ fieldKey: 'days', suggestedValue: '4天', finalValue: '5天' },
|
||||||
|
{
|
||||||
|
fieldKey: 'time',
|
||||||
|
suggestedValue: '2026-02-20 至 2026-02-23',
|
||||||
|
finalValue: '2026-02-20 至 2026-02-24'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
assert.equal(tracked.aiDecisionFeedback.revision, 1)
|
||||||
|
|
||||||
|
const editedAgain = trackApplicationPreviewFieldEdit(
|
||||||
|
tracked,
|
||||||
|
{
|
||||||
|
...tracked,
|
||||||
|
fields: {
|
||||||
|
time: '2026-02-20 至 2026-02-25',
|
||||||
|
days: '6天'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
['time', 'days']
|
||||||
|
)
|
||||||
|
assert.equal(editedAgain.aiDecisionFeedback.changedFields[0].suggestedValue, '4天')
|
||||||
|
assert.equal(
|
||||||
|
editedAgain.aiDecisionFeedback.changedFields[1].suggestedValue,
|
||||||
|
'2026-02-20 至 2026-02-23'
|
||||||
|
)
|
||||||
|
assert.equal(editedAgain.aiDecisionFeedback.revision, 2)
|
||||||
|
|
||||||
|
const restored = trackApplicationPreviewFieldEdit(
|
||||||
|
editedAgain,
|
||||||
|
{ ...editedAgain, fields: previous.fields },
|
||||||
|
['time', 'days']
|
||||||
|
)
|
||||||
|
assert.deepEqual(restored.aiDecisionFeedback.changedFields, [])
|
||||||
|
assert.equal(restored.aiDecisionFeedback.revision, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
testTracksFirstExplicitEdit()
|
||||||
|
testKeepsOriginalSuggestionAcrossRepeatedEdits()
|
||||||
|
testRemovesDiffWhenUserRestoresSuggestion()
|
||||||
|
testReturnDateUsesCanonicalTimeField()
|
||||||
|
testTracksDateAndDerivedDaysTogether()
|
||||||
|
console.log('expense-application-decision-feedback tests passed')
|
||||||
@@ -1916,6 +1916,14 @@ test('application preview editor recalculates days and subsidy after date range
|
|||||||
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
|
||||||
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
|
||||||
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
|
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
|
||||||
|
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||||
|
{ fieldKey: 'days', suggestedValue: '1\u5929', finalValue: '4\u5929' },
|
||||||
|
{
|
||||||
|
fieldKey: 'time',
|
||||||
|
suggestedValue: '2026-05-25',
|
||||||
|
finalValue: '2026-02-20 \u81f3 2026-02-23'
|
||||||
|
}
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('application preview editor can edit return date from table row', async () => {
|
test('application preview editor can edit return date from table row', async () => {
|
||||||
@@ -1981,6 +1989,14 @@ test('application preview editor can edit return date from table row', async ()
|
|||||||
})
|
})
|
||||||
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
|
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
|
||||||
assert.equal(message.applicationPreview.fields.days, '5\u5929')
|
assert.equal(message.applicationPreview.fields.days, '5\u5929')
|
||||||
|
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||||
|
{ fieldKey: 'days', suggestedValue: '4\u5929', finalValue: '5\u5929' },
|
||||||
|
{
|
||||||
|
fieldKey: 'time',
|
||||||
|
suggestedValue: '2026-02-20 \u81f3 2026-02-23',
|
||||||
|
finalValue: '2026-02-20 \u81f3 2026-02-24'
|
||||||
|
}
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('application preview editor can edit return date from inline table input', async () => {
|
test('application preview editor can edit return date from inline table input', async () => {
|
||||||
@@ -2185,3 +2201,210 @@ test('application preview editor estimates after shorthand return date input', a
|
|||||||
assert.equal(message.applicationPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
|
assert.equal(message.applicationPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
|
||||||
assert.match(message.applicationPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
|
assert.match(message.applicationPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application preview editor ignores stale out-of-order estimate responses', async () => {
|
||||||
|
const message = {
|
||||||
|
id: 'application-preview-stale-estimate-message',
|
||||||
|
applicationPreview: normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '差旅费用申请',
|
||||||
|
time: '2026-07-20 至 2026-07-21',
|
||||||
|
location: '上海',
|
||||||
|
reason: '客户现场实施',
|
||||||
|
days: '2天',
|
||||||
|
transportMode: '火车',
|
||||||
|
grade: 'P5'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
const pendingEstimates = []
|
||||||
|
const editor = useApplicationPreviewEditor({
|
||||||
|
persistSessionState: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
currentUser: ref({ grade: 'P5' }),
|
||||||
|
calculateTravelReimbursement: (payload) => new Promise((resolve) => {
|
||||||
|
pendingEstimates.push({ payload, resolve })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'location', '上海')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '北京'
|
||||||
|
const firstCommit = editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'location', '北京')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '广州'
|
||||||
|
const secondCommit = editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
assert.equal(pendingEstimates.length, 2)
|
||||||
|
pendingEstimates[1].resolve({
|
||||||
|
days: 2,
|
||||||
|
location: '广州',
|
||||||
|
matched_city: '广州',
|
||||||
|
grade: 'P5',
|
||||||
|
hotel_rate: 600,
|
||||||
|
hotel_amount: 1200,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 200,
|
||||||
|
total_amount: 1400,
|
||||||
|
rule_name: '第二次估算规则',
|
||||||
|
rule_version: 'v2'
|
||||||
|
})
|
||||||
|
await secondCommit
|
||||||
|
pendingEstimates[0].resolve({
|
||||||
|
days: 2,
|
||||||
|
location: '北京',
|
||||||
|
matched_city: '北京',
|
||||||
|
grade: 'P5',
|
||||||
|
hotel_rate: 300,
|
||||||
|
hotel_amount: 600,
|
||||||
|
total_allowance_rate: 80,
|
||||||
|
allowance_amount: 160,
|
||||||
|
total_amount: 760,
|
||||||
|
rule_name: '过期估算规则',
|
||||||
|
rule_version: 'v1'
|
||||||
|
})
|
||||||
|
await firstCommit
|
||||||
|
|
||||||
|
assert.equal(message.applicationPreview.fields.location, '广州')
|
||||||
|
assert.equal(message.applicationPreview.fields.ruleName, '第二次估算规则')
|
||||||
|
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||||
|
{ fieldKey: 'location', suggestedValue: '上海', finalValue: '广州' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('application type edit starts a fresh estimate instead of leaving pending state', async () => {
|
||||||
|
const message = {
|
||||||
|
id: 'application-preview-type-estimate-message',
|
||||||
|
applicationPreview: normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '差旅费用申请',
|
||||||
|
time: '2026-07-20 至 2026-07-21',
|
||||||
|
location: '上海',
|
||||||
|
reason: '客户现场实施',
|
||||||
|
days: '2天',
|
||||||
|
transportMode: '火车',
|
||||||
|
grade: 'P5'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
const pendingEstimates = []
|
||||||
|
const editor = useApplicationPreviewEditor({
|
||||||
|
persistSessionState: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
currentUser: ref({ grade: 'P5' }),
|
||||||
|
calculateTravelReimbursement: (payload) => new Promise((resolve) => {
|
||||||
|
pendingEstimates.push({ payload, resolve })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'location', '上海')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '北京'
|
||||||
|
const firstCommit = editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'applicationType', '差旅费用申请')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '出差费用申请'
|
||||||
|
const secondCommit = editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
assert.equal(pendingEstimates.length, 2)
|
||||||
|
pendingEstimates[1].resolve({
|
||||||
|
days: 2,
|
||||||
|
location: '北京',
|
||||||
|
matched_city: '北京',
|
||||||
|
grade: 'P5',
|
||||||
|
hotel_rate: 600,
|
||||||
|
hotel_amount: 1200,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 200,
|
||||||
|
total_amount: 1400,
|
||||||
|
rule_name: '申请类型更新规则',
|
||||||
|
rule_version: 'v2'
|
||||||
|
})
|
||||||
|
await secondCommit
|
||||||
|
pendingEstimates[0].resolve({
|
||||||
|
days: 2,
|
||||||
|
location: '北京',
|
||||||
|
matched_city: '北京',
|
||||||
|
grade: 'P5',
|
||||||
|
hotel_rate: 300,
|
||||||
|
hotel_amount: 600,
|
||||||
|
total_allowance_rate: 80,
|
||||||
|
allowance_amount: 160,
|
||||||
|
total_amount: 760,
|
||||||
|
rule_name: '过期申请类型规则',
|
||||||
|
rule_version: 'v1'
|
||||||
|
})
|
||||||
|
await firstCommit
|
||||||
|
|
||||||
|
assert.equal(message.applicationPreview.fields.applicationType, '出差费用申请')
|
||||||
|
assert.equal(message.applicationPreview.fields.ruleName, '申请类型更新规则')
|
||||||
|
assert.equal(message.applicationPreview.policyEstimateStatus, 'completed')
|
||||||
|
assert.doesNotMatch(message.applicationPreview.fields.policyEstimate, /正在/)
|
||||||
|
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||||
|
{
|
||||||
|
fieldKey: 'applicationType',
|
||||||
|
suggestedValue: '差旅费用申请',
|
||||||
|
finalValue: '出差费用申请'
|
||||||
|
},
|
||||||
|
{ fieldKey: 'location', suggestedValue: '上海', finalValue: '北京' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('application preview editor merges estimate without dropping later edits', async () => {
|
||||||
|
const message = {
|
||||||
|
id: 'application-preview-merge-estimate-message',
|
||||||
|
applicationPreview: normalizeApplicationPreview({
|
||||||
|
fields: {
|
||||||
|
applicationType: '差旅费用申请',
|
||||||
|
time: '2026-07-20 至 2026-07-21',
|
||||||
|
location: '上海',
|
||||||
|
reason: '客户拜访',
|
||||||
|
days: '2天',
|
||||||
|
transportMode: '火车',
|
||||||
|
grade: 'P5'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
let resolveEstimate
|
||||||
|
const editor = useApplicationPreviewEditor({
|
||||||
|
persistSessionState: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
currentUser: ref({ grade: 'P5' }),
|
||||||
|
calculateTravelReimbursement: () => new Promise((resolve) => {
|
||||||
|
resolveEstimate = resolve
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'location', '上海')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '北京'
|
||||||
|
const estimateCommit = editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
editor.openApplicationPreviewEditor(message, 'reason', '客户拜访')
|
||||||
|
editor.applicationPreviewEditor.value.draftValue = '客户现场实施'
|
||||||
|
await editor.commitApplicationPreviewEditor(message)
|
||||||
|
|
||||||
|
resolveEstimate({
|
||||||
|
days: 2,
|
||||||
|
location: '北京',
|
||||||
|
matched_city: '北京',
|
||||||
|
grade: 'P5',
|
||||||
|
hotel_rate: 500,
|
||||||
|
hotel_amount: 1000,
|
||||||
|
total_allowance_rate: 100,
|
||||||
|
allowance_amount: 200,
|
||||||
|
total_amount: 1200,
|
||||||
|
rule_name: '并发合并规则',
|
||||||
|
rule_version: 'v1'
|
||||||
|
})
|
||||||
|
await estimateCommit
|
||||||
|
|
||||||
|
assert.equal(message.applicationPreview.fields.location, '北京')
|
||||||
|
assert.equal(message.applicationPreview.fields.reason, '客户现场实施')
|
||||||
|
assert.equal(message.applicationPreview.fields.ruleName, '并发合并规则')
|
||||||
|
assert.deepEqual(message.applicationPreview.aiDecisionFeedback.changedFields, [
|
||||||
|
{ fieldKey: 'location', suggestedValue: '上海', finalValue: '北京' },
|
||||||
|
{ fieldKey: 'reason', suggestedValue: '客户拜访', finalValue: '客户现场实施' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user