diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index f093f4a..8718d40 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -514,3 +514,8 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ - 2026-07-14(历史旧单接入):新增独立 `ExpenseCaseLegacyBackfillService` 和 `backfill_legacy_expense_claim_cases.py`。命令只读取显式 `DATABASE_URL`,默认 dry-run;apply 强制目标核验、精确确认、迁移 head、advisory lock 和批次事务。每张旧单只创建一条 `historical_claim_imported` 系统快照,保留源时间和指纹、明确不重建历史,并以 `suppressed` 阻止实时投递。 - 2026-07-14(克隆库联调):将持久开发库以只读 `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(AI 行为闭环首个切片):新增 `AIDecision`、`AIDecisionFeedback` 和 `WorkflowOutcome` 三类独立事实,把申请预填建议、用户确认或显式字段纠正、草稿保存或申请提交结果通过稳定 `decision_id` 关联。只有已认证的申请预览快速入口显式传入服务端 `CurrentUserContext` 时才写账本;通用 User Agent、模板预览和单据详情编辑路径均不写入,避免伪造身份和非 AI 操作污染样本。 +- 2026-07-14(字段纠正与隐私边界):申请表编辑器在当前会话中维护首次建议值与最终显式编辑值,多次修改保留最初建议,改回原值会移除差异;日期调整同时记录 `time` 与派生 `days`。服务端最终值始终从解析后的申请 facts 重建,不信任客户端 `finalValue`;持久化层只保存字段名和 SHA-256 值指纹,不复制事由、地点、人员、金额等原始值,原始对话、Prompt、模型全文、附件和支付敏感信息也不进入学习账本。 +- 2026-07-14(可信度边界):浏览器编辑轨迹统一标记为 `verification_status=client_observed`、`trust_level=behavioral_analytics_only` 且 `training_eligible=false`,只能用于受限的产品行为统计和后续人工核验,不能直接作为模型训练、审计结论、风险自动放行或企业记忆激活依据。后续需增加服务端预览登记并返回不可伪造的 `decision_id`,再实现拒绝/忽略、审批/退回/付款结果追踪、记忆候选与下次建议变化。 +- 2026-07-14(事务与一致性):学习三表与申请、预算及 Business Event 使用同一 Session,在最终 commit 前 `flush`,失败整体回滚;提交场景通过提交服务的事务内回调关联真实 `application_submitted` 事件。Case、Business Event 和 Decision 使用包含 `tenant_id + expense_case_id` 的复合外键,既拒绝跨租户,也拒绝同租户跨 Case 串联。前端估算使用消息级请求版本和输入指纹,只合并估算派生字段,旧响应不会覆盖后续编辑。 +- 2026-07-14(0003 迁移与验证):新增 `20260714_0003_ai_learning_loop.py`,三张学习账本表纳入集中迁移所有权;AgentRun 与旧 ExpenseClaim 保持带索引软引用以兼容空库迁移顺序。一次性 tmpfs PostgreSQL 17 的升级、重复升级、跨租户/同租户跨 Case 外键拒绝、降级和再次升级 4 项通过;容器内学习账本、入口边界和迁移所有权定向 29 项,日期联动及异步估算关键场景 5 项通过。持久开发库只读复查仍为 `40|4|105|248|62`,7 张 migration-owned 表数量为 0。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index 4d0b95a..bd80fe1 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -37,7 +37,8 @@ - [ ] [CONCEPT: 费用领域与编排] 定义 `Expense Case`、申请、票据、报销、审批、付款、凭证和归档的领域边界与迁移关系。 - [ ] [CONCEPT: 数据与契约] 定义 `expense_cases`、`expense_case_links` 和最小状态机,明确非法状态跃迁。 - [ ] [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: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。 - [ ] [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 校验通过。 - [x] [CONCEPT: 数据与契约] 新增 `expense_cases`、`expense_case_links` 和 `business_events` 表及迁移。 证据:`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: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。 - [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。 @@ -109,6 +111,8 @@ - [ ] [CONCEPT: 风险与预审] 统一风险输出为事实、规则、证据、判断、建议动作和降级原因。 - [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。 - [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。 +- [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。 + 证据:`expenseApplicationDecisionFeedback.js`、`useApplicationPreviewEditor.js`、`expense_application_learning.py`;改回原建议会清除字段差异,日期联动同步记录天数,最终值由服务端 facts 重建,客户端 `finalValue` 不作为事实源。账本只保存字段名和值指纹,证据标记为 `client_observed` 且 `training_eligible=false`,尚不直接训练模型或激活记忆。 - [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。 - [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。 - [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。 @@ -165,6 +169,8 @@ - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 - [ ] [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: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。 - [x] [CONCEPT: 测试方案] 为已有 Expense Case 事件时间线补充视图模型、404 降级、详情页接入及相关响应式回归,并完成前端生产构建。 diff --git a/server/alembic/versions/20260714_0003_ai_learning_loop.py b/server/alembic/versions/20260714_0003_ai_learning_loop.py new file mode 100644 index 0000000..92d2e79 --- /dev/null +++ b/server/alembic/versions/20260714_0003_ai_learning_loop.py @@ -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") diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 0ebe1ff..97e30f7 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -163,6 +163,7 @@ def run_application_preview_action( user_agent_response = UserAgentService(db)._build_expense_application_response( request, risk_flags=[], + learning_current_user=current_user, ) except ValueError as error: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 8868945..5bfe3be 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -9,6 +9,7 @@ from app.models.agent_asset import ( from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_feedback import AgentOperationFeedback 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.audit_log import AuditLog from app.models.auth_session import AuthSession @@ -52,6 +53,8 @@ __all__ = [ "AgentRun", "AgentToolCall", "AgentTraceEvent", + "AIDecision", + "AIDecisionFeedback", "ApprovalRecord", "AuditLog", "AuthSession", @@ -82,4 +85,5 @@ __all__ = [ "SystemSetting", "SystemSettingSecret", "UserSessionMetric", + "WorkflowOutcome", ] diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py index 954331c..a8372d4 100644 --- a/server/src/app/db/migration_preflight.py +++ b/server/src/app/db/migration_preflight.py @@ -27,8 +27,19 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = { "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") diff --git a/server/src/app/db/schema_ownership.py b/server/src/app/db/schema_ownership.py index 875518d..e2ca2c5 100644 --- a/server/src/app/db/schema_ownership.py +++ b/server/src/app/db/schema_ownership.py @@ -7,9 +7,12 @@ from app.db.base import Base MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( { "auth_sessions", + "ai_decisions", + "ai_decision_feedback", "expense_cases", "expense_case_links", "business_events", + "workflow_outcomes", } ) diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index 2e9d56f..90f5f17 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.agent_asset import ( from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_feedback import AgentOperationFeedback 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.audit_log import AuditLog from app.models.auth_session import AuthSession @@ -51,6 +52,8 @@ __all__ = [ "ApprovalRecord", "AuditLog", "AuthSession", + "AIDecision", + "AIDecisionFeedback", "BudgetAllocation", "BudgetReservation", "BudgetTransaction", @@ -78,4 +81,5 @@ __all__ = [ "SystemSetting", "SystemSettingSecret", "UserSessionMetric", + "WorkflowOutcome", ] diff --git a/server/src/app/models/ai_learning.py b/server/src/app/models/ai_learning.py new file mode 100644 index 0000000..1f60e9b --- /dev/null +++ b/server/src/app/models/ai_learning.py @@ -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() + ) diff --git a/server/src/app/models/expense_case.py b/server/src/app/models/expense_case.py index 04b1873..badf687 100644 --- a/server/src/app/models/expense_case.py +++ b/server/src/app/models/expense_case.py @@ -18,6 +18,7 @@ def _new_id() -> str: class ExpenseCase(Base): __tablename__ = "expense_cases" __table_args__ = ( + UniqueConstraint("tenant_id", "id", name="uq_expense_cases_tenant_id"), 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_status", "tenant_id", "status"), @@ -73,6 +74,12 @@ class ExpenseCaseLink(Base): class BusinessEvent(Base): __tablename__ = "business_events" __table_args__ = ( + UniqueConstraint( + "tenant_id", + "expense_case_id", + "id", + name="uq_business_events_tenant_case_id", + ), UniqueConstraint( "tenant_id", "aggregate_type", diff --git a/server/src/app/services/expense_application_draft_events.py b/server/src/app/services/expense_application_draft_events.py index b8b5a7a..5d90739 100644 --- a/server/src/app/services/expense_application_draft_events.py +++ b/server/src/app/services/expense_application_draft_events.py @@ -9,7 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session 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.schemas.user_agent import UserAgentRequest from app.services.expense_cases import ExpenseCaseService @@ -30,8 +30,8 @@ class ExpenseApplicationDraftEventService: event_type: str, previous_status: str = "", previous_approval_stage: str = "", - ) -> None: - ExpenseCaseService(self.db).record_claim_event( + ) -> tuple[ExpenseCase, BusinessEvent]: + return ExpenseCaseService(self.db).record_claim_event( claim, event_type=event_type, actor_id=current_user.username, diff --git a/server/src/app/services/expense_application_learning.py b/server/src/app/services/expense_application_learning.py new file mode 100644 index 0000000..ef0f617 --- /dev/null +++ b/server/src/app/services/expense_application_learning.py @@ -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}")) diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 34626df..07b32a1 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -4,6 +4,7 @@ import json import re import shutil import uuid +from collections.abc import Callable from collections import defaultdict from datetime import UTC, date, datetime, timedelta 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.models.agent_asset import AgentAsset from app.models.employee import Employee +from app.models.expense_case import BusinessEvent from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.hermes_report import HermesRiskReport from app.models.risk_observation import RiskObservation, RiskObservationFeedback @@ -563,7 +565,14 @@ class ExpenseClaimItemActionMixin: "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) if claim is None: return None @@ -646,11 +655,12 @@ class ExpenseClaimItemActionMixin: 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, event_type=("application_submitted" if is_application_claim else "claim_submitted"), actor_id=current_user.username, tenant_id=getattr(current_user, "tenant_id", None), + correlation_id=correlation_id, idempotency_key=( f"submit:{claim.id}:{claim.submitted_at.isoformat()}" if claim.submitted_at is not None @@ -659,6 +669,8 @@ class ExpenseClaimItemActionMixin: previous_status=str(before_json.get("status") 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.refresh(claim) diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 2d5c327..3a5c4a8 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -8,6 +8,7 @@ from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from app.api.deps import CurrentUserContext +from app.models.expense_case import BusinessEvent from app.models.financial_record import ExpenseClaim from app.schemas.reimbursement import TravelReimbursementCalculatorRequest from app.schemas.user_agent import ( @@ -28,6 +29,7 @@ from app.services.document_numbering import ( from app.services.expense_application_draft_events import ( ExpenseApplicationDraftEventService, ) +from app.services.expense_application_learning import ExpenseApplicationLearningService from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService @@ -635,6 +637,27 @@ class UserAgentApplicationSlotMixin: 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 def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str: if not isinstance(context_json, dict): @@ -703,6 +726,7 @@ class UserAgentApplicationPersistenceMixin: claim: ExpenseClaim, *, submit: bool, + learning_current_user: CurrentUserContext | None = None, ) -> ExpenseClaim: current_user = self._build_application_current_user(payload) previous_status = str(claim.status or "").strip() @@ -732,7 +756,7 @@ class UserAgentApplicationPersistenceMixin: claim.approval_stage = "待提交" claim.submitted_at = None try: - ExpenseApplicationDraftEventService(self.db).record( + _, draft_event = ExpenseApplicationDraftEventService(self.db).record( payload, claim, current_user, @@ -740,6 +764,14 @@ class UserAgentApplicationPersistenceMixin: previous_status=previous_status, 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.refresh(claim) return claim @@ -749,10 +781,28 @@ class UserAgentApplicationPersistenceMixin: from app.services.expense_claims import ExpenseClaimService - submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user) - if submitted is None: - raise ValueError("未找到可修改的申请单。") - return submitted + try: + submitted = ExpenseClaimService(self.db).submit_claim( + claim.id, + current_user, + correlation_id=payload.run_id, + before_commit=( + lambda event: self._record_expense_application_learning( + payload, + facts, + claim, + learning_current_user, + action_type="submit", + business_event=event, + ) + ), + ) + if submitted is None: + raise ValueError("未找到可修改的申请单。") + return submitted + except Exception: + self.db.rollback() + raise def _create_expense_application_record( self, @@ -760,6 +810,7 @@ class UserAgentApplicationPersistenceMixin: facts: dict[str, str], *, submit: bool, + learning_current_user: CurrentUserContext | None = None, ) -> ExpenseClaim: current_user = self._build_application_current_user(payload) access_policy = ExpenseClaimAccessPolicy(self.db) @@ -821,19 +872,41 @@ class UserAgentApplicationPersistenceMixin: return existing raise if not submit: - draft_event_service.record( + _, draft_event = draft_event_service.record( payload, claim, current_user, 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.refresh(claim) return claim 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: raise ValueError("未找到可提交的申请单。") return submitted @@ -1212,6 +1285,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat payload: UserAgentRequest, *, risk_flags: list[str], + learning_current_user: CurrentUserContext | None = None, ) -> UserAgentResponse: facts = self._resolve_expense_application_facts(payload) step = self._resolve_expense_application_step(payload, facts) @@ -1224,6 +1298,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat facts, editable_claim, submit=step == "submitted", + learning_current_user=learning_current_user, ) facts["application_edit_mode"] = "true" elif step == "submitted": @@ -1236,12 +1311,14 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat payload, facts, submit=True, + learning_current_user=learning_current_user, ) else: application_claim = self._create_expense_application_record( payload, facts, submit=False, + learning_current_user=learning_current_user, ) if application_claim is not None: facts["application_no"] = application_claim.claim_no diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py index 4afa09e..d262d29 100644 --- a/server/tests/test_alembic_migrations.py +++ b/server/tests/test_alembic_migrations.py @@ -9,19 +9,21 @@ import pytest from alembic.config import Config from sqlalchemy import create_engine, inspect, text from sqlalchemy.engine import Engine, make_url +from sqlalchemy.exc import IntegrityError from sqlalchemy.pool import NullPool from alembic import command from app.core.config import get_settings 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() LEGACY_PROBE_TABLE = "legacy_migration_probe_records" -HEAD_REVISION = "20260713_0002" +HEAD_REVISION = "20260714_0003" SERVER_DIR = Path(__file__).resolve().parents[1] ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" + def _normalize_probe_component(value: str) -> str: 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" +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: names = _table_names(engine) assert MIGRATION_OWNED_TABLES.issubset(names) @@ -163,6 +184,12 @@ def _assert_head_schema(engine: Engine) -> None: "uq_expense_case_links_resource", ("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( engine, "business_events", @@ -175,6 +202,30 @@ def _assert_head_schema(engine: Engine) -> None: "uq_auth_sessions_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( engine, @@ -214,8 +265,85 @@ def _assert_head_schema(engine: Engine) -> None: "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, "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: @@ -271,6 +399,194 @@ def _assert_runtime_cascade(engine: Engine) -> None: ) == 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: with engine.begin() as connection: connection.execute( @@ -337,9 +653,12 @@ def test_alembic_migration_cycle_on_disposable_postgres( _upgrade_head(migration_database_url) _assert_head_schema(engine) assert validate_migration_state(engine).revision == HEAD_REVISION + create_legacy_schema(engine) + assert "expense_claims" in _table_names(engine) _upgrade_head(migration_database_url) _assert_head_schema(engine) + _assert_learning_ledger_tenant_boundary(engine) _assert_runtime_cascade(engine) _create_legacy_sentinel(engine) diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py index 3a9d8dd..19be09d 100644 --- a/server/tests/test_migration_preflight.py +++ b/server/tests/test_migration_preflight.py @@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng @pytest.mark.parametrize( "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( engine: Engine, @@ -87,6 +87,10 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set( "20260713_0002", 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( diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 4f9c890..ed6a4e8 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -16,6 +16,7 @@ from sqlalchemy.pool import StaticPool from app.api.deps import get_db from app.db.base import Base 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.employee import Employee 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.payload_json["previous_status"] == "draft" 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( isinstance(flag, dict) 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(ExpenseCaseLink)).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: diff --git a/server/tests/test_schema_ownership.py b/server/tests/test_schema_ownership.py index 2f9db24..e2d5c00 100644 --- a/server/tests/test_schema_ownership.py +++ b/server/tests/test_schema_ownership.py @@ -16,10 +16,13 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None: assert MIGRATION_OWNED_TABLES == frozenset( { + "ai_decision_feedback", + "ai_decisions", "auth_sessions", "business_events", "expense_case_links", "expense_cases", + "workflow_outcomes", } ) assert table_names diff --git a/server/tests/test_user_agent_application_draft_events.py b/server/tests/test_user_agent_application_draft_events.py index 678314f..8da21ec 100644 --- a/server/tests/test_user_agent_application_draft_events.py +++ b/server/tests/test_user_agent_application_draft_events.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base +from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.employee import Employee from app.models.expense_case import BusinessEvent, ExpenseCaseLink 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) -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( run_id=run_id, user_id="owner@example.com", @@ -40,6 +46,20 @@ def build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentReque "name": "张三", "employee_no": "E001", "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.commit() - updated = UserAgentService(db)._update_expense_application_record( - build_request(run_id="application-draft-update"), + service = UserAgentService(db) + request = build_request(run_id="application-draft-update") + updated = service._update_expense_application_record( + request, build_facts(reason="更新后的申请事由"), claim, submit=False, + learning_current_user=service._build_application_current_user(request), ) 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)) assert link is not None 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( @@ -217,8 +299,21 @@ def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None: request = build_request(run_id="application-draft-idempotent") facts = build_facts(reason="同一份草稿") service = UserAgentService(db) - service._update_expense_application_record(request, facts, claim, submit=False) - 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, + learning_current_user=learning_current_user, + ) + service._update_expense_application_record( + request, + facts, + claim, + submit=False, + learning_current_user=learning_current_user, + ) events = list( db.scalars( @@ -228,6 +323,9 @@ def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None: assert len(events) == 1 assert events[0].event_type == "claim_draft_updated" 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: @@ -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") service = UserAgentService(db) + learning_current_user = service._build_application_current_user(request) service._update_expense_application_record( request, build_facts(reason="第一版草稿"), claim, submit=False, + learning_current_user=learning_current_user, ) service._update_expense_application_record( request, build_facts(reason="第二版草稿"), claim, submit=False, + learning_current_user=learning_current_user, ) events = list( @@ -283,3 +384,71 @@ def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> N assert len(events) == 2 assert {event.event_type for event in events} == {"claim_draft_updated"} 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) diff --git a/web/src/utils/expenseApplicationDecisionFeedback.js b/web/src/utils/expenseApplicationDecisionFeedback.js new file mode 100644 index 0000000..dbb42b9 --- /dev/null +++ b/web/src/utils/expenseApplicationDecisionFeedback.js @@ -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)) + )) + } + } +} diff --git a/web/src/views/scripts/useApplicationPreviewEditor.js b/web/src/views/scripts/useApplicationPreviewEditor.js index 56a6a39..23a66ec 100644 --- a/web/src/views/scripts/useApplicationPreviewEditor.js +++ b/web/src/views/scripts/useApplicationPreviewEditor.js @@ -11,12 +11,66 @@ import { resolveApplicationDaysFromDateRange, refreshApplicationPreviewTransportEstimate } from '../../utils/expenseApplicationPreview.js' +import { trackApplicationPreviewFieldEdit } from '../../utils/expenseApplicationDecisionFeedback.js' import { buildWorkbenchDateLabel, canApplyWorkbenchDateSelection, getTodayDateValue } 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) { const text = String(value || '').trim() return [...text.matchAll(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g)] @@ -186,7 +240,14 @@ function validateApplicationPreviewDateRange(value = '') { } 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) { @@ -270,6 +331,7 @@ export function useApplicationPreviewEditor({ currentUser } = {}) { const applicationPreviewEditor = ref(buildEmptyEditor()) + const estimateRequestVersions = new WeakMap() async function refreshApplicationPreviewEstimate(preview = {}) { const user = resolveEditorCurrentUser(currentUser) @@ -406,14 +468,19 @@ export function useApplicationPreviewEditor({ } return false } - const nextPreview = normalizeApplicationPreview({ - ...message.applicationPreview, - fields: buildEditedApplicationPreviewFields( - message.applicationPreview.fields || {}, - editor, - nextValue - ) - }) + const isDateField = isApplicationPreviewDateField(editor.fieldKey) + const nextPreview = trackApplicationPreviewFieldEdit( + message.applicationPreview, + normalizeApplicationPreview({ + ...message.applicationPreview, + fields: buildEditedApplicationPreviewFields( + message.applicationPreview.fields || {}, + editor, + nextValue + ) + }), + isDateField ? [editor.fieldKey, 'days'] : editor.fieldKey + ) const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey) message.applicationPreview = needRefreshEstimate ? buildTransportEstimatePendingPreview(nextPreview) @@ -422,9 +489,23 @@ export function useApplicationPreviewEditor({ cancelApplicationPreviewEditor() persistSessionState?.() if (needRefreshEstimate) { + const requestVersion = (estimateRequestVersions.get(message) || 0) + 1 + estimateRequestVersions.set(message, requestVersion) + const estimateInputFingerprint = buildApplicationEstimateInputFingerprint(nextPreview) const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview) - message.applicationPreview = refreshedPreview - message.text = buildLocalApplicationPreviewMessage(refreshedPreview) + const isLatestRequest = estimateRequestVersions.get(message) === requestVersion + 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?.() toast?.('已更新出行方式和费用测算。') return true diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs index fc25b77..f0668bd 100644 --- a/web/tests/ai-application-preview-actions.test.mjs +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -42,6 +42,16 @@ async function testSubmitActionUsesFastPreviewEndpoint() { days: '3天', transportMode: '火车', amount: '1000元' + }, + aiDecisionFeedback: { + schemaVersion: 1, + changedFields: [ + { + fieldKey: 'transportMode', + suggestedValue: '飞机', + finalValue: '火车' + } + ] } }, 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.application_stage, 'expense_application') 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() { diff --git a/web/tests/expense-application-decision-feedback.test.mjs b/web/tests/expense-application-decision-feedback.test.mjs new file mode 100644 index 0000000..bad8c8e --- /dev/null +++ b/web/tests/expense-application-decision-feedback.test.mjs @@ -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') diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index e9bf1d2..2e1cc32 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -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.subsidyDailyCap, '100\u5143/\u5929') 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 () => { @@ -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.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 () => { @@ -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.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: '客户现场实施' } + ]) +})