feat(ai): add expense application feedback ledger

This commit is contained in:
caoxiaozhu
2026-07-14 11:10:55 +08:00
parent 5ed34c2b8f
commit a662cfe6c3
24 changed files with 2159 additions and 34 deletions

View File

@@ -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-runapply 强制目标核验、精确确认、迁移 head、advisory lock 和批次事务。每张旧单只创建一条 `historical_claim_imported` 系统快照,保留源时间和指纹、明确不重建历史,并以 `suppressed` 阻止实时投递。 - 2026-07-14历史旧单接入新增独立 `ExpenseCaseLegacyBackfillService``backfill_legacy_expense_claim_cases.py`。命令只读取显式 `DATABASE_URL`,默认 dry-runapply 强制目标核验、精确确认、迁移 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-14AI 行为闭环首个切片):新增 `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-140003 迁移与验证):新增 `20260714_0003_ai_learning_loop.py`三张学习账本表纳入集中迁移所有权AgentRun 与旧 ExpenseClaim 保持带索引软引用以兼容空库迁移顺序。一次性 tmpfs PostgreSQL 17 的升级、重复升级、跨租户/同租户跨 Case 外键拒绝、降级和再次升级 4 项通过;容器内学习账本、入口边界和迁移所有权定向 29 项,日期联动及异步估算关键场景 5 项通过。持久开发库只读复查仍为 `40|4|105|248|62`7 张 migration-owned 表数量为 0。

View File

@@ -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 降级、详情页接入及相关响应式回归,并完成前端生产构建。

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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:
if submitted is None: submitted = ExpenseClaimService(self.db).submit_claim(
raise ValueError("未找到可修改的申请单。") claim.id,
return submitted 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:
raise ValueError("未找到可修改的申请单。")
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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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)
...message.applicationPreview, const nextPreview = trackApplicationPreviewFieldEdit(
fields: buildEditedApplicationPreviewFields( message.applicationPreview,
message.applicationPreview.fields || {}, normalizeApplicationPreview({
editor, ...message.applicationPreview,
nextValue fields: buildEditedApplicationPreviewFields(
) message.applicationPreview.fields || {},
}) editor,
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

View File

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

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

View File

@@ -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: '客户现场实施' }
])
})