feat(ai): add personal expense application memory
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
300
server/src/app/models/ai_memory.py
Normal file
300
server/src/app/models/ai_memory.py
Normal 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(),
|
||||
)
|
||||
Reference in New Issue
Block a user