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

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