feat(ai): add personal expense application memory

This commit is contained in:
caoxiaozhu
2026-07-14 17:16:23 +08:00
parent 211f85d981
commit 54754b5502
36 changed files with 3579 additions and 74 deletions

View File

@@ -0,0 +1,298 @@
"""add tenant-scoped expense application memory
Revision ID: 20260714_0005
Revises: 20260714_0004
Create Date: 2026-07-14 17:10:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0005"
down_revision: str | None = "20260714_0004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_unique_constraint(
"uq_ai_decision_feedback_tenant_id",
"ai_decision_feedback",
["tenant_id", "id"],
)
op.create_unique_constraint(
"uq_workflow_outcomes_tenant_id",
"workflow_outcomes",
["tenant_id", "id"],
)
op.create_table(
"memory_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column(
"scope_type",
sa.String(length=20),
server_default="user",
nullable=False,
),
sa.Column("scope_id", sa.String(length=120), nullable=False),
sa.Column(
"scene",
sa.String(length=50),
server_default="travel_application",
nullable=False,
),
sa.Column(
"field_key",
sa.String(length=60),
server_default="transport_mode",
nullable=False,
),
sa.Column("generation", sa.Integer(), server_default="1", nullable=False),
sa.Column("value_json", sa.JSON(), nullable=False),
sa.Column("value_fingerprint", sa.String(length=80), nullable=False),
sa.Column(
"status",
sa.String(length=20),
server_default="candidate",
nullable=False,
),
sa.Column("evidence_count", sa.Integer(), server_default="0", nullable=False),
sa.Column(
"approved_evidence_count",
sa.Integer(),
server_default="0",
nullable=False,
),
sa.Column(
"confidence",
sa.Numeric(precision=5, scale=4),
server_default="0",
nullable=False,
),
sa.Column(
"last_evidence_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("candidate_expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("active_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("suppressed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_reason", sa.String(length=255), nullable=True),
sa.Column("superseded_by_id", sa.String(length=36), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"scope_type = 'user'",
name="ck_memory_entries_scope_type",
),
sa.CheckConstraint(
"scene = 'travel_application'",
name="ck_memory_entries_scene",
),
sa.CheckConstraint(
"field_key = 'transport_mode'",
name="ck_memory_entries_field_key",
),
sa.CheckConstraint(
"status IN ('candidate', 'active', 'suppressed', 'expired', 'revoked')",
name="ck_memory_entries_status",
),
sa.CheckConstraint(
"generation >= 1",
name="ck_memory_entries_generation",
),
sa.CheckConstraint(
"evidence_count >= 0 AND approved_evidence_count >= 0 "
"AND approved_evidence_count <= evidence_count",
name="ck_memory_entries_evidence_counts",
),
sa.CheckConstraint(
"confidence >= 0 AND confidence <= 1",
name="ck_memory_entries_confidence",
),
sa.CheckConstraint(
"candidate_expires_at > created_at",
name="ck_memory_entries_candidate_expiry",
),
sa.CheckConstraint(
"active_expires_at IS NULL OR ("
"activated_at IS NOT NULL AND active_expires_at > activated_at)",
name="ck_memory_entries_active_expiry",
),
sa.CheckConstraint(
"status != 'active' OR (activated_at IS NOT NULL AND active_expires_at IS NOT NULL)",
name="ck_memory_entries_active_fields",
),
sa.CheckConstraint(
"status != 'suppressed' OR suppressed_at IS NOT NULL",
name="ck_memory_entries_suppressed_fields",
),
sa.CheckConstraint(
"status != 'expired' OR expired_at IS NOT NULL",
name="ck_memory_entries_expired_fields",
),
sa.CheckConstraint(
"status != 'revoked' OR (revoked_at IS NOT NULL AND length(revoked_reason) > 0)",
name="ck_memory_entries_revoked_fields",
),
sa.CheckConstraint(
"superseded_by_id IS NULL OR status IN ('suppressed', 'revoked')",
name="ck_memory_entries_superseded_status",
),
sa.CheckConstraint(
"superseded_by_id IS NULL OR superseded_by_id != id",
name="ck_memory_entries_not_self_superseded",
),
sa.ForeignKeyConstraint(
["tenant_id", "superseded_by_id"],
["memory_entries.tenant_id", "memory_entries.id"],
name="fk_memory_entries_tenant_superseded_by",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_memory_entries_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"scope_type",
"scope_id",
"scene",
"field_key",
"generation",
name="uq_memory_entries_generation",
),
)
op.create_index(
"ix_memory_entries_scope_lookup",
"memory_entries",
["tenant_id", "scope_type", "scope_id", "scene", "field_key", "status"],
)
op.create_index(
"ix_memory_entries_status_expiry",
"memory_entries",
["tenant_id", "status", "candidate_expires_at", "active_expires_at"],
)
op.create_table(
"memory_evidence_links",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("memory_entry_id", sa.String(length=36), 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("feedback_id", sa.String(length=36), nullable=False),
sa.Column("outcome_id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["tenant_id", "memory_entry_id"],
["memory_entries.tenant_id", "memory_entries.id"],
name="fk_memory_evidence_links_tenant_entry",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_memory_evidence_links_tenant_case",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "decision_id"],
["ai_decisions.tenant_id", "ai_decisions.id"],
name="fk_memory_evidence_links_tenant_decision",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "feedback_id"],
["ai_decision_feedback.tenant_id", "ai_decision_feedback.id"],
name="fk_memory_evidence_links_tenant_feedback",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["tenant_id", "outcome_id"],
["workflow_outcomes.tenant_id", "workflow_outcomes.id"],
name="fk_memory_evidence_links_tenant_outcome",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_memory_evidence_links_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"memory_entry_id",
"expense_case_id",
name="uq_memory_evidence_links_entry_case",
),
)
op.create_index(
"ix_memory_evidence_links_entry_time",
"memory_evidence_links",
["tenant_id", "memory_entry_id", "created_at"],
)
op.create_index(
"ix_memory_evidence_links_sources",
"memory_evidence_links",
["tenant_id", "decision_id", "feedback_id", "outcome_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_memory_evidence_links_sources",
table_name="memory_evidence_links",
)
op.drop_index(
"ix_memory_evidence_links_entry_time",
table_name="memory_evidence_links",
)
op.drop_table("memory_evidence_links")
op.drop_index(
"ix_memory_entries_status_expiry",
table_name="memory_entries",
)
op.drop_index(
"ix_memory_entries_scope_lookup",
table_name="memory_entries",
)
op.drop_table("memory_entries")
op.drop_constraint(
"uq_workflow_outcomes_tenant_id",
"workflow_outcomes",
type_="unique",
)
op.drop_constraint(
"uq_ai_decision_feedback_tenant_id",
"ai_decision_feedback",
type_="unique",
)

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.expense_application_memory import (
ExpenseApplicationMemoryListRead,
ExpenseApplicationMemoryRevokedRead,
)
from app.services.expense_application_memory import ExpenseApplicationMemoryService
router = APIRouter(prefix="/expense-application-memories")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"/me",
response_model=ExpenseApplicationMemoryListRead,
summary="读取当前登录人的费用申请记忆",
)
def list_my_expense_application_memories(
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationMemoryListRead:
return ExpenseApplicationMemoryService(db).list_current_user_memories(current_user)
@router.delete(
"/{memory_id}",
response_model=ExpenseApplicationMemoryRevokedRead,
summary="撤销当前登录人的费用申请记忆",
)
def revoke_my_expense_application_memory(
memory_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationMemoryRevokedRead:
result = ExpenseApplicationMemoryService(db).revoke_current_user_memory(
memory_id,
current_user,
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="未找到可撤销的个人费用申请记忆。",
)
return result

View File

@@ -15,6 +15,9 @@ from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.budgets import router as budgets_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.expense_application_memories import (
router as expense_application_memories_router,
)
from app.api.v1.endpoints.expense_application_previews import (
router as expense_application_previews_router,
)
@@ -57,6 +60,7 @@ router.include_router(orchestrator_router, tags=["orchestrator"])
router.include_router(receipt_folder_router, tags=["receipt-folder"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(expense_cases_router, tags=["expense-cases"])
router.include_router(expense_application_memories_router, tags=["expense-application-memories"])
router.include_router(expense_application_previews_router, tags=["reimbursements"])
router.include_router(employee_profiles_router, tags=["employee-profiles"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])

View File

@@ -11,6 +11,7 @@ from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -76,6 +77,8 @@ __all__ = [
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"MemoryEntry",
"MemoryEvidenceLink",
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",

View File

@@ -50,8 +50,22 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"workflow_outcomes",
}
),
"20260714_0005": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"workflow_outcomes",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")

View File

@@ -13,6 +13,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"expense_cases",
"expense_case_links",
"business_events",
"memory_entries",
"memory_evidence_links",
"workflow_outcomes",
}
)

View File

@@ -9,6 +9,7 @@ from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -72,6 +73,8 @@ __all__ = [
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"MemoryEntry",
"MemoryEvidenceLink",
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",

View File

@@ -133,6 +133,11 @@ class AIDecisionFeedback(Base):
__tablename__ = "ai_decision_feedback"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_ai_decision_feedback_tenant_id",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
@@ -203,6 +208,11 @@ class WorkflowOutcome(Base):
__tablename__ = "workflow_outcomes"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_workflow_outcomes_tenant_id",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",

View File

@@ -0,0 +1,300 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import (
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
MEMORY_CANDIDATE_TTL_DAYS = 90
MEMORY_ACTIVE_TTL_DAYS = 180
def _new_id() -> str:
return str(uuid.uuid4())
def _candidate_expires_at() -> datetime:
return datetime.now(UTC) + timedelta(days=MEMORY_CANDIDATE_TTL_DAYS)
class MemoryEntry(Base):
"""受证据约束、可撤销的个人费用申请记忆。"""
__tablename__ = "memory_entries"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_memory_entries_tenant_id",
),
UniqueConstraint(
"tenant_id",
"scope_type",
"scope_id",
"scene",
"field_key",
"generation",
name="uq_memory_entries_generation",
),
ForeignKeyConstraint(
["tenant_id", "superseded_by_id"],
["memory_entries.tenant_id", "memory_entries.id"],
ondelete="RESTRICT",
name="fk_memory_entries_tenant_superseded_by",
),
CheckConstraint(
"scope_type = 'user'",
name="ck_memory_entries_scope_type",
),
CheckConstraint(
"scene = 'travel_application'",
name="ck_memory_entries_scene",
),
CheckConstraint(
"field_key = 'transport_mode'",
name="ck_memory_entries_field_key",
),
CheckConstraint(
"status IN ('candidate', 'active', 'suppressed', 'expired', 'revoked')",
name="ck_memory_entries_status",
),
CheckConstraint(
"generation >= 1",
name="ck_memory_entries_generation",
),
CheckConstraint(
"evidence_count >= 0 AND approved_evidence_count >= 0 "
"AND approved_evidence_count <= evidence_count",
name="ck_memory_entries_evidence_counts",
),
CheckConstraint(
"confidence >= 0 AND confidence <= 1",
name="ck_memory_entries_confidence",
),
CheckConstraint(
"candidate_expires_at > created_at",
name="ck_memory_entries_candidate_expiry",
),
CheckConstraint(
"active_expires_at IS NULL OR ("
"activated_at IS NOT NULL AND active_expires_at > activated_at)",
name="ck_memory_entries_active_expiry",
),
CheckConstraint(
"status != 'active' OR (activated_at IS NOT NULL AND active_expires_at IS NOT NULL)",
name="ck_memory_entries_active_fields",
),
CheckConstraint(
"status != 'suppressed' OR suppressed_at IS NOT NULL",
name="ck_memory_entries_suppressed_fields",
),
CheckConstraint(
"status != 'expired' OR expired_at IS NOT NULL",
name="ck_memory_entries_expired_fields",
),
CheckConstraint(
"status != 'revoked' OR (revoked_at IS NOT NULL AND length(revoked_reason) > 0)",
name="ck_memory_entries_revoked_fields",
),
CheckConstraint(
"superseded_by_id IS NULL OR status IN ('suppressed', 'revoked')",
name="ck_memory_entries_superseded_status",
),
CheckConstraint(
"superseded_by_id IS NULL OR superseded_by_id != id",
name="ck_memory_entries_not_self_superseded",
),
Index(
"ix_memory_entries_scope_lookup",
"tenant_id",
"scope_type",
"scope_id",
"scene",
"field_key",
"status",
),
Index(
"ix_memory_entries_status_expiry",
"tenant_id",
"status",
"candidate_expires_at",
"active_expires_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
scope_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="user",
server_default="user",
)
scope_id: Mapped[str] = mapped_column(String(120), nullable=False)
scene: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="travel_application",
server_default="travel_application",
)
field_key: Mapped[str] = mapped_column(
String(60),
nullable=False,
default="transport_mode",
server_default="transport_mode",
)
generation: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
server_default="1",
)
value_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
value_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="candidate",
server_default="candidate",
)
evidence_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
)
approved_evidence_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
)
confidence: Mapped[Decimal] = mapped_column(
Numeric(5, 4),
nullable=False,
default=Decimal("0.0000"),
server_default="0",
)
last_evidence_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
server_default=func.now(),
)
candidate_expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=_candidate_expires_at,
)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
active_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
suppressed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
superseded_by_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
class MemoryEvidenceLink(Base):
"""一条记忆在一个费用 Case 中的去重证据包。"""
__tablename__ = "memory_evidence_links"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_memory_evidence_links_tenant_id",
),
UniqueConstraint(
"tenant_id",
"memory_entry_id",
"expense_case_id",
name="uq_memory_evidence_links_entry_case",
),
ForeignKeyConstraint(
["tenant_id", "memory_entry_id"],
["memory_entries.tenant_id", "memory_entries.id"],
ondelete="RESTRICT",
name="fk_memory_evidence_links_tenant_entry",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
ondelete="RESTRICT",
name="fk_memory_evidence_links_tenant_case",
),
ForeignKeyConstraint(
["tenant_id", "decision_id"],
["ai_decisions.tenant_id", "ai_decisions.id"],
ondelete="RESTRICT",
name="fk_memory_evidence_links_tenant_decision",
),
ForeignKeyConstraint(
["tenant_id", "feedback_id"],
["ai_decision_feedback.tenant_id", "ai_decision_feedback.id"],
ondelete="RESTRICT",
name="fk_memory_evidence_links_tenant_feedback",
),
ForeignKeyConstraint(
["tenant_id", "outcome_id"],
["workflow_outcomes.tenant_id", "workflow_outcomes.id"],
ondelete="RESTRICT",
name="fk_memory_evidence_links_tenant_outcome",
),
Index(
"ix_memory_evidence_links_entry_time",
"tenant_id",
"memory_entry_id",
"created_at",
),
Index(
"ix_memory_evidence_links_sources",
"tenant_id",
"decision_id",
"feedback_id",
"outcome_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
memory_entry_id: Mapped[str] = mapped_column(String(36), nullable=False)
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
decision_id: Mapped[str] = mapped_column(String(36), nullable=False)
feedback_id: Mapped[str] = mapped_column(String(36), nullable=False)
outcome_id: Mapped[str] = mapped_column(String(36), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class ExpenseApplicationMemoryApplication(BaseModel):
memory_id: str
field_key: str = "transport_mode"
field_label: str = "出行方式"
value: str
source: str = "verified_user_history"
status: str = "applied"
evidence_count: int = 0
approved_evidence_count: int = 0
confidence: float = 0.0
expires_at: datetime | None = None
message: str = "已按可信历史记忆预填常用出行方式,可继续修改。"
class ExpenseApplicationLearningReceipt(BaseModel):
memory_id: str
evidence_id: str
field_key: str = "transport_mode"
field_label: str = "出行方式"
value: str
status: str
evidence_count: int = 0
approved_evidence_count: int = 0
activated: bool = False
message: str
class ExpenseApplicationMemoryRead(BaseModel):
id: str
scene: str
field_key: str
value: str = ""
status: str
evidence_count: int = 0
approved_evidence_count: int = 0
confidence: float = 0.0
activation_threshold: int = 3
policy_version: str
valid_from: datetime | None = None
expires_at: datetime | None = None
last_evidence_at: datetime | None = None
activated_at: datetime | None = None
suppressed_at: datetime | None = None
revoked_at: datetime | None = None
revoked_reason: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class ExpenseApplicationMemoryListRead(BaseModel):
items: list[ExpenseApplicationMemoryRead] = Field(default_factory=list)
class ExpenseApplicationMemoryRevokedRead(BaseModel):
memory_id: str
status: str = "revoked"
revoked_at: datetime

View File

@@ -207,6 +207,7 @@ class ExpenseApplicationPreviewActionResult(BaseModel):
draft_payload: dict[str, Any] | None = None
decision_id: str | None = None
decision_expires_at: datetime | None = None
learning_receipts: list[dict[str, Any]] = Field(default_factory=list)
class ExpenseApplicationPreviewActionResponse(BaseModel):

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from decimal import Decimal
@@ -14,6 +15,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
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_application_memory import ExpenseApplicationMemoryService
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
)
@@ -27,6 +29,8 @@ from app.services.expense_application_snapshot import (
)
from app.services.expense_cases import ExpenseCaseService
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ExpenseApplicationLearningRecords:
@@ -122,6 +126,12 @@ class ExpenseApplicationLearningService:
)
existing = self._find_existing(tenant_id, idempotency_key)
if existing is not None:
self._record_transport_memory_evidence(
current_user=current_user,
records=existing,
claim=claim,
facts=final_values,
)
return existing
decision_id = self._stable_id("decision", tenant_id, idempotency_key)
@@ -226,6 +236,13 @@ class ExpenseApplicationLearningService:
)
self.db.add_all([decision, feedback, outcome])
self.db.flush()
records = ExpenseApplicationLearningRecords(decision, feedback, outcome)
self._record_transport_memory_evidence(
current_user=current_user,
records=records,
claim=claim,
facts=final_values,
)
if preview_decision is not None:
ExpenseApplicationPreviewDecisionService(self.db).consume(
preview_decision,
@@ -235,7 +252,30 @@ class ExpenseApplicationLearningService:
claim=claim,
business_event=business_event,
)
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
return records
def _record_transport_memory_evidence(
self,
*,
current_user: CurrentUserContext,
records: ExpenseApplicationLearningRecords,
claim: ExpenseClaim,
facts: dict[str, str],
) -> None:
"""记忆属于可降级派生能力,失败不能回滚申请和学习账本。"""
try:
with self.db.begin_nested():
ExpenseApplicationMemoryService(self.db).record_transport_edit_evidence(
current_user=current_user,
decision=records.decision,
feedback=records.feedback,
outcome=records.outcome,
claim=claim,
transport_mode=str(facts.get("transport_mode") or ""),
)
except Exception:
logger.warning("个人出行方式记忆证据写入失败,本次申请继续提交。", exc_info=True)
def _find_existing(
self,

View File

@@ -0,0 +1,699 @@
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.schemas.expense_application_memory import (
ExpenseApplicationLearningReceipt,
ExpenseApplicationMemoryApplication,
ExpenseApplicationMemoryListRead,
ExpenseApplicationMemoryRead,
ExpenseApplicationMemoryRevokedRead,
)
from app.services.expense_application_memory_evidence import (
ExpenseApplicationMemoryEvidenceValidator,
)
from app.services.expense_application_snapshot import hmac_fingerprint
from app.services.expense_cases import ExpenseCaseService
logger = logging.getLogger(__name__)
MEMORY_SCOPE_TYPE = "user"
MEMORY_SCENE = "travel_application"
MEMORY_FIELD_KEY = "transport_mode"
MEMORY_POLICY_VERSION = "expense_application_transport_memory.v1"
MEMORY_ACTIVATION_THRESHOLD = 3
MEMORY_APPROVED_THRESHOLD = 2
MEMORY_EVIDENCE_SPAN = timedelta(days=7)
MEMORY_CANDIDATE_TTL = timedelta(days=90)
MEMORY_ACTIVE_TTL = timedelta(days=180)
MEMORY_OUTCOME_EVENT_TYPES = {"application_approved", "application_returned"}
SUPPORTED_TRANSPORT_VALUES = {"飞机", "火车", "轮船"}
class ExpenseApplicationMemoryService:
"""从可信字段纠正证据生成、激活并应用个人费用申请记忆。"""
def __init__(self, db: Session) -> None:
self.db = db
def record_transport_edit_evidence(
self,
*,
current_user: CurrentUserContext,
decision: AIDecision,
feedback: AIDecisionFeedback,
outcome: WorkflowOutcome,
claim: ExpenseClaim,
transport_mode: str,
) -> ExpenseApplicationLearningReceipt | None:
if not self._is_eligible_feedback(feedback, outcome):
return None
if not self._changed_transport_mode(feedback):
return None
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
if any(
item != tenant_id
for item in (decision.tenant_id, feedback.tenant_id, outcome.tenant_id)
):
raise PermissionError("个人记忆证据不能关联其他租户的学习记录。")
ExpenseApplicationMemoryEvidenceValidator(self.db).validate(
current_user=current_user,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
)
scope_id = self._scope_id(current_user)
now = datetime.now(UTC)
replay = self.db.execute(
select(MemoryEvidenceLink, MemoryEntry)
.join(MemoryEntry, MemoryEntry.id == MemoryEvidenceLink.memory_entry_id)
.where(
MemoryEvidenceLink.tenant_id == tenant_id,
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
or_(
MemoryEvidenceLink.decision_id == decision.id,
MemoryEvidenceLink.feedback_id == feedback.id,
MemoryEvidenceLink.outcome_id == outcome.id,
),
)
).first()
if replay is not None:
evidence, replay_entry = replay
return self._build_receipt(
replay_entry,
evidence,
activated=False,
)
normalized_value = self._normalize_transport_value(transport_mode)
if not normalized_value:
# 未知方式只作为旧偏好失效的负向信号,绝不保存新值。
self._suppress_active_entries(
tenant_id=tenant_id,
scope_id=scope_id,
now=now,
)
self.db.flush()
return None
value_fingerprint = self._value_fingerprint(normalized_value)
entry = self._find_open_entry(
tenant_id=tenant_id,
scope_id=scope_id,
value_fingerprint=value_fingerprint,
)
while entry is not None and self._is_expired(entry, now):
entry.status = "expired"
entry.expired_at = now
self.db.flush()
entry = self._find_open_entry(
tenant_id=tenant_id,
scope_id=scope_id,
value_fingerprint=value_fingerprint,
)
if entry is None:
entry = self._create_candidate_entry(
tenant_id=tenant_id,
scope_id=scope_id,
value=normalized_value,
value_fingerprint=value_fingerprint,
now=now,
)
self.db.add(entry)
self.db.flush()
existing_link = self.db.scalar(
select(MemoryEvidenceLink).where(
MemoryEvidenceLink.tenant_id == tenant_id,
MemoryEvidenceLink.memory_entry_id == entry.id,
MemoryEvidenceLink.expense_case_id == decision.expense_case_id,
)
)
if existing_link is not None:
return self._build_receipt(
entry,
existing_link,
activated=False,
)
existing_link = MemoryEvidenceLink(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
memory_entry_id=entry.id,
decision_id=decision.id,
feedback_id=feedback.id,
expense_case_id=decision.expense_case_id,
outcome_id=outcome.id,
)
self.db.add(existing_link)
self.db.flush()
self._suppress_opposite_active_entries(
tenant_id=tenant_id,
scope_id=scope_id,
value_fingerprint=value_fingerprint,
now=now,
)
previous_status = str(entry.status or "")
self._refresh_entry_metrics(entry, now=now, refresh_expiry=True)
self.db.flush()
return self._build_receipt(
entry,
existing_link,
activated=previous_status != "active" and entry.status == "active",
)
def apply_active_transport_memory(
self,
facts: dict[str, Any],
current_user: CurrentUserContext,
) -> list[ExpenseApplicationMemoryApplication]:
# 当前输入只要显式给出了出行方式就必须优先,即使值暂不在可学习白名单内。
if str(facts.get(MEMORY_FIELD_KEY) or "").strip():
return []
try:
with self.db.begin_nested():
entry = self._resolve_active_entry(current_user)
if entry is None:
return []
value = self._entry_value(entry)
if not value:
return []
facts[MEMORY_FIELD_KEY] = value
return [self._build_application(entry, value)]
except Exception:
logger.warning("个人出行方式记忆读取失败,本轮预览不应用记忆。", exc_info=True)
return []
def learning_receipts_for_preview_decision(
self,
preview_decision_id: str,
current_user: CurrentUserContext,
) -> list[ExpenseApplicationLearningReceipt]:
normalized_id = str(preview_decision_id or "").strip()
if not normalized_id:
return []
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
scope_id = self._scope_id(current_user)
stmt = (
select(MemoryEvidenceLink, MemoryEntry)
.join(AIDecision, AIDecision.id == MemoryEvidenceLink.decision_id)
.join(MemoryEntry, MemoryEntry.id == MemoryEvidenceLink.memory_entry_id)
.where(
MemoryEvidenceLink.tenant_id == tenant_id,
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
AIDecision.preview_decision_id == normalized_id,
)
.order_by(MemoryEvidenceLink.created_at.asc())
)
return [
self._build_receipt(entry, evidence, activated=entry.status == "active")
for evidence, entry in self.db.execute(stmt).all()
]
def list_current_user_memories(
self,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryListRead:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
scope_id = self._scope_id(current_user)
now = datetime.now(UTC)
entries = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
)
.order_by(MemoryEntry.generation.desc(), MemoryEntry.created_at.desc())
).all()
)
for entry in entries:
if entry.status in {"candidate", "active"}:
self._refresh_entry_metrics(entry, now=now, allow_activation=False)
self.db.commit()
return ExpenseApplicationMemoryListRead(
items=[self._serialize_entry(entry) for entry in entries]
)
def revoke_current_user_memory(
self,
memory_id: str,
current_user: CurrentUserContext,
) -> ExpenseApplicationMemoryRevokedRead | None:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
scope_id = self._scope_id(current_user)
entry = self.db.scalar(
select(MemoryEntry)
.where(
MemoryEntry.id == str(memory_id or "").strip(),
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
)
.with_for_update()
)
if entry is None:
return None
now = datetime.now(UTC)
entry.status = "revoked"
entry.value_json = {}
entry.value_fingerprint = ""
entry.revoked_at = now
entry.revoked_reason = "user_requested"
self.db.commit()
return ExpenseApplicationMemoryRevokedRead(
memory_id=entry.id,
revoked_at=now,
)
def _resolve_active_entry(
self,
current_user: CurrentUserContext,
) -> MemoryEntry | None:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
scope_id = self._scope_id(current_user)
now = datetime.now(UTC)
entries = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status.in_(["candidate", "active"]),
)
.order_by(MemoryEntry.last_evidence_at.desc(), MemoryEntry.generation.desc())
.with_for_update()
).all()
)
active_entry = next((entry for entry in entries if entry.status == "active"), None)
if active_entry is not None:
self._refresh_entry_metrics(active_entry, now=now)
if active_entry.status == "active":
return active_entry
for entry in entries:
if entry.status != "candidate":
continue
self._refresh_entry_metrics(entry, now=now)
if entry.status == "active":
return entry
return None
def _refresh_entry_metrics(
self,
entry: MemoryEntry,
*,
now: datetime,
refresh_expiry: bool = False,
allow_activation: bool = True,
) -> None:
if entry.status not in {"candidate", "active"}:
return
if self._is_expired(entry, now):
entry.status = "expired"
entry.expired_at = now
return
links = ExpenseApplicationMemoryEvidenceValidator(
self.db
).list_currently_valid_links(
tenant_id=entry.tenant_id,
memory_entry_id=entry.id,
)
case_ids = {link.expense_case_id for link in links}
approved_case_ids = self._approved_case_ids(
tenant_id=entry.tenant_id,
case_ids=case_ids,
)
entry.evidence_count = len({link.expense_case_id for link in links})
entry.approved_evidence_count = len(approved_case_ids)
entry.confidence = Decimal(
str(min(1.0, entry.evidence_count / MEMORY_ACTIVATION_THRESHOLD))
).quantize(Decimal("0.0001"))
if links:
entry.last_evidence_at = max(link.created_at for link in links)
if refresh_expiry:
if entry.status == "active":
entry.active_expires_at = now + MEMORY_ACTIVE_TTL
else:
entry.candidate_expires_at = now + MEMORY_CANDIDATE_TTL
if entry.status == "active" and not self._qualifies_for_activation(entry, links):
# 审批退回、证据 invalidated 或 outcome reversed 后不得继续预填。
entry.status = "suppressed"
entry.suppressed_at = now
return
if (
not allow_activation
or entry.status != "candidate"
or not self._qualifies_for_activation(entry, links)
):
return
self._suppress_other_active_entries(entry, now=now)
entry.status = "active"
entry.activated_at = now
entry.active_expires_at = now + MEMORY_ACTIVE_TTL
@staticmethod
def _qualifies_for_activation(
entry: MemoryEntry,
links: list[MemoryEvidenceLink],
) -> bool:
if (
entry.evidence_count < MEMORY_ACTIVATION_THRESHOLD
or entry.approved_evidence_count < MEMORY_APPROVED_THRESHOLD
or len(links) < 2
):
return False
first_at = min(link.created_at for link in links)
last_at = max(link.created_at for link in links)
return last_at - first_at >= MEMORY_EVIDENCE_SPAN
def _suppress_other_active_entries(
self,
activated_entry: MemoryEntry,
*,
now: datetime,
) -> None:
entries = list(
self.db.scalars(
select(MemoryEntry).where(
MemoryEntry.tenant_id == activated_entry.tenant_id,
MemoryEntry.scope_type == activated_entry.scope_type,
MemoryEntry.scope_id == activated_entry.scope_id,
MemoryEntry.scene == activated_entry.scene,
MemoryEntry.field_key == activated_entry.field_key,
MemoryEntry.status == "active",
MemoryEntry.id != activated_entry.id,
)
).all()
)
for entry in entries:
entry.status = "suppressed"
entry.suppressed_at = now
def _suppress_opposite_active_entries(
self,
*,
tenant_id: str,
scope_id: str,
value_fingerprint: str,
now: datetime,
) -> None:
entries = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status == "active",
MemoryEntry.value_fingerprint != value_fingerprint,
)
.with_for_update()
).all()
)
for entry in entries:
entry.status = "suppressed"
entry.suppressed_at = now
def _suppress_active_entries(
self,
*,
tenant_id: str,
scope_id: str,
now: datetime,
) -> None:
entries = list(
self.db.scalars(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.status == "active",
)
.with_for_update()
).all()
)
for entry in entries:
entry.status = "suppressed"
entry.suppressed_at = now
def _find_open_entry(
self,
*,
tenant_id: str,
scope_id: str,
value_fingerprint: str,
) -> MemoryEntry | None:
return self.db.scalar(
select(MemoryEntry)
.where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
MemoryEntry.value_fingerprint == value_fingerprint,
MemoryEntry.status.in_(["candidate", "active"]),
)
.order_by(MemoryEntry.generation.desc())
.with_for_update()
)
def _create_candidate_entry(
self,
*,
tenant_id: str,
scope_id: str,
value: str,
value_fingerprint: str,
now: datetime,
) -> MemoryEntry:
generation = int(
self.db.scalar(
select(func.coalesce(func.max(MemoryEntry.generation), 0)).where(
MemoryEntry.tenant_id == tenant_id,
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
MemoryEntry.scope_id == scope_id,
MemoryEntry.scene == MEMORY_SCENE,
MemoryEntry.field_key == MEMORY_FIELD_KEY,
)
)
or 0
) + 1
return MemoryEntry(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
scope_type=MEMORY_SCOPE_TYPE,
scope_id=scope_id,
scene=MEMORY_SCENE,
field_key=MEMORY_FIELD_KEY,
generation=generation,
value_json={"value": value},
value_fingerprint=value_fingerprint,
status="candidate",
evidence_count=0,
approved_evidence_count=0,
confidence=Decimal("0"),
candidate_expires_at=now + MEMORY_CANDIDATE_TTL,
last_evidence_at=now,
)
def _approved_case_ids(
self,
*,
tenant_id: str,
case_ids: set[str],
) -> set[str]:
if not case_ids:
return set()
events = list(
self.db.scalars(
select(BusinessEvent)
.where(
BusinessEvent.tenant_id == tenant_id,
BusinessEvent.expense_case_id.in_(case_ids),
BusinessEvent.event_type.in_(MEMORY_OUTCOME_EVENT_TYPES),
)
.order_by(
BusinessEvent.occurred_at.asc(),
case(
(BusinessEvent.event_type == "application_returned", 1),
else_=0,
).asc(),
BusinessEvent.id.asc(),
)
).all()
)
latest_by_case: dict[str, BusinessEvent] = {}
for event in events:
latest_by_case[event.expense_case_id] = event
return {
case_id
for case_id, event in latest_by_case.items()
if event.event_type == "application_approved"
}
@staticmethod
def _is_eligible_feedback(
feedback: AIDecisionFeedback,
outcome: WorkflowOutcome,
) -> bool:
return (
feedback.verification_status in {"server_verified", "human_verified"}
and feedback.feedback_type == "edited"
and feedback.action_type == "submit"
and outcome.outcome_type == "application_submitted"
and outcome.outcome_status in {"recorded", "verified"}
)
@staticmethod
def _changed_transport_mode(feedback: AIDecisionFeedback) -> bool:
return any(
isinstance(item, dict) and item.get("field_key") == MEMORY_FIELD_KEY
for item in list(feedback.changed_fields_json or [])
)
@staticmethod
def _scope_id(current_user: CurrentUserContext) -> str:
value = str(current_user.employee_id or current_user.username or "").strip()[:120]
if not value:
raise ValueError("当前登录用户缺少可用于个人记忆的主体标识。")
return value
@staticmethod
def _normalize_transport_value(value: object) -> str:
normalized = str(value or "").strip()
return normalized if normalized in SUPPORTED_TRANSPORT_VALUES else ""
@staticmethod
def _value_fingerprint(value: str) -> str:
return hmac_fingerprint({"field_key": MEMORY_FIELD_KEY, "value": value})
@staticmethod
def _entry_value(entry: MemoryEntry) -> str:
value_json = entry.value_json if isinstance(entry.value_json, dict) else {}
return ExpenseApplicationMemoryService._normalize_transport_value(
value_json.get("value")
)
@staticmethod
def _is_expired(entry: MemoryEntry, now: datetime) -> bool:
expires_at = (
entry.active_expires_at
if entry.status == "active"
else entry.candidate_expires_at
)
if expires_at is None:
return False
normalized = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=UTC)
return normalized <= now
@staticmethod
def _build_application(
entry: MemoryEntry,
value: str,
) -> ExpenseApplicationMemoryApplication:
return ExpenseApplicationMemoryApplication(
memory_id=entry.id,
value=value,
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
confidence=float(entry.confidence or 0),
expires_at=entry.active_expires_at,
)
@staticmethod
def _build_receipt(
entry: MemoryEntry,
evidence: MemoryEvidenceLink,
*,
activated: bool,
) -> ExpenseApplicationLearningReceipt:
external_status = "applied" if entry.status == "active" else entry.status
if external_status == "applied":
message = "已形成常用出行方式记忆,后续申请可自动预填。"
else:
remaining = max(0, MEMORY_ACTIVATION_THRESHOLD - int(entry.evidence_count or 0))
message = (
f"已记录本次出行方式纠正,再积累 {remaining} 个不同申请证据后可参与预填。"
if remaining
else "已记录本次出行方式纠正,待审批通过证据满足后可参与预填。"
)
return ExpenseApplicationLearningReceipt(
memory_id=entry.id,
evidence_id=evidence.id,
value=ExpenseApplicationMemoryService._entry_value(entry),
status=external_status,
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
activated=activated,
message=message,
)
@staticmethod
def _serialize_entry(entry: MemoryEntry) -> ExpenseApplicationMemoryRead:
value = (
""
if entry.status == "revoked"
else ExpenseApplicationMemoryService._entry_value(entry)
)
return ExpenseApplicationMemoryRead(
id=entry.id,
scene=entry.scene,
field_key=entry.field_key,
value=value,
status=entry.status,
evidence_count=int(entry.evidence_count or 0),
approved_evidence_count=int(entry.approved_evidence_count or 0),
confidence=float(entry.confidence or 0),
activation_threshold=MEMORY_ACTIVATION_THRESHOLD,
policy_version=MEMORY_POLICY_VERSION,
valid_from=entry.activated_at or entry.created_at,
expires_at=(
entry.active_expires_at
if entry.status == "active"
else entry.candidate_expires_at
),
last_evidence_at=entry.last_evidence_at,
activated_at=entry.activated_at,
suppressed_at=entry.suppressed_at,
revoked_at=entry.revoked_at,
revoked_reason=str(entry.revoked_reason or ""),
created_at=entry.created_at,
updated_at=entry.updated_at,
)

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from sqlalchemy import and_, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEvidenceLink
from app.models.expense_case import BusinessEvent, ExpenseCase
from app.models.financial_record import ExpenseClaim
class ExpenseApplicationMemoryEvidenceValidator:
"""校验个人记忆证据链及其租户、操作人和费用主体。"""
def __init__(self, db: Session) -> None:
self.db = db
def validate(
self,
*,
current_user: CurrentUserContext,
decision: AIDecision,
feedback: AIDecisionFeedback,
outcome: WorkflowOutcome,
claim: ExpenseClaim,
) -> None:
self._validate_record_links(
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
)
allowed_actor_ids = self._allowed_actor_ids(current_user)
evidence_actor_ids = {
str(feedback.actor_id or "").strip().casefold(),
str(outcome.actor_id or "").strip().casefold(),
}
if not allowed_actor_ids or not evidence_actor_ids.issubset(allowed_actor_ids):
raise PermissionError("个人记忆证据的操作人与当前登录人不一致。")
business_event = self.db.scalar(
select(BusinessEvent).where(
BusinessEvent.tenant_id == decision.tenant_id,
BusinessEvent.expense_case_id == decision.expense_case_id,
BusinessEvent.id == decision.business_event_id,
)
)
if business_event is None:
raise ValueError("个人记忆证据缺少可信的提交业务事件。")
if (
business_event.event_type != "application_submitted"
or business_event.aggregate_type != "expense_claim"
or business_event.aggregate_id != str(claim.id)
):
raise ValueError("个人记忆证据关联的提交业务事件语义不一致。")
if str(business_event.actor_id or "").strip().casefold() not in allowed_actor_ids:
raise PermissionError("个人记忆证据的提交人与当前登录人不一致。")
owner_employee_id = str(
self.db.scalar(
select(ExpenseCase.owner_employee_id).where(
ExpenseCase.tenant_id == decision.tenant_id,
ExpenseCase.id == decision.expense_case_id,
)
)
or ""
).strip()
current_employee_id = str(current_user.employee_id or "").strip()
if (
owner_employee_id
and current_employee_id
and owner_employee_id != current_employee_id
):
raise PermissionError("个人记忆证据的费用 Case 不属于当前员工。")
claim_employee_id = str(claim.employee_id or "").strip()
if (
claim_employee_id
and current_employee_id
and claim_employee_id != current_employee_id
):
raise PermissionError("个人记忆证据的申请单不属于当前员工。")
def list_currently_valid_links(
self,
*,
tenant_id: str,
memory_entry_id: str,
) -> list[MemoryEvidenceLink]:
"""只返回尚未失效、反转或降级的可信提交纠正证据。"""
return list(
self.db.scalars(
select(MemoryEvidenceLink)
.join(
AIDecisionFeedback,
and_(
AIDecisionFeedback.tenant_id
== MemoryEvidenceLink.tenant_id,
AIDecisionFeedback.id == MemoryEvidenceLink.feedback_id,
),
)
.join(
WorkflowOutcome,
and_(
WorkflowOutcome.tenant_id == MemoryEvidenceLink.tenant_id,
WorkflowOutcome.id == MemoryEvidenceLink.outcome_id,
),
)
.where(
MemoryEvidenceLink.tenant_id == tenant_id,
MemoryEvidenceLink.memory_entry_id == memory_entry_id,
AIDecisionFeedback.verification_status.in_(
["server_verified", "human_verified"]
),
AIDecisionFeedback.feedback_type == "edited",
AIDecisionFeedback.action_type == "submit",
WorkflowOutcome.outcome_type == "application_submitted",
WorkflowOutcome.outcome_status.in_(["recorded", "verified"]),
)
.order_by(MemoryEvidenceLink.created_at.asc())
).all()
)
@staticmethod
def _validate_record_links(
*,
decision: AIDecision,
feedback: AIDecisionFeedback,
outcome: WorkflowOutcome,
claim: ExpenseClaim,
) -> None:
claim_ids = {
str(claim.id or ""),
str(decision.expense_claim_id or ""),
str(feedback.expense_claim_id or ""),
str(outcome.expense_claim_id or ""),
}
if len(claim_ids) != 1:
raise ValueError("个人记忆证据关联的申请单不一致。")
if feedback.decision_id != decision.id or outcome.decision_id != decision.id:
raise ValueError("个人记忆证据关联的 AI 决策不一致。")
if outcome.expense_case_id != decision.expense_case_id:
raise ValueError("个人记忆证据关联的费用 Case 不一致。")
if outcome.business_event_id != decision.business_event_id:
raise ValueError("个人记忆证据关联的业务事件不一致。")
@staticmethod
def _allowed_actor_ids(current_user: CurrentUserContext) -> set[str]:
return {
value.casefold()
for value in (
str(current_user.username or "").strip(),
str(current_user.employee_id or "").strip(),
)
if value
}

View File

@@ -16,6 +16,10 @@ from app.schemas.reimbursement import (
ExpenseApplicationPreviewActionResult,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.application_system_estimate import (
apply_application_system_estimate_to_facts,
)
from app.services.expense_application_memory import ExpenseApplicationMemoryService
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
PreviewDecisionConflictError,
@@ -53,6 +57,12 @@ class ExpenseApplicationPreviewWorkflow:
)
try:
facts = UserAgentService(self.db)._resolve_expense_application_facts(request)
memory_applications = ExpenseApplicationMemoryService(
self.db
).apply_active_transport_memory(facts, current_user)
if memory_applications:
# 交通方式会影响系统预估;记忆补空后必须在签名前重算派生字段。
apply_application_system_estimate_to_facts(facts)
issued = ExpenseApplicationPreviewDecisionService(self.db).issue(
facts,
current_user,
@@ -71,6 +81,9 @@ class ExpenseApplicationPreviewWorkflow:
expires_at=issued.decision.expires_at,
application_preview={
"fields": issued.fields,
"memoryApplications": [
item.model_dump(mode="json") for item in memory_applications
],
"decisionId": issued.decision.id,
"decisionSource": issued.decision.decision_source,
"decisionExpiresAt": issued.decision.expires_at.isoformat(),
@@ -154,6 +167,11 @@ class ExpenseApplicationPreviewWorkflow:
type(error).__name__,
)
learning_receipts = self._resolve_learning_receipts(
consumed_preview_decision_id,
current_user,
)
return ExpenseApplicationPreviewActionResponse(
status="succeeded",
conversation_id=payload.conversation_id,
@@ -179,9 +197,29 @@ class ExpenseApplicationPreviewWorkflow:
if next_preview_decision is not None
else None
),
learning_receipts=[
item.model_dump(mode="json") for item in learning_receipts
],
),
)
def _resolve_learning_receipts(
self,
preview_decision_id: str,
current_user: CurrentUserContext,
) -> list:
try:
with self.db.begin_nested():
return ExpenseApplicationMemoryService(
self.db
).learning_receipts_for_preview_decision(
preview_decision_id,
current_user,
)
except Exception:
logger.warning("个人记忆学习回执读取失败,本次申请动作保持成功。", exc_info=True)
return []
@staticmethod
def _build_action_request(
payload: ExpenseApplicationPreviewActionPayload,

View File

@@ -12,6 +12,7 @@ from app.schemas.ontology import OntologyParseResult
from app.schemas.orchestrator import OrchestratorRequest
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_application_memory import ExpenseApplicationMemoryService
from app.services.expense_application_preview_decisions import PreviewDecisionConflictError
from app.services.expense_application_preview_workflow import ExpenseApplicationPreviewWorkflow
from app.services.expense_application_snapshot import (
@@ -64,6 +65,9 @@ class OrchestratorExpenseApplicationWorkflow:
requires_confirmation=False,
)
facts = self.user_agent_service._resolve_expense_application_facts(request)
memory_applications = ExpenseApplicationMemoryService(
self.db
).apply_active_transport_memory(facts, current_user)
step = self.user_agent_service._resolve_expense_application_step(request, facts)
requested_action = self._resolve_requested_action(payload.message, decision_state)
if requested_action:
@@ -83,6 +87,7 @@ class OrchestratorExpenseApplicationWorkflow:
facts=facts,
decision_state=decision_state,
current_preview=current_preview,
memory_applications=memory_applications,
current_user=current_user,
conversation_id=conversation_id,
context_json=context_json,
@@ -106,12 +111,16 @@ class OrchestratorExpenseApplicationWorkflow:
facts: dict[str, Any],
decision_state: dict[str, Any],
current_preview: dict[str, Any],
memory_applications: list[Any],
current_user: CurrentUserContext,
conversation_id: str | None,
context_json: dict[str, Any],
) -> ExecutionOutcome:
resolved_request = request.model_copy(
update={"message": self._build_facts_message(facts)}
)
preview_response = self.user_agent_service._build_expense_application_response(
request,
resolved_request,
risk_flags=[],
)
result = OrchestratorExecutionEngine._build_user_agent_result(
@@ -141,6 +150,10 @@ class OrchestratorExpenseApplicationWorkflow:
decision_id = issue_response.decision_id
decision_source = issue_response.decision_source
expires_at = issue_response.expires_at.isoformat()
if memory_applications:
issued_preview["memoryApplications"] = [
item.model_dump(mode="json") for item in memory_applications
]
context_json["application_preview_decision"] = {
"status": "issued",

View File

@@ -19,7 +19,7 @@ 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 = "20260714_0004"
HEAD_REVISION = "20260714_0005"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -132,6 +132,18 @@ def _assert_indexes(
assert indexes.get(index_name) == expected_columns
def _assert_check_constraint(
engine: Engine,
table_name: str,
constraint_name: str,
) -> None:
constraints = {
str(item["name"])
for item in inspect(engine).get_check_constraints(table_name, schema="public")
}
assert constraint_name in constraints
def _assert_cascade_foreign_key(engine: Engine, table_name: str) -> None:
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
matching = [
@@ -226,18 +238,47 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_ai_application_preview_decisions_issue_request",
("tenant_id", "actor_id", "auth_session_id", "issue_request_id"),
)
_assert_unique_constraint(
engine,
"ai_decision_feedback",
"uq_ai_decision_feedback_tenant_id",
("tenant_id", "id"),
)
_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_id",
("tenant_id", "id"),
)
_assert_unique_constraint(
engine,
"workflow_outcomes",
"uq_workflow_outcomes_tenant_idempotency",
("tenant_id", "idempotency_key"),
)
_assert_unique_constraint(
engine,
"memory_entries",
"uq_memory_entries_generation",
("tenant_id", "scope_type", "scope_id", "scene", "field_key", "generation"),
)
_assert_unique_constraint(
engine,
"memory_evidence_links",
"uq_memory_evidence_links_entry_case",
("tenant_id", "memory_entry_id", "expense_case_id"),
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_expired_fields",
)
_assert_indexes(
engine,
@@ -332,6 +373,31 @@ def _assert_head_schema(engine: Engine) -> None:
),
},
)
_assert_indexes(
engine,
"memory_entries",
{
"ix_memory_entries_scope_lookup": (
"tenant_id",
"scope_type",
"scope_id",
"scene",
"field_key",
"status",
),
},
)
_assert_indexes(
engine,
"memory_evidence_links",
{
"ix_memory_evidence_links_entry_time": (
"tenant_id",
"memory_entry_id",
"created_at",
),
},
)
_assert_cascade_foreign_key(engine, "expense_case_links")
_assert_cascade_foreign_key(engine, "business_events")
_assert_composite_foreign_key(
@@ -379,6 +445,42 @@ def _assert_head_schema(engine: Engine) -> None:
"business_events",
("tenant_id", "expense_case_id", "id"),
)
_assert_composite_foreign_key(
engine,
"memory_entries",
("tenant_id", "superseded_by_id"),
"memory_entries",
)
_assert_composite_foreign_key(
engine,
"memory_evidence_links",
("tenant_id", "memory_entry_id"),
"memory_entries",
)
_assert_composite_foreign_key(
engine,
"memory_evidence_links",
("tenant_id", "expense_case_id"),
"expense_cases",
)
_assert_composite_foreign_key(
engine,
"memory_evidence_links",
("tenant_id", "decision_id"),
"ai_decisions",
)
_assert_composite_foreign_key(
engine,
"memory_evidence_links",
("tenant_id", "feedback_id"),
"ai_decision_feedback",
)
_assert_composite_foreign_key(
engine,
"memory_evidence_links",
("tenant_id", "outcome_id"),
"workflow_outcomes",
)
def _assert_runtime_cascade(engine: Engine) -> None:

View File

@@ -0,0 +1,766 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext, get_db
from app.main import create_app
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.expense_case import BusinessEvent, ExpenseCase
from app.models.financial_record import ExpenseClaim
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.orchestrator import OrchestratorRequest
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
from app.services.expense_application_memory import (
MEMORY_CANDIDATE_TTL,
ExpenseApplicationMemoryService,
)
from app.services.expense_application_preview_workflow import (
ExpenseApplicationPreviewWorkflow,
)
from app.services.orchestrator_expense_application_workflow import (
OrchestratorExpenseApplicationWorkflow,
)
from app.test_helpers.db import build_in_memory_session_factory
def _user(
*,
tenant_id: str = "tenant-memory",
employee_id: str = "employee-memory-owner",
) -> CurrentUserContext:
return CurrentUserContext(
username=f"{employee_id}@example.com",
name="记忆测试员工",
role_codes=["user"],
is_admin=False,
tenant_id=tenant_id,
employee_id=employee_id,
employee_no="E-MEMORY-001",
department_name="交付部",
position="实施顾问",
grade="P4",
auth_session_id=f"session-{employee_id}",
)
def _seed_learning_evidence(
db: Session,
*,
current_user: CurrentUserContext,
index: int,
transport_mode: str = "火车",
evidence_at: datetime | None = None,
approved: bool = False,
expect_memory_entry: bool = True,
) -> tuple[
MemoryEntry | None,
ExpenseCase,
AIDecision,
AIDecisionFeedback,
WorkflowOutcome,
]:
tenant_id = current_user.tenant_id
suffix = f"{tenant_id}-{current_user.employee_id}-{index}-{uuid.uuid4().hex[:6]}"
occurred_at = evidence_at or datetime.now(UTC)
claim = ExpenseClaim(
id=str(uuid.uuid4()),
claim_no=f"CLM-{suffix}"[:50],
employee_name=current_user.name,
department_name=current_user.department_name,
expense_type="travel",
reason="客户现场实施",
location="上海",
amount=Decimal("1800.00"),
occurred_at=occurred_at,
submitted_at=occurred_at,
status="submitted",
approval_stage="审批中",
risk_flags_json=[],
)
expense_case = ExpenseCase(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
case_no=f"CASE-{suffix}"[:80],
scene_code="travel",
title="差旅申请",
owner_employee_id=current_user.employee_id,
current_stage="application",
status="active",
)
submitted_event = BusinessEvent(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
expense_case_id=expense_case.id,
aggregate_type="expense_claim",
aggregate_id=claim.id,
event_type="application_submitted",
event_version=1,
idempotency_key=f"submitted:{suffix}"[:120],
correlation_id=f"corr:{suffix}"[:64],
actor_id=current_user.employee_id,
actor_type="user",
payload_json={},
delivery_status="pending",
occurred_at=occurred_at,
)
db.add_all([claim, expense_case, submitted_event])
db.flush()
decision = AIDecision(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
expense_case_id=expense_case.id,
business_event_id=submitted_event.id,
expense_claim_id=claim.id,
correlation_id=f"corr:{suffix}"[:64],
subject_type="expense_claim",
subject_id=claim.id,
decision_type="expense_application_submit",
decision_source="server_preview",
status="edited",
automation_mode="human_confirmed",
confidence=Decimal("1.0000"),
suggestion_json={},
evidence_json={},
version_json={},
schema_version=1,
training_eligible=True,
idempotency_key=f"decision:{suffix}"[:120],
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
)
feedback = AIDecisionFeedback(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
decision_id=decision.id,
expense_claim_id=claim.id,
correlation_id=f"corr:{suffix}"[:64],
feedback_type="edited",
action_type="submit",
actor_id=current_user.employee_id,
actor_type="user",
evidence_source="server_preview_action",
verification_status="server_verified",
training_eligible=True,
final_value_json={"transport_mode": transport_mode},
changed_fields_json=[{"field_key": "transport_mode"}],
idempotency_key=f"feedback:{suffix}"[:120],
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
)
outcome = WorkflowOutcome(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
expense_case_id=expense_case.id,
decision_id=decision.id,
business_event_id=submitted_event.id,
expense_claim_id=claim.id,
correlation_id=f"corr:{suffix}"[:64],
outcome_type="application_submitted",
outcome_status="verified",
actor_id=current_user.employee_id,
actor_type="user",
result_json={},
idempotency_key=f"outcome:{suffix}"[:120],
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
effective_at=occurred_at,
)
db.add_all([decision, feedback, outcome])
if approved:
db.add(
BusinessEvent(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
expense_case_id=expense_case.id,
aggregate_type="expense_claim",
aggregate_id=claim.id,
event_type="application_approved",
event_version=1,
idempotency_key=f"approved:{suffix}"[:120],
correlation_id=f"approval:{suffix}"[:64],
actor_id="approver",
actor_type="user",
payload_json={},
delivery_status="pending",
occurred_at=occurred_at + timedelta(hours=1),
)
)
db.flush()
receipt = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
current_user=current_user,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
transport_mode=transport_mode,
)
if not expect_memory_entry:
assert receipt is None
return None, expense_case, decision, feedback, outcome
assert receipt is not None
evidence = db.get(MemoryEvidenceLink, receipt.evidence_id)
assert evidence is not None
evidence.created_at = occurred_at
db.flush()
entry = db.get(MemoryEntry, receipt.memory_id)
assert entry is not None
return entry, expense_case, decision, feedback, outcome
def _seed_active_memory(
db: Session,
*,
current_user: CurrentUserContext,
transport_mode: str = "火车",
) -> tuple[MemoryEntry, list[ExpenseCase]]:
now = datetime.now(UTC)
cases: list[ExpenseCase] = []
entry: MemoryEntry | None = None
for index, (days_ago, approved) in enumerate(((10, True), (5, True), (0, False)), 1):
entry, expense_case, *_ = _seed_learning_evidence(
db,
current_user=current_user,
index=index,
transport_mode=transport_mode,
evidence_at=now - timedelta(days=days_ago),
approved=approved,
)
cases.append(expense_case)
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts,
current_user,
)
assert entry is not None
assert applications and facts["transport_mode"] == transport_mode
db.flush()
db.refresh(entry)
assert entry.status == "active"
return entry, cases
def _add_returned_event(
db: Session,
*,
current_user: CurrentUserContext,
expense_case: ExpenseCase,
) -> None:
approved_at = db.scalar(
select(BusinessEvent.occurred_at).where(
BusinessEvent.expense_case_id == expense_case.id,
BusinessEvent.event_type == "application_approved",
)
)
assert approved_at is not None
db.add(
BusinessEvent(
id=str(uuid.uuid4()),
tenant_id=current_user.tenant_id,
expense_case_id=expense_case.id,
aggregate_type="expense_claim",
aggregate_id="returned-claim",
event_type="application_returned",
event_version=1,
idempotency_key=f"returned:{uuid.uuid4().hex}",
correlation_id=f"return:{uuid.uuid4().hex}"[:64],
actor_id="approver",
actor_type="user",
payload_json={},
delivery_status="pending",
# 同一时间戳也必须由退回事件保守覆盖批准,不能依赖随机 UUID 排序。
occurred_at=approved_at,
)
)
db.flush()
def test_memory_activates_after_three_cases_two_approvals_and_seven_days() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
entry, _ = _seed_active_memory(db, current_user=current_user)
assert entry.evidence_count == 3
assert entry.approved_evidence_count == 2
assert entry.active_expires_at is not None
assert entry.activated_at is not None
explicit_facts = {"transport_mode": "汽车"}
assert (
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
explicit_facts,
current_user,
)
== []
)
assert explicit_facts["transport_mode"] == "汽车"
def test_latest_returned_event_reverses_approval_and_suppresses_active_memory() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
entry, cases = _seed_active_memory(db, current_user=current_user)
_add_returned_event(db, current_user=current_user, expense_case=cases[1])
facts: dict[str, object] = {}
assert (
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts,
current_user,
)
== []
)
db.refresh(entry)
assert entry.approved_evidence_count == 1
assert entry.status == "suppressed"
assert entry.suppressed_at is not None
assert facts == {}
def test_invalidated_feedback_and_reversed_outcome_remove_active_evidence() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
entry, _ = _seed_active_memory(db, current_user=current_user)
evidence = list(
db.scalars(
select(MemoryEvidenceLink)
.where(MemoryEvidenceLink.memory_entry_id == entry.id)
.order_by(MemoryEvidenceLink.created_at.asc())
).all()
)
assert len(evidence) == 3
invalidated_feedback = db.get(AIDecisionFeedback, evidence[0].feedback_id)
reversed_outcome = db.get(WorkflowOutcome, evidence[1].outcome_id)
assert invalidated_feedback is not None and reversed_outcome is not None
invalidated_feedback.verification_status = "invalidated"
invalidated_feedback.training_eligible = False
reversed_outcome.outcome_status = "reversed"
db.flush()
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
{}, current_user
) == []
db.refresh(entry)
assert entry.status == "suppressed"
assert entry.evidence_count == 1
assert entry.approved_evidence_count == 0
def test_opposite_evidence_immediately_suppresses_old_active_memory() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
old_entry, _ = _seed_active_memory(db, current_user=current_user)
new_entry, *_ = _seed_learning_evidence(
db,
current_user=current_user,
index=4,
transport_mode="飞机",
)
db.refresh(old_entry)
assert old_entry.status == "suppressed"
assert old_entry.suppressed_at is not None
assert new_entry.status == "candidate"
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
{}, current_user
) == []
def test_non_whitelisted_correction_suppresses_old_active_without_storing_value() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
old_entry, _ = _seed_active_memory(db, current_user=current_user)
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
new_entry, *_ = _seed_learning_evidence(
db,
current_user=current_user,
index=4,
transport_mode="汽车",
expect_memory_entry=False,
)
assert new_entry is None
db.refresh(old_entry)
assert old_entry.status == "suppressed"
assert old_entry.suppressed_at is not None
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
{}, current_user
) == []
def test_expiry_sets_expired_at_and_replay_does_not_extend_ttl() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
entry, _, decision, feedback, outcome = _seed_learning_evidence(
db,
current_user=current_user,
index=1,
)
original_expiry = datetime.now(UTC) + timedelta(days=30)
entry.candidate_expires_at = original_expiry
db.flush()
claim = db.get(ExpenseClaim, decision.expense_claim_id)
assert claim is not None
replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
current_user=current_user,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
transport_mode="火车",
)
db.refresh(entry)
assert replay is not None
assert entry.evidence_count == 1
assert entry.candidate_expires_at == original_expiry.replace(tzinfo=None)
old_created_at = datetime.now(UTC) - MEMORY_CANDIDATE_TTL - timedelta(days=2)
entry.created_at = old_created_at
entry.candidate_expires_at = datetime.now(UTC) - timedelta(days=1)
db.flush()
memories = ExpenseApplicationMemoryService(db).list_current_user_memories(
current_user
)
db.refresh(entry)
assert memories.items[0].status == "expired"
assert entry.status == "expired"
assert entry.expired_at is not None
def test_expired_entry_gets_new_generation_for_new_evidence() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
old_entry, *_ = _seed_learning_evidence(
db,
current_user=current_user,
index=1,
)
old_entry.created_at = datetime.now(UTC) - MEMORY_CANDIDATE_TTL - timedelta(days=2)
old_entry.candidate_expires_at = datetime.now(UTC) - timedelta(days=1)
db.flush()
new_entry, *_ = _seed_learning_evidence(
db,
current_user=current_user,
index=2,
)
db.refresh(old_entry)
assert new_entry.id != old_entry.id
assert new_entry.generation == old_entry.generation + 1
assert new_entry.status == "candidate"
assert old_entry.status == "expired"
assert old_entry.expired_at is not None
def test_replay_after_revoke_or_suppression_never_recreates_memory() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
revoked_entry, _, decision, feedback, outcome = _seed_learning_evidence(
db,
current_user=current_user,
index=1,
)
claim = db.get(ExpenseClaim, decision.expense_claim_id)
assert claim is not None
assert ExpenseApplicationMemoryService(db).revoke_current_user_memory(
revoked_entry.id,
current_user,
) is not None
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
revoked_replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
current_user=current_user,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
transport_mode="火车",
)
assert revoked_replay is not None
assert revoked_replay.status == "revoked"
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
with build_in_memory_session_factory()() as db:
current_user = _user()
suppressed_entry, _ = _seed_active_memory(db, current_user=current_user)
evidence = db.scalar(
select(MemoryEvidenceLink)
.where(MemoryEvidenceLink.memory_entry_id == suppressed_entry.id)
.order_by(MemoryEvidenceLink.created_at.asc())
)
assert evidence is not None
decision = db.get(AIDecision, evidence.decision_id)
feedback = db.get(AIDecisionFeedback, evidence.feedback_id)
outcome = db.get(WorkflowOutcome, evidence.outcome_id)
assert decision is not None and feedback is not None and outcome is not None
claim = db.get(ExpenseClaim, decision.expense_claim_id)
assert claim is not None
_seed_learning_evidence(
db,
current_user=current_user,
index=4,
transport_mode="飞机",
)
db.refresh(suppressed_entry)
assert suppressed_entry.status == "suppressed"
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
suppressed_replay = ExpenseApplicationMemoryService(
db
).record_transport_edit_evidence(
current_user=current_user,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
transport_mode="火车",
)
assert suppressed_replay is not None
assert suppressed_replay.status == "suppressed"
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
def test_memory_isolated_by_tenant_and_owner_and_revoke_clears_value() -> None:
with build_in_memory_session_factory()() as db:
owner = _user()
entry, _ = _seed_active_memory(db, current_user=owner)
service = ExpenseApplicationMemoryService(db)
assert service.list_current_user_memories(_user(employee_id="employee-other")).items == []
assert service.list_current_user_memories(
_user(tenant_id="tenant-other")
).items == []
assert service.revoke_current_user_memory(
entry.id,
_user(employee_id="employee-other"),
) is None
revoked = service.revoke_current_user_memory(entry.id, owner)
assert revoked is not None
db.refresh(entry)
assert entry.status == "revoked"
assert entry.value_json == {}
assert entry.value_fingerprint == ""
assert service.apply_active_transport_memory({}, owner) == []
def test_memory_rejects_same_tenant_evidence_from_another_owner() -> None:
with build_in_memory_session_factory()() as db:
owner = _user()
_, _, decision, feedback, outcome = _seed_learning_evidence(
db,
current_user=owner,
index=1,
)
claim = db.get(ExpenseClaim, decision.expense_claim_id)
assert claim is not None
other_owner = _user(employee_id="employee-other")
with pytest.raises(PermissionError, match="当前登录人"):
ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
current_user=other_owner,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
transport_mode="火车",
)
assert (
ExpenseApplicationMemoryService(db)
.list_current_user_memories(other_owner)
.items
== []
)
@pytest.mark.parametrize(
("field_name", "invalid_value"),
[
("event_type", "claim_draft_updated"),
("aggregate_type", "expense_case"),
("aggregate_id", "another-claim"),
],
)
def test_memory_rejects_non_submit_or_wrong_claim_business_event(
field_name: str,
invalid_value: str,
) -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
_, _, decision, feedback, outcome = _seed_learning_evidence(
db,
current_user=current_user,
index=1,
)
business_event = db.get(BusinessEvent, decision.business_event_id)
claim = db.get(ExpenseClaim, decision.expense_claim_id)
assert business_event is not None
assert claim is not None
setattr(business_event, field_name, invalid_value)
db.flush()
with pytest.raises(ValueError, match="提交业务事件语义不一致"):
ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
current_user=current_user,
decision=decision,
feedback=feedback,
outcome=outcome,
claim=claim,
transport_mode="火车",
)
def test_memory_read_failure_degrades_without_mutating_facts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with build_in_memory_session_factory()() as db:
service = ExpenseApplicationMemoryService(db)
facts: dict[str, object] = {}
def fail_resolve(_current_user: CurrentUserContext) -> MemoryEntry | None:
raise RuntimeError("memory unavailable")
monkeypatch.setattr(service, "_resolve_active_entry", fail_resolve)
assert service.apply_active_transport_memory(facts, _user()) == []
assert facts == {}
def test_preview_and_orchestrator_apply_active_memory() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
_seed_active_memory(db, current_user=current_user)
preview = ExpenseApplicationPreviewWorkflow(db).issue(
ExpenseApplicationPreviewDecisionCreate(
message=(
"申请时间2026-07-20 至 2026-07-22\n"
"地点:上海\n事由:客户现场实施\n天数3天"
),
conversation_id="conversation-memory-preview",
request_id="request-memory-preview",
),
current_user,
)
assert preview.application_preview["fields"]["transportMode"] == "火车"
assert preview.application_preview["fields"]["amount"]
assert preview.application_preview["fields"]["transportEstimatedAmount"]
assert "交通" in preview.application_preview["fields"]["policyEstimate"]
assert preview.application_preview["memoryApplications"][0]["status"] == "applied"
outcome = OrchestratorExpenseApplicationWorkflow(db).execute(
payload=OrchestratorRequest(
source="user_message",
user_id=current_user.username,
message=(
"申请时间2026-08-01 至 2026-08-03\n"
"地点:北京\n事由:客户现场验收\n天数3天\n申请金额2000元"
),
),
current_user=current_user,
run_id="run-memory-orchestrator",
conversation_id="conversation-memory-orchestrator",
ontology=OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="test",
),
confidence=1.0,
run_id="run-memory-orchestrator",
),
context_json={},
selected_capability_codes=[],
)
assert outcome is not None
assert outcome.result["application_preview"]["fields"]["transportMode"] == "火车"
assert outcome.result["application_preview"]["memoryApplications"][0][
"status"
] == "applied"
assert "补充出行方式" not in str(outcome.result)
edited_fields = dict(preview.application_preview["fields"])
edited_fields["transportMode"] = "飞机"
action = ExpenseApplicationPreviewWorkflow(db).execute(
ExpenseApplicationPreviewActionPayload(
source="user_message",
user_id=current_user.username,
conversation_id="conversation-memory-preview",
action_type="submit",
decision_id=preview.decision_id,
request_id="request-memory-submit",
message=(
"申请时间2026-07-20 至 2026-07-22\n"
"地点:上海\n事由:客户现场实施\n天数3天\n"
"出行方式:飞机\n申请金额1800元\n确认提交"
),
context_json={
"application_preview": {
"modelReviewStatus": "server_registered",
"fields": edited_fields,
}
},
),
current_user,
)
assert action.result.learning_receipts
assert action.result.learning_receipts[0]["value"] == "飞机"
assert action.result.learning_receipts[0]["status"] == "candidate"
def test_memory_api_enforces_owner_and_allows_owner_revoke() -> None:
session_factory: sessionmaker[Session] = build_in_memory_session_factory()
with session_factory() as db:
owner = _user()
entry, _ = _seed_active_memory(db, current_user=owner)
memory_id = entry.id
db.commit()
app = create_app()
install_legacy_header_auth_override(app)
def override_db():
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
owner_headers = {
"X-Auth-Username": owner.username,
"X-Auth-Name": "Memory Owner",
"X-Auth-Employee-Id": owner.employee_id,
"X-Auth-Tenant-Id": owner.tenant_id,
"X-Auth-Role-Codes": "user",
}
other_headers = {**owner_headers, "X-Auth-Employee-Id": "employee-other"}
assert client.get(
"/api/v1/expense-application-memories/me",
headers=other_headers,
).json() == {"items": []}
assert client.delete(
f"/api/v1/expense-application-memories/{memory_id}",
headers=other_headers,
).status_code == 404
response = client.delete(
f"/api/v1/expense-application-memories/{memory_id}",
headers=owner_headers,
)
assert response.status_code == 200, response.text
with session_factory() as db:
revoked_entry = db.scalar(select(MemoryEntry).where(MemoryEntry.id == memory_id))
assert revoked_entry is not None
assert revoked_entry.status == "revoked"
assert revoked_entry.value_json == {}

View File

@@ -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["20260714_0004"]),
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"]),
)
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
engine: Engine,
@@ -96,6 +96,10 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]
- {"ai_application_preview_decisions"},
),
(
"20260714_0005",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] - {"memory_entries"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -23,6 +23,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"business_events",
"expense_case_links",
"expense_cases",
"memory_entries",
"memory_evidence_links",
"workflow_outcomes",
}
)