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