feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
@@ -26,6 +26,7 @@ class CurrentUserContext:
|
||||
is_admin: bool
|
||||
tenant_id: str = "default"
|
||||
department_name: str = ""
|
||||
department_id: str = ""
|
||||
cost_center: str = ""
|
||||
position: str = ""
|
||||
grade: str = ""
|
||||
@@ -74,6 +75,7 @@ def _authenticate_bearer_user(db: Session, authorization: str | None) -> Current
|
||||
is_admin=user.is_admin,
|
||||
tenant_id=user.tenant_id,
|
||||
department_name=user.department,
|
||||
department_id=user.department_id or "",
|
||||
cost_center=user.cost_center,
|
||||
position=user.position,
|
||||
grade=user.grade,
|
||||
|
||||
@@ -141,6 +141,7 @@ def regenerate_risk_rule(
|
||||
AgentAssetRiskRuleRegenerationService(db).regenerate(
|
||||
asset_id,
|
||||
payload,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor=_actor_name(current_user, x_actor),
|
||||
request_id=x_request_id,
|
||||
)
|
||||
|
||||
@@ -83,6 +83,7 @@ def _complete_risk_rule_generation_task(
|
||||
payload: dict,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
@@ -90,6 +91,7 @@ def _complete_risk_rule_generation_task(
|
||||
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
|
||||
asset_id,
|
||||
body,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
@@ -334,6 +336,7 @@ def generate_agent_asset_risk_rule(
|
||||
actor = (x_actor or current_user.name or "system").strip() or "system"
|
||||
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
|
||||
payload,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor=actor,
|
||||
request_id=x_request_id,
|
||||
)
|
||||
@@ -343,6 +346,7 @@ def generate_agent_asset_risk_rule(
|
||||
payload.model_dump(mode="json"),
|
||||
actor,
|
||||
x_request_id,
|
||||
current_user.tenant_id,
|
||||
)
|
||||
asset = AgentAssetService(db).get_asset(asset_id)
|
||||
if asset is None:
|
||||
@@ -941,9 +945,10 @@ def create_golden_case(
|
||||
_: RuleEditorUser,
|
||||
db: DbSession,
|
||||
) -> GoldenCaseRead:
|
||||
from app.models.golden_case import GoldenCase
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.golden_case import GoldenCase
|
||||
|
||||
existing = db.scalar(select(GoldenCase).where(GoldenCase.case_key == body.case_key))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="case_key 已存在")
|
||||
@@ -975,9 +980,10 @@ def list_golden_cases(
|
||||
_: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> list[GoldenCaseRead]:
|
||||
from app.models.golden_case import GoldenCase
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.golden_case import GoldenCase
|
||||
|
||||
cases = db.scalars(
|
||||
select(GoldenCase).where(GoldenCase.rule_code == rule_code).order_by(GoldenCase.created_at)
|
||||
).all()
|
||||
@@ -1013,7 +1019,6 @@ def run_golden_eval(
|
||||
rule_code = str(manifest.get("rule_code") or "").strip()
|
||||
if not rule_code:
|
||||
raise ValueError("manifest 缺少 rule_code。")
|
||||
version = body.version or asset.working_version or ""
|
||||
report = RiskRuleGoldenEvaluator().evaluate_for_rule(db, manifest, rule_code)
|
||||
return GoldenEvalRead(**report.to_dict())
|
||||
except Exception as exc:
|
||||
|
||||
@@ -5,16 +5,34 @@ 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.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_platform_admin_user,
|
||||
)
|
||||
from app.schemas.expense_application_memory import (
|
||||
ExpenseApplicationMemoryListRead,
|
||||
ExpenseApplicationMemoryRead,
|
||||
ExpenseApplicationMemoryRevokedRead,
|
||||
ExpenseApplicationOrganizationMemoryCreate,
|
||||
ExpenseApplicationOrganizationMemoryRevoke,
|
||||
ExpenseApplicationOrganizationMemoryUpdate,
|
||||
)
|
||||
from app.services.expense_application_memory import ExpenseApplicationMemoryService
|
||||
from app.services.expense_application_memory_admin import (
|
||||
ExpenseApplicationOrganizationMemoryService,
|
||||
OrganizationMemoryConflictError,
|
||||
OrganizationMemoryNotFoundError,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/expense-application-memories")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
PlatformAdminUser = Annotated[
|
||||
CurrentUserContext,
|
||||
Depends(require_platform_admin_user),
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -29,6 +47,105 @@ def list_my_expense_application_memories(
|
||||
return ExpenseApplicationMemoryService(db).list_current_user_memories(current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organization",
|
||||
response_model=ExpenseApplicationMemoryListRead,
|
||||
summary="读取当前租户的企业与部门费用记忆",
|
||||
)
|
||||
def list_organization_expense_application_memories(
|
||||
db: DbSession,
|
||||
current_user: PlatformAdminUser,
|
||||
) -> ExpenseApplicationMemoryListRead:
|
||||
return ExpenseApplicationOrganizationMemoryService(db).list_organization_memories(
|
||||
current_user
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organization",
|
||||
response_model=ExpenseApplicationMemoryRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建企业或部门费用记忆",
|
||||
)
|
||||
def create_organization_expense_application_memory(
|
||||
payload: ExpenseApplicationOrganizationMemoryCreate,
|
||||
db: DbSession,
|
||||
current_user: PlatformAdminUser,
|
||||
) -> ExpenseApplicationMemoryRead:
|
||||
try:
|
||||
return ExpenseApplicationOrganizationMemoryService(
|
||||
db
|
||||
).create_organization_memory(payload, current_user)
|
||||
except OrganizationMemoryConflictError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
@router.put(
|
||||
"/organization/{memory_id}",
|
||||
response_model=ExpenseApplicationMemoryRead,
|
||||
summary="换代更新企业或部门费用记忆",
|
||||
)
|
||||
def update_organization_expense_application_memory(
|
||||
memory_id: str,
|
||||
payload: ExpenseApplicationOrganizationMemoryUpdate,
|
||||
db: DbSession,
|
||||
current_user: PlatformAdminUser,
|
||||
) -> ExpenseApplicationMemoryRead:
|
||||
try:
|
||||
return ExpenseApplicationOrganizationMemoryService(
|
||||
db
|
||||
).update_organization_memory(memory_id, payload, current_user)
|
||||
except OrganizationMemoryNotFoundError as error:
|
||||
raise _organization_memory_not_found() from error
|
||||
except OrganizationMemoryConflictError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organization/{memory_id}/revoke",
|
||||
response_model=ExpenseApplicationMemoryRevokedRead,
|
||||
summary="撤销企业或部门费用记忆",
|
||||
)
|
||||
def revoke_organization_expense_application_memory(
|
||||
memory_id: str,
|
||||
payload: ExpenseApplicationOrganizationMemoryRevoke,
|
||||
db: DbSession,
|
||||
current_user: PlatformAdminUser,
|
||||
) -> ExpenseApplicationMemoryRevokedRead:
|
||||
try:
|
||||
return ExpenseApplicationOrganizationMemoryService(
|
||||
db
|
||||
).revoke_organization_memory(memory_id, payload, current_user)
|
||||
except OrganizationMemoryNotFoundError as error:
|
||||
raise _organization_memory_not_found() from error
|
||||
except OrganizationMemoryConflictError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{memory_id}",
|
||||
response_model=ExpenseApplicationMemoryRevokedRead,
|
||||
@@ -49,3 +166,10 @@ def revoke_my_expense_application_memory(
|
||||
detail="未找到可撤销的个人费用申请记忆。",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _organization_memory_not_found() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="未找到当前租户内可管理的组织费用记忆。",
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.risk_observation import (
|
||||
RiskObservationDashboardRead,
|
||||
@@ -16,8 +16,9 @@ from app.schemas.risk_observation import (
|
||||
)
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
router = APIRouter(prefix="/risk-observations", dependencies=[Depends(get_current_user)])
|
||||
router = APIRouter(prefix="/risk-observations")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -28,6 +29,7 @@ DbSession = Annotated[Session, Depends(get_db)]
|
||||
)
|
||||
def list_risk_observations(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
claim_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
run_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
execution_log_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
@@ -42,6 +44,7 @@ def list_risk_observations(
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> RiskObservationListRead:
|
||||
items, total = RiskObservationService(db).list_observations(
|
||||
tenant_id=current_user.tenant_id,
|
||||
claim_id=claim_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=execution_log_id,
|
||||
@@ -63,10 +66,12 @@ def list_risk_observations(
|
||||
)
|
||||
def summarize_risk_observations(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||
limit: Annotated[int, Query(ge=1, le=2000)] = 500,
|
||||
) -> RiskObservationDashboardRead:
|
||||
return RiskObservationService(db).summarize_dashboard(
|
||||
tenant_id=current_user.tenant_id,
|
||||
window_days=window_days,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -78,8 +83,15 @@ def summarize_risk_observations(
|
||||
summary="查询单据风险观察",
|
||||
description="按报销单 ID 返回该单据关联的风险观察,供单据详情证据链使用。",
|
||||
)
|
||||
def list_claim_risk_observations(claim_id: str, db: DbSession) -> list[RiskObservationRead]:
|
||||
return RiskObservationService(db).list_claim_observations(claim_id)
|
||||
def list_claim_risk_observations(
|
||||
claim_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> list[RiskObservationRead]:
|
||||
return RiskObservationService(db).list_claim_observations(
|
||||
claim_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -91,8 +103,12 @@ def list_claim_risk_observations(claim_id: str, db: DbSession) -> list[RiskObser
|
||||
def list_execution_log_risk_observations(
|
||||
execution_log_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> list[RiskObservationRead]:
|
||||
return RiskObservationService(db).list_execution_log_observations(execution_log_id)
|
||||
return RiskObservationService(db).list_execution_log_observations(
|
||||
execution_log_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -110,8 +126,12 @@ def list_execution_log_risk_observations(
|
||||
def get_risk_observation(
|
||||
observation_key_or_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> RiskObservationRead:
|
||||
observation = RiskObservationService(db).get_observation(observation_key_or_id)
|
||||
observation = RiskObservationService(db).get_observation(
|
||||
observation_key_or_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if observation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -136,9 +156,15 @@ def create_risk_observation_feedback(
|
||||
observation_key_or_id: str,
|
||||
payload: RiskObservationFeedbackCreate,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> RiskObservationFeedbackRead:
|
||||
try:
|
||||
return RiskObservationService(db).create_feedback(observation_key_or_id, payload)
|
||||
return RiskObservationService(db).create_feedback(
|
||||
observation_key_or_id,
|
||||
payload,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor=current_user.name or current_user.username,
|
||||
)
|
||||
except LookupError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -79,10 +79,67 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
"20260716_0007": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
"20260716_0008": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
"20260716_0009": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
}
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
||||
# 它们作为完整或部分旧资产存在,由 0008 统一收编;其他未来表仍严格拒绝。
|
||||
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES = frozenset(
|
||||
{"risk_observations", "risk_observation_feedback", "few_shot_samples"}
|
||||
)
|
||||
|
||||
|
||||
class MigrationPreflightError(RuntimeError):
|
||||
"""Raised when the database schema cannot be safely advanced by Alembic."""
|
||||
@@ -103,10 +160,11 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
owned_tables = table_names & MIGRATION_OWNED_TABLES
|
||||
|
||||
if "alembic_version" not in table_names:
|
||||
if owned_tables:
|
||||
unsafe_owned_tables = owned_tables - LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
|
||||
if unsafe_owned_tables:
|
||||
raise MigrationPreflightError(
|
||||
"unversioned database contains migration-owned tables "
|
||||
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
|
||||
f"({_format_tables(unsafe_owned_tables)}); refusing to guess, stamp, or repair"
|
||||
)
|
||||
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
|
||||
|
||||
@@ -117,10 +175,11 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
).scalars()
|
||||
)
|
||||
if not revisions:
|
||||
if owned_tables:
|
||||
unsafe_owned_tables = owned_tables - LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
|
||||
if unsafe_owned_tables:
|
||||
raise MigrationPreflightError(
|
||||
"alembic_version has no recorded revision but migration-owned tables exist "
|
||||
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
|
||||
f"({_format_tables(unsafe_owned_tables)}); refusing to guess, stamp, or repair"
|
||||
)
|
||||
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
|
||||
|
||||
@@ -137,9 +196,14 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
f"unknown Alembic revision {revision!r}; refusing to run migrations"
|
||||
)
|
||||
|
||||
if owned_tables != expected_tables:
|
||||
missing_tables = expected_tables - owned_tables
|
||||
unexpected_tables = owned_tables - expected_tables
|
||||
adoptable_tables = (
|
||||
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
|
||||
if revision not in {"20260716_0008", "20260716_0009"}
|
||||
else frozenset()
|
||||
)
|
||||
missing_tables = expected_tables - owned_tables
|
||||
unexpected_tables = owned_tables - expected_tables - adoptable_tables
|
||||
if missing_tables or unexpected_tables:
|
||||
raise MigrationPreflightError(
|
||||
f"migration-owned table set does not match revision {revision}: "
|
||||
f"missing={_format_tables(missing_tables)}; "
|
||||
|
||||
@@ -16,6 +16,9 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||
"business_events",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"risk_observations",
|
||||
"risk_observation_feedback",
|
||||
"few_shot_samples",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -34,7 +35,7 @@ def _candidate_expires_at() -> datetime:
|
||||
|
||||
|
||||
class MemoryEntry(Base):
|
||||
"""受证据约束、可撤销的个人费用申请记忆。"""
|
||||
"""受证据约束、可审计且可撤销的分层费用申请记忆。"""
|
||||
|
||||
__tablename__ = "memory_entries"
|
||||
__table_args__ = (
|
||||
@@ -59,9 +60,33 @@ class MemoryEntry(Base):
|
||||
name="fk_memory_entries_tenant_superseded_by",
|
||||
),
|
||||
CheckConstraint(
|
||||
"scope_type = 'user'",
|
||||
"scope_type IN ('user', 'department', 'enterprise')",
|
||||
name="ck_memory_entries_scope_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"origin_type IN ('learned', 'admin_managed')",
|
||||
name="ck_memory_entries_origin_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(scope_type = 'user' AND origin_type = 'learned') OR "
|
||||
"(scope_type IN ('department', 'enterprise') "
|
||||
"AND origin_type = 'admin_managed')",
|
||||
name="ck_memory_entries_scope_origin",
|
||||
),
|
||||
CheckConstraint(
|
||||
"scope_type != 'enterprise' OR scope_id = tenant_id",
|
||||
name="ck_memory_entries_enterprise_scope",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(origin_type = 'learned' AND managed_by IS NULL "
|
||||
"AND managed_at IS NULL AND management_reason IS NULL) OR "
|
||||
"(origin_type = 'admin_managed' AND managed_by IS NOT NULL "
|
||||
"AND length(trim(managed_by)) > 0 AND managed_at IS NOT NULL "
|
||||
"AND management_reason IS NOT NULL "
|
||||
"AND length(trim(management_reason)) > 0 "
|
||||
"AND policy_version IS NOT NULL AND length(trim(policy_version)) > 0)",
|
||||
name="ck_memory_entries_management_audit",
|
||||
),
|
||||
CheckConstraint(
|
||||
"scene = 'travel_application'",
|
||||
name="ck_memory_entries_scene",
|
||||
@@ -120,6 +145,17 @@ class MemoryEntry(Base):
|
||||
"superseded_by_id IS NULL OR superseded_by_id != id",
|
||||
name="ck_memory_entries_not_self_superseded",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(management_request_id IS NULL AND management_payload_fingerprint IS NULL) OR "
|
||||
"(management_request_id IS NOT NULL "
|
||||
"AND management_payload_fingerprint IS NOT NULL)",
|
||||
name="ck_memory_entries_management_idempotency_pair",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(revoke_request_id IS NULL AND revoke_payload_fingerprint IS NULL) OR "
|
||||
"(revoke_request_id IS NOT NULL AND revoke_payload_fingerprint IS NOT NULL)",
|
||||
name="ck_memory_entries_revoke_idempotency_pair",
|
||||
),
|
||||
Index(
|
||||
"ix_memory_entries_scope_lookup",
|
||||
"tenant_id",
|
||||
@@ -136,6 +172,31 @@ class MemoryEntry(Base):
|
||||
"candidate_expires_at",
|
||||
"active_expires_at",
|
||||
),
|
||||
Index(
|
||||
"uq_memory_entries_management_request",
|
||||
"tenant_id",
|
||||
"management_request_id",
|
||||
unique=True,
|
||||
),
|
||||
Index(
|
||||
"uq_memory_entries_revoke_request",
|
||||
"tenant_id",
|
||||
"revoke_request_id",
|
||||
unique=True,
|
||||
),
|
||||
Index(
|
||||
"uq_memory_entries_active_scope",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scene",
|
||||
"field_key",
|
||||
unique=True,
|
||||
postgresql_where=text(
|
||||
"status = 'active' "
|
||||
"AND scope_type IN ('department', 'enterprise')"
|
||||
),
|
||||
).ddl_if(dialect="postgresql"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
@@ -147,6 +208,16 @@ class MemoryEntry(Base):
|
||||
server_default="user",
|
||||
)
|
||||
scope_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
origin_type: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
nullable=False,
|
||||
default="learned",
|
||||
server_default="learned",
|
||||
)
|
||||
managed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
managed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
management_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
policy_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
scene: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
@@ -212,6 +283,16 @@ class MemoryEntry(Base):
|
||||
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)
|
||||
management_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
management_payload_fingerprint: Mapped[str | None] = mapped_column(
|
||||
String(80),
|
||||
nullable=True,
|
||||
)
|
||||
revoke_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
revoke_payload_fingerprint: Mapped[str | None] = mapped_column(
|
||||
String(80),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
@@ -20,12 +20,26 @@ class FewShotSample(Base):
|
||||
|
||||
__tablename__ = "few_shot_samples"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"sample_key",
|
||||
name="uq_few_shot_samples_tenant_key",
|
||||
),
|
||||
Index(
|
||||
"ix_few_shot_samples_tenant_rule_lookup",
|
||||
"tenant_id",
|
||||
"scene",
|
||||
"policy_ref",
|
||||
"rule_version",
|
||||
"status",
|
||||
),
|
||||
Index("ix_few_shot_samples_scene_label", "scene", "label"),
|
||||
Index("ix_few_shot_samples_domain_risk_type", "domain", "risk_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
sample_key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
sample_key: Mapped[str] = mapped_column(String(160), index=True)
|
||||
source_observation_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("risk_observations.id"),
|
||||
nullable=True,
|
||||
@@ -33,6 +47,8 @@ class FewShotSample(Base):
|
||||
)
|
||||
|
||||
scene: Mapped[str] = mapped_column(String(50), default="risk_rule_generation", index=True)
|
||||
policy_ref: Mapped[str] = mapped_column(String(160), default="", index=True)
|
||||
rule_version: Mapped[str] = mapped_column(String(80), default="", index=True)
|
||||
domain: Mapped[str] = mapped_column(String(50), default="", index=True)
|
||||
risk_type: Mapped[str] = mapped_column(String(80), default="", index=True)
|
||||
risk_level: Mapped[str] = mapped_column(String(20), default="")
|
||||
@@ -45,7 +61,9 @@ class FewShotSample(Base):
|
||||
vector_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=func.now(),
|
||||
|
||||
@@ -4,7 +4,17 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
@@ -14,18 +24,25 @@ from app.db.base_class import Base
|
||||
class RiskObservation(Base):
|
||||
__tablename__ = "risk_observations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"observation_key",
|
||||
name="uq_risk_observations_tenant_key",
|
||||
),
|
||||
Index("ix_risk_observations_tenant_status", "tenant_id", "status", "created_at"),
|
||||
Index("ix_risk_observations_subject", "subject_type", "subject_key"),
|
||||
Index("ix_risk_observations_signal_level", "risk_signal", "risk_level"),
|
||||
Index("ix_risk_observations_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
observation_key: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
observation_key: Mapped[str] = mapped_column(String(160), index=True)
|
||||
subject_type: Mapped[str] = mapped_column(String(50), index=True)
|
||||
subject_key: Mapped[str] = mapped_column(String(160), index=True)
|
||||
subject_label: Mapped[str] = mapped_column(String(160), default="")
|
||||
claim_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("expense_claims.id"),
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
@@ -66,7 +83,12 @@ class RiskObservation(Base):
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
claim = relationship("ExpenseClaim", foreign_keys=[claim_id])
|
||||
claim = relationship(
|
||||
"ExpenseClaim",
|
||||
primaryjoin="foreign(RiskObservation.claim_id) == ExpenseClaim.id",
|
||||
foreign_keys=[claim_id],
|
||||
viewonly=True,
|
||||
)
|
||||
feedback_items = relationship(
|
||||
"RiskObservationFeedback",
|
||||
back_populates="observation",
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryConflict(BaseModel):
|
||||
scope_type: Literal["user", "department", "enterprise"]
|
||||
scope_label: str
|
||||
priority: int
|
||||
reason: Literal["same_priority_conflict", "lower_priority_overridden"]
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryApplication(BaseModel):
|
||||
memory_id: str
|
||||
field_key: str = "transport_mode"
|
||||
@@ -15,7 +23,14 @@ class ExpenseApplicationMemoryApplication(BaseModel):
|
||||
evidence_count: int = 0
|
||||
approved_evidence_count: int = 0
|
||||
confidence: float = 0.0
|
||||
effective_confidence: float = 0.0
|
||||
expires_at: datetime | None = None
|
||||
scope_type: Literal["user", "department", "enterprise"] = "user"
|
||||
scope_id: str = ""
|
||||
scope_label: str = "个人偏好"
|
||||
priority: int = 100
|
||||
conflicts: list[ExpenseApplicationMemoryConflict] = Field(default_factory=list)
|
||||
can_revoke: bool = True
|
||||
message: str = "已按可信历史记忆预填常用出行方式,可继续修改。"
|
||||
|
||||
|
||||
@@ -38,6 +53,12 @@ class ExpenseApplicationMemoryRead(BaseModel):
|
||||
field_key: str
|
||||
value: str = ""
|
||||
status: str
|
||||
scope_type: Literal["user", "department", "enterprise"] = "user"
|
||||
scope_id: str = ""
|
||||
scope_label: str = "个人偏好"
|
||||
source: str = "verified_user_history"
|
||||
origin_type: Literal["learned", "admin_managed"] = "learned"
|
||||
generation: int = 1
|
||||
evidence_count: int = 0
|
||||
approved_evidence_count: int = 0
|
||||
confidence: float = 0.0
|
||||
@@ -50,6 +71,11 @@ class ExpenseApplicationMemoryRead(BaseModel):
|
||||
suppressed_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
revoked_reason: str = ""
|
||||
managed_by: str = ""
|
||||
managed_at: datetime | None = None
|
||||
management_reason: str = ""
|
||||
superseded_by_id: str = ""
|
||||
can_revoke: bool = True
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -62,3 +88,26 @@ class ExpenseApplicationMemoryRevokedRead(BaseModel):
|
||||
memory_id: str
|
||||
status: str = "revoked"
|
||||
revoked_at: datetime
|
||||
|
||||
|
||||
class ExpenseApplicationOrganizationMemoryCreate(BaseModel):
|
||||
scope_type: Literal["department", "enterprise"]
|
||||
scope_id: str | None = Field(default=None, max_length=120)
|
||||
value: Literal["飞机", "火车", "轮船"]
|
||||
expires_in_days: int = Field(default=180, ge=30, le=365)
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
request_id: str = Field(min_length=8, max_length=120)
|
||||
|
||||
|
||||
class ExpenseApplicationOrganizationMemoryUpdate(BaseModel):
|
||||
value: Literal["飞机", "火车", "轮船"] | None = None
|
||||
expires_in_days: int | None = Field(default=None, ge=30, le=365)
|
||||
expected_generation: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
request_id: str = Field(min_length=8, max_length=120)
|
||||
|
||||
|
||||
class ExpenseApplicationOrganizationMemoryRevoke(BaseModel):
|
||||
expected_generation: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
request_id: str = Field(min_length=8, max_length=120)
|
||||
|
||||
@@ -161,6 +161,19 @@ class ExpenseClaimPreReviewFindingRead(BaseModel):
|
||||
remediation: ExpenseClaimPreReviewRemediationRead
|
||||
|
||||
|
||||
class ExpenseClaimHistoricalCaseEvidenceRead(BaseModel):
|
||||
label: Literal["confirmed", "false_positive"]
|
||||
label_text: str
|
||||
advisory_only: Literal[True] = True
|
||||
score: float = 0.0
|
||||
scene_code: str = ""
|
||||
policy_ref: str = ""
|
||||
rule_version: str = ""
|
||||
version_status: Literal["matched", "stale"] = "matched"
|
||||
stale: bool = False
|
||||
summary: str
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewRead(BaseModel):
|
||||
review_id: str
|
||||
input_fingerprint: str
|
||||
@@ -173,6 +186,9 @@ class ExpenseClaimPreReviewRead(BaseModel):
|
||||
blocking_count: int = 0
|
||||
message: str
|
||||
findings: list[ExpenseClaimPreReviewFindingRead] = Field(default_factory=list)
|
||||
historical_case_evidence: list[ExpenseClaimHistoricalCaseEvidenceRead] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimSubmitPayload(BaseModel):
|
||||
|
||||
@@ -44,6 +44,7 @@ class RiskObservationRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
observation_key: str
|
||||
subject_type: str
|
||||
subject_key: str
|
||||
@@ -100,7 +101,11 @@ class RiskObservationListRead(BaseModel):
|
||||
class RiskObservationFeedbackCreate(BaseModel):
|
||||
feedback_type: RiskObservationFeedbackType
|
||||
action: str | None = Field(default=None, max_length=50)
|
||||
actor: str | None = Field(default=None, max_length=100)
|
||||
actor: str | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="兼容字段;服务端始终以当前认证用户覆盖该值。",
|
||||
)
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
payload_json: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.schemas.agent_asset import (
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_generation import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
EXPENSE_BUSINESS_STAGE_LABELS,
|
||||
@@ -22,7 +23,6 @@ from app.services.risk_rule_generation import (
|
||||
RiskRuleGenerationService,
|
||||
)
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
@@ -52,6 +52,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
asset_id: str,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAsset:
|
||||
@@ -60,12 +61,14 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
return self._regenerate_revision_draft(
|
||||
asset,
|
||||
body,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
return self._regenerate_unpublished_draft(
|
||||
asset,
|
||||
body,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
@@ -75,6 +78,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
@@ -84,7 +88,12 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
before = self._snapshot(asset)
|
||||
config = dict(asset.config_json or {})
|
||||
request = self._build_generation_request(asset, config, body.model_dump(exclude_unset=True))
|
||||
payload, risk_score = self._compile_payload(request, actor=actor, created_at=asset.created_at)
|
||||
payload, risk_score = self._compile_payload(
|
||||
request,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
created_at=asset.created_at,
|
||||
)
|
||||
rule_code = self._stable_rule_code(asset, payload)
|
||||
payload["rule_code"] = rule_code
|
||||
file_name = f"{rule_code}.json"
|
||||
@@ -104,6 +113,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
actor=actor,
|
||||
)
|
||||
config.update(self._config_from_payload(payload, risk_score=risk_score, request=request))
|
||||
config["tenant_id"] = str(tenant_id or "").strip()
|
||||
config.update(
|
||||
{
|
||||
"generation_status": "completed",
|
||||
@@ -138,6 +148,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
@@ -151,7 +162,12 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
body.model_dump(exclude_unset=True),
|
||||
base=revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {},
|
||||
)
|
||||
payload, risk_score = self._compile_payload(request, actor=actor, created_at=datetime.now(UTC))
|
||||
payload, risk_score = self._compile_payload(
|
||||
request,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
payload["rule_code"] = str(asset.code or payload["rule_code"]).strip()
|
||||
payload["enabled"] = False
|
||||
payload.setdefault("metadata", {})["revision_version"] = revision_version
|
||||
@@ -184,6 +200,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
}
|
||||
)
|
||||
config["revision_draft"] = revision
|
||||
config["tenant_id"] = str(tenant_id or "").strip()
|
||||
config["last_operation"] = {
|
||||
"action": "regenerate_revision",
|
||||
"actor": actor,
|
||||
@@ -216,6 +233,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
self,
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
actor: str,
|
||||
created_at: datetime | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
@@ -230,6 +248,7 @@ class AgentAssetRiskRuleRegenerationService:
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
fields = self.generator._resolve_fields(natural_language, domain=domain)
|
||||
draft = self.generator._compile_with_model(
|
||||
tenant_id=tenant_id,
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
|
||||
@@ -53,6 +53,7 @@ class AuthenticatedUser:
|
||||
avatar: str
|
||||
is_admin: bool = False
|
||||
employee_id: str | None = None
|
||||
department_id: str | None = None
|
||||
tenant_id: str = "default"
|
||||
|
||||
|
||||
@@ -116,7 +117,7 @@ class AuthService:
|
||||
}
|
||||
if auth_session.username.strip().casefold() not in allowed_identifiers:
|
||||
return None
|
||||
return self._build_admin_user(record)
|
||||
return self._restore_session_scope(self._build_admin_user(record), auth_session)
|
||||
|
||||
if auth_session.principal_type != "employee":
|
||||
return None
|
||||
@@ -133,7 +134,17 @@ class AuthService:
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
if employee is None or employee.employment_status == "停用":
|
||||
return None
|
||||
return self._build_employee_user(employee)
|
||||
return self._restore_session_scope(self._build_employee_user(employee), auth_session)
|
||||
|
||||
@staticmethod
|
||||
def _restore_session_scope(
|
||||
user: AuthenticatedUser,
|
||||
auth_session: AuthSession,
|
||||
) -> AuthenticatedUser:
|
||||
"""会话恢复时以签发并认证过的会话租户为准,禁止回落到默认租户。"""
|
||||
|
||||
user.tenant_id = str(auth_session.tenant_id or "default").strip() or "default"
|
||||
return user
|
||||
|
||||
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
|
||||
normalized = identifier.strip()
|
||||
@@ -249,6 +260,7 @@ class AuthService:
|
||||
avatar=(employee.name or "?")[:1].upper(),
|
||||
is_admin=False,
|
||||
employee_id=employee.id,
|
||||
department_id=employee.organization_unit_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -24,6 +24,12 @@ from app.schemas.expense_application_memory import (
|
||||
from app.services.expense_application_memory_evidence import (
|
||||
ExpenseApplicationMemoryEvidenceValidator,
|
||||
)
|
||||
from app.services.expense_application_memory_resolution import (
|
||||
ExpenseApplicationMemoryResolution,
|
||||
ExpenseApplicationMemoryResolver,
|
||||
memory_scope_label,
|
||||
memory_source,
|
||||
)
|
||||
from app.services.expense_application_snapshot import hmac_fingerprint
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
@@ -191,16 +197,16 @@ class ExpenseApplicationMemoryService:
|
||||
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
entry = self._resolve_active_entry(current_user)
|
||||
if entry is None:
|
||||
resolution = self._resolve_active_entry(current_user)
|
||||
if resolution is None:
|
||||
return []
|
||||
value = self._entry_value(entry)
|
||||
if not value:
|
||||
return []
|
||||
facts[MEMORY_FIELD_KEY] = value
|
||||
return [self._build_application(entry, value)]
|
||||
application = resolution.to_application(current_user)
|
||||
if resolution.winner is None:
|
||||
return [application]
|
||||
facts[MEMORY_FIELD_KEY] = resolution.value
|
||||
return [application]
|
||||
except Exception:
|
||||
logger.warning("个人出行方式记忆读取失败,本轮预览不应用记忆。", exc_info=True)
|
||||
logger.warning("出行方式分层记忆读取失败,本轮预览不应用记忆。", exc_info=True)
|
||||
return []
|
||||
|
||||
def learning_receipts_for_preview_decision(
|
||||
@@ -255,7 +261,7 @@ class ExpenseApplicationMemoryService:
|
||||
self._refresh_entry_metrics(entry, now=now, allow_activation=False)
|
||||
self.db.commit()
|
||||
return ExpenseApplicationMemoryListRead(
|
||||
items=[self._serialize_entry(entry) for entry in entries]
|
||||
items=[self._serialize_entry(entry, current_user) for entry in entries]
|
||||
)
|
||||
|
||||
def revoke_current_user_memory(
|
||||
@@ -293,38 +299,12 @@ class ExpenseApplicationMemoryService:
|
||||
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()
|
||||
) -> ExpenseApplicationMemoryResolution | None:
|
||||
return ExpenseApplicationMemoryResolver(self.db).resolve(
|
||||
current_user,
|
||||
user_scope_id=self._scope_id(current_user),
|
||||
refresh_user_entry=self._refresh_entry_metrics,
|
||||
)
|
||||
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,
|
||||
@@ -522,6 +502,7 @@ class ExpenseApplicationMemoryService:
|
||||
scope_id=scope_id,
|
||||
scene=MEMORY_SCENE,
|
||||
field_key=MEMORY_FIELD_KEY,
|
||||
origin_type="learned",
|
||||
generation=generation,
|
||||
value_json={"value": value},
|
||||
value_fingerprint=value_fingerprint,
|
||||
@@ -531,6 +512,7 @@ class ExpenseApplicationMemoryService:
|
||||
confidence=Decimal("0"),
|
||||
candidate_expires_at=now + MEMORY_CANDIDATE_TTL,
|
||||
last_evidence_at=now,
|
||||
policy_version=MEMORY_POLICY_VERSION,
|
||||
)
|
||||
|
||||
def _approved_case_ids(
|
||||
@@ -623,20 +605,6 @@ class ExpenseApplicationMemoryService:
|
||||
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,
|
||||
@@ -666,7 +634,11 @@ class ExpenseApplicationMemoryService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_entry(entry: MemoryEntry) -> ExpenseApplicationMemoryRead:
|
||||
def _serialize_entry(
|
||||
entry: MemoryEntry,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryRead:
|
||||
scope_type = str(entry.scope_type or "user")
|
||||
value = (
|
||||
""
|
||||
if entry.status == "revoked"
|
||||
@@ -678,11 +650,20 @@ class ExpenseApplicationMemoryService:
|
||||
field_key=entry.field_key,
|
||||
value=value,
|
||||
status=entry.status,
|
||||
scope_type=scope_type,
|
||||
scope_id=str(entry.scope_id or ""),
|
||||
scope_label=memory_scope_label(entry, current_user),
|
||||
source=memory_source(scope_type),
|
||||
origin_type=str(getattr(entry, "origin_type", "learned") or "learned"),
|
||||
generation=int(entry.generation or 1),
|
||||
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,
|
||||
policy_version=str(
|
||||
getattr(entry, "policy_version", MEMORY_POLICY_VERSION)
|
||||
or MEMORY_POLICY_VERSION
|
||||
),
|
||||
valid_from=entry.activated_at or entry.created_at,
|
||||
expires_at=(
|
||||
entry.active_expires_at
|
||||
@@ -694,6 +675,11 @@ class ExpenseApplicationMemoryService:
|
||||
suppressed_at=entry.suppressed_at,
|
||||
revoked_at=entry.revoked_at,
|
||||
revoked_reason=str(entry.revoked_reason or ""),
|
||||
managed_by=str(getattr(entry, "managed_by", "") or ""),
|
||||
managed_at=getattr(entry, "managed_at", None),
|
||||
management_reason=str(getattr(entry, "management_reason", "") or ""),
|
||||
superseded_by_id=str(entry.superseded_by_id or ""),
|
||||
can_revoke=scope_type == "user",
|
||||
created_at=entry.created_at,
|
||||
updated_at=entry.updated_at,
|
||||
)
|
||||
|
||||
761
server/src/app/services/expense_application_memory_admin.py
Normal file
761
server/src/app/services/expense_application_memory_admin.py
Normal file
@@ -0,0 +1,761 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_memory import MemoryEntry
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.schemas.expense_application_memory import (
|
||||
ExpenseApplicationMemoryListRead,
|
||||
ExpenseApplicationMemoryRead,
|
||||
ExpenseApplicationMemoryRevokedRead,
|
||||
ExpenseApplicationOrganizationMemoryCreate,
|
||||
ExpenseApplicationOrganizationMemoryRevoke,
|
||||
ExpenseApplicationOrganizationMemoryUpdate,
|
||||
)
|
||||
from app.services.expense_application_memory_resolution import memory_source
|
||||
from app.services.expense_application_snapshot import hmac_fingerprint
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.organization_memory_locks import (
|
||||
organization_memory_operation_locks,
|
||||
organization_memory_request_lock_key,
|
||||
organization_memory_scope_lock_key,
|
||||
)
|
||||
|
||||
MEMORY_SCENE = "travel_application"
|
||||
MEMORY_FIELD_KEY = "transport_mode"
|
||||
ORGANIZATION_SCOPE_TYPES = {"department", "enterprise"}
|
||||
ORGANIZATION_MEMORY_POLICY_VERSION = "expense_application_transport_org_memory.v1"
|
||||
SUPPORTED_TRANSPORT_VALUES = {"飞机", "火车", "轮船"}
|
||||
|
||||
|
||||
class OrganizationMemoryNotFoundError(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
class OrganizationMemoryConflictError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ExpenseApplicationOrganizationMemoryService:
|
||||
"""由平台管理员显式维护企业/部门出行方式记忆。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def list_organization_memories(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryListRead:
|
||||
self._require_admin(current_user)
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
now = datetime.now(UTC)
|
||||
entries = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type.in_(ORGANIZATION_SCOPE_TYPES),
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
)
|
||||
.order_by(
|
||||
MemoryEntry.scope_type.asc(),
|
||||
MemoryEntry.scope_id.asc(),
|
||||
MemoryEntry.generation.desc(),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for entry in entries:
|
||||
if entry.status == "active" and self._is_expired(entry, now):
|
||||
entry.status = "expired"
|
||||
entry.expired_at = now
|
||||
department_names = self._department_names(entries)
|
||||
self.db.commit()
|
||||
return ExpenseApplicationMemoryListRead(
|
||||
items=[self._serialize_entry(entry, department_names) for entry in entries]
|
||||
)
|
||||
def create_organization_memory(
|
||||
self,
|
||||
payload: ExpenseApplicationOrganizationMemoryCreate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryRead:
|
||||
self._require_admin(current_user)
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
scope_id = self._validate_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
request_id = self._normalize_request_id(payload.request_id)
|
||||
reason = self._normalize_reason(payload.reason)
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
operation="create",
|
||||
target=f"{payload.scope_type}:{scope_id}",
|
||||
payload={
|
||||
"scope_type": payload.scope_type,
|
||||
"scope_id": scope_id,
|
||||
"value": payload.value,
|
||||
"expires_in_days": payload.expires_in_days,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
entry_id = self._request_entry_id(
|
||||
tenant_id=tenant_id,
|
||||
operation="create",
|
||||
target=f"{payload.scope_type}:{scope_id}",
|
||||
request_id=request_id,
|
||||
)
|
||||
expires_at = now + timedelta(days=payload.expires_in_days)
|
||||
try:
|
||||
with organization_memory_operation_locks(
|
||||
self.db,
|
||||
self._scope_lock_key(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=scope_id,
|
||||
),
|
||||
self._request_lock_key(tenant_id, request_id),
|
||||
):
|
||||
replay = self._management_request_replay(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
result = self._serialize_entry(
|
||||
replay,
|
||||
self._department_names([replay]),
|
||||
)
|
||||
self.db.commit()
|
||||
return result
|
||||
|
||||
active = self._get_scope_active_entry(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
if active is not None:
|
||||
if self._is_expired(active, now):
|
||||
active.status = "expired"
|
||||
active.expired_at = now
|
||||
self.db.flush()
|
||||
else:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"该组织范围已有生效记忆,请使用更新操作换代。"
|
||||
)
|
||||
entry = self._new_active_generation(
|
||||
entry_id=entry_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=scope_id,
|
||||
value=payload.value,
|
||||
expires_at=expires_at,
|
||||
reason=reason,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
expected_active_ids=set(),
|
||||
current_user=current_user,
|
||||
now=now,
|
||||
)
|
||||
self.db.commit()
|
||||
except OrganizationMemoryConflictError:
|
||||
self.db.rollback()
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
self.db.rollback()
|
||||
replay = self._management_request_replay(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is None:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"该组织记忆已被其他管理员创建,请刷新后重试。"
|
||||
) from error
|
||||
entry = replay
|
||||
self.db.refresh(entry)
|
||||
return self._serialize_entry(entry, self._department_names([entry]))
|
||||
def update_organization_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
payload: ExpenseApplicationOrganizationMemoryUpdate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryRead:
|
||||
self._require_admin(current_user)
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
previous_snapshot = self._get_entry(memory_id, tenant_id, for_update=False)
|
||||
request_id = self._normalize_request_id(payload.request_id)
|
||||
reason = self._normalize_reason(payload.reason)
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
operation="update",
|
||||
target=previous_snapshot.id,
|
||||
payload={
|
||||
"value": payload.value,
|
||||
"expires_in_days": payload.expires_in_days,
|
||||
"expected_generation": payload.expected_generation,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
entry_id = self._request_entry_id(
|
||||
tenant_id=tenant_id,
|
||||
operation="update",
|
||||
target=previous_snapshot.id,
|
||||
request_id=request_id,
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
with organization_memory_operation_locks(
|
||||
self.db,
|
||||
self._scope_lock_key_for_entry(tenant_id, previous_snapshot),
|
||||
self._request_lock_key(tenant_id, request_id),
|
||||
):
|
||||
replay = self._management_request_replay(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
result = self._serialize_entry(
|
||||
replay,
|
||||
self._department_names([replay]),
|
||||
)
|
||||
self.db.commit()
|
||||
return result
|
||||
previous = self._get_entry(memory_id, tenant_id)
|
||||
self._require_active(previous)
|
||||
self._validate_expected_generation(previous, payload.expected_generation)
|
||||
if self._is_expired(previous, now):
|
||||
previous.status = "expired"
|
||||
previous.expired_at = now
|
||||
self.db.commit()
|
||||
raise OrganizationMemoryConflictError(
|
||||
"组织记忆已过期,不能继续更新。"
|
||||
)
|
||||
|
||||
expires_at = (
|
||||
now + timedelta(days=payload.expires_in_days)
|
||||
if payload.expires_in_days is not None
|
||||
else self._aware(previous.active_expires_at)
|
||||
)
|
||||
entry = self._new_active_generation(
|
||||
entry_id=entry_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=str(previous.scope_type),
|
||||
scope_id=str(previous.scope_id),
|
||||
value=payload.value or self._entry_value(previous),
|
||||
expires_at=expires_at,
|
||||
reason=reason,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
expected_active_ids={previous.id},
|
||||
current_user=current_user,
|
||||
now=now,
|
||||
)
|
||||
self.db.commit()
|
||||
except OrganizationMemoryConflictError:
|
||||
self.db.rollback()
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
self.db.rollback()
|
||||
replay = self._management_request_replay(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is None:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"组织记忆已被其他管理员更新,请刷新后重试。"
|
||||
) from error
|
||||
entry = replay
|
||||
self.db.refresh(entry)
|
||||
return self._serialize_entry(entry, self._department_names([entry]))
|
||||
def revoke_organization_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
payload: ExpenseApplicationOrganizationMemoryRevoke,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryRevokedRead:
|
||||
self._require_admin(current_user)
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
entry_snapshot = self._get_entry(memory_id, tenant_id, for_update=False)
|
||||
request_id = self._normalize_request_id(payload.request_id)
|
||||
reason = self._normalize_reason(payload.reason)
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
operation="revoke",
|
||||
target=entry_snapshot.id,
|
||||
payload={
|
||||
"expected_generation": payload.expected_generation,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
with organization_memory_operation_locks(
|
||||
self.db,
|
||||
self._scope_lock_key_for_entry(tenant_id, entry_snapshot),
|
||||
self._request_lock_key(tenant_id, request_id),
|
||||
):
|
||||
replay = self._revoke_request_replay(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
result = self._revoked_response(replay)
|
||||
self.db.commit()
|
||||
return result
|
||||
entry = self._get_entry(memory_id, tenant_id)
|
||||
self._require_active(entry)
|
||||
self._validate_expected_generation(entry, payload.expected_generation)
|
||||
if self._is_expired(entry, now):
|
||||
entry.status = "expired"
|
||||
entry.expired_at = now
|
||||
self.db.commit()
|
||||
raise OrganizationMemoryConflictError(
|
||||
"组织记忆已过期,不能继续撤销。"
|
||||
)
|
||||
entry.status = "revoked"
|
||||
entry.revoked_at = now
|
||||
entry.revoked_reason = reason
|
||||
entry.managed_by = self._actor_id(current_user)
|
||||
entry.managed_at = now
|
||||
entry.management_reason = reason
|
||||
entry.revoke_request_id = request_id
|
||||
entry.revoke_payload_fingerprint = request_fingerprint
|
||||
self.db.commit()
|
||||
except OrganizationMemoryConflictError:
|
||||
self.db.rollback()
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
self.db.rollback()
|
||||
replay = self._revoke_request_replay(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is None:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"组织记忆已被其他管理员撤销,请刷新后重试。"
|
||||
) from error
|
||||
entry = replay
|
||||
return self._revoked_response(entry)
|
||||
def _new_active_generation(
|
||||
self,
|
||||
*,
|
||||
entry_id: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
value: str,
|
||||
expires_at: datetime,
|
||||
reason: str,
|
||||
request_id: str,
|
||||
request_fingerprint: str,
|
||||
expected_active_ids: set[str],
|
||||
current_user: CurrentUserContext,
|
||||
now: datetime,
|
||||
) -> MemoryEntry:
|
||||
if value not in SUPPORTED_TRANSPORT_VALUES:
|
||||
raise ValueError("组织记忆只允许飞机、火车或轮船三种低敏枚举值。")
|
||||
existing = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == scope_type,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
MemoryEntry.status == "active",
|
||||
)
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
actual_active_ids = {entry.id for entry in existing}
|
||||
if actual_active_ids != expected_active_ids:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"组织记忆生效版本已变化,请刷新后重试。"
|
||||
)
|
||||
generation = int(
|
||||
self.db.scalar(
|
||||
select(func.coalesce(func.max(MemoryEntry.generation), 0)).where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == scope_type,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
for old_entry in existing:
|
||||
old_entry.status = "suppressed"
|
||||
old_entry.suppressed_at = now
|
||||
if existing:
|
||||
self.db.flush()
|
||||
entry = MemoryEntry(
|
||||
id=entry_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scene=MEMORY_SCENE,
|
||||
field_key=MEMORY_FIELD_KEY,
|
||||
origin_type="admin_managed",
|
||||
generation=generation,
|
||||
value_json={"value": value},
|
||||
value_fingerprint=hmac_fingerprint(
|
||||
{"field_key": MEMORY_FIELD_KEY, "value": value}
|
||||
),
|
||||
status="active",
|
||||
evidence_count=0,
|
||||
approved_evidence_count=0,
|
||||
confidence=Decimal("1.0000"),
|
||||
last_evidence_at=now,
|
||||
candidate_expires_at=expires_at,
|
||||
activated_at=now,
|
||||
active_expires_at=expires_at,
|
||||
managed_by=self._actor_id(current_user),
|
||||
managed_at=now,
|
||||
management_reason=reason,
|
||||
policy_version=ORGANIZATION_MEMORY_POLICY_VERSION,
|
||||
management_request_id=request_id,
|
||||
management_payload_fingerprint=request_fingerprint,
|
||||
)
|
||||
self.db.add(entry)
|
||||
self.db.flush()
|
||||
for old_entry in existing:
|
||||
old_entry.superseded_by_id = entry.id
|
||||
self.db.flush()
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def _scope_lock_key(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
) -> str:
|
||||
return organization_memory_scope_lock_key(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scene=MEMORY_SCENE,
|
||||
field_key=MEMORY_FIELD_KEY,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _scope_lock_key_for_entry(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
entry: MemoryEntry,
|
||||
) -> str:
|
||||
return cls._scope_lock_key(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=str(entry.scope_type),
|
||||
scope_id=str(entry.scope_id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _request_lock_key(tenant_id: str, request_id: str) -> str:
|
||||
return organization_memory_request_lock_key(tenant_id, request_id)
|
||||
|
||||
def _management_request_replay(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
request_fingerprint: str,
|
||||
) -> MemoryEntry | None:
|
||||
if self._get_revoke_request_entry(tenant_id, request_id) is not None:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"该幂等请求标识已用于其他组织记忆操作。"
|
||||
)
|
||||
entry = self.db.scalar(
|
||||
self._organization_entry_query().where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.management_request_id == request_id,
|
||||
)
|
||||
)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry.management_payload_fingerprint != request_fingerprint:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"同一幂等请求标识对应的请求内容不一致。"
|
||||
)
|
||||
return entry
|
||||
|
||||
def _revoke_request_replay(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
request_fingerprint: str,
|
||||
) -> MemoryEntry | None:
|
||||
management_entry = self.db.scalar(
|
||||
self._organization_entry_query().where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.management_request_id == request_id,
|
||||
)
|
||||
)
|
||||
if management_entry is not None:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"该幂等请求标识已用于其他组织记忆操作。"
|
||||
)
|
||||
entry = self._get_revoke_request_entry(tenant_id, request_id)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry.revoke_payload_fingerprint != request_fingerprint:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"同一幂等请求标识对应的请求内容不一致。"
|
||||
)
|
||||
if entry.status != "revoked" or entry.revoked_at is None:
|
||||
raise OrganizationMemoryConflictError("撤销操作审计状态不完整,请人工复核。")
|
||||
return entry
|
||||
|
||||
def _get_revoke_request_entry(
|
||||
self,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
) -> MemoryEntry | None:
|
||||
return self.db.scalar(
|
||||
self._organization_entry_query().where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.revoke_request_id == request_id,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _request_fingerprint(
|
||||
*,
|
||||
operation: str,
|
||||
target: str,
|
||||
payload: dict[str, object],
|
||||
) -> str:
|
||||
return hmac_fingerprint(
|
||||
{
|
||||
"protocol": "organization_memory_mutation.v1",
|
||||
"operation": operation,
|
||||
"target": target,
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request_id(value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if len(normalized) < 8:
|
||||
raise ValueError("组织记忆管理操作缺少有效的幂等请求标识。")
|
||||
return normalized[:120]
|
||||
|
||||
@staticmethod
|
||||
def _revoked_response(entry: MemoryEntry) -> ExpenseApplicationMemoryRevokedRead:
|
||||
if entry.revoked_at is None:
|
||||
raise OrganizationMemoryConflictError("撤销操作缺少审计时间,请人工复核。")
|
||||
return ExpenseApplicationMemoryRevokedRead(
|
||||
memory_id=entry.id,
|
||||
revoked_at=entry.revoked_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _organization_entry_query():
|
||||
return select(MemoryEntry).where(
|
||||
MemoryEntry.scope_type.in_(ORGANIZATION_SCOPE_TYPES),
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
)
|
||||
|
||||
def _get_entry(
|
||||
self,
|
||||
memory_id: str,
|
||||
tenant_id: str,
|
||||
*,
|
||||
for_update: bool = True,
|
||||
) -> MemoryEntry:
|
||||
statement = self._organization_entry_query().where(
|
||||
MemoryEntry.id == str(memory_id or "").strip(),
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
entry = self.db.scalar(statement)
|
||||
if entry is None:
|
||||
raise OrganizationMemoryNotFoundError("未找到当前租户内可管理的组织记忆。")
|
||||
return entry
|
||||
|
||||
def _get_scope_active_entry(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
) -> MemoryEntry | None:
|
||||
return self.db.scalar(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == scope_type,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
MemoryEntry.status == "active",
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _require_active(entry: MemoryEntry) -> None:
|
||||
if entry.status != "active":
|
||||
raise OrganizationMemoryConflictError(
|
||||
"该组织记忆已被换代、撤销或失效,请刷新后重试。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _request_entry_id(
|
||||
*,
|
||||
tenant_id: str,
|
||||
operation: str,
|
||||
target: str,
|
||||
request_id: str,
|
||||
) -> str:
|
||||
normalized_request_id = str(request_id or "").strip()
|
||||
if not normalized_request_id:
|
||||
raise ValueError("组织记忆管理操作缺少幂等请求标识。")
|
||||
material = "|".join(
|
||||
(tenant_id, operation, target, normalized_request_id)
|
||||
)
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"x-financial:memory:{material}"))
|
||||
|
||||
def _validate_scope(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> str:
|
||||
normalized_scope_id = str(scope_id or "").strip()
|
||||
if scope_type == "enterprise":
|
||||
if normalized_scope_id and normalized_scope_id != tenant_id:
|
||||
raise ValueError("企业记忆的 scope_id 必须等于当前租户 ID。")
|
||||
return tenant_id
|
||||
if scope_type != "department" or not normalized_scope_id:
|
||||
raise ValueError("部门记忆必须提供稳定的 OrganizationUnit.id。")
|
||||
department = self.db.get(OrganizationUnit, normalized_scope_id)
|
||||
if department is None or str(department.unit_type or "") != "department":
|
||||
raise ValueError("部门记忆只能绑定已存在的 department 类型组织单元。")
|
||||
return normalized_scope_id
|
||||
|
||||
@staticmethod
|
||||
def _require_admin(current_user: CurrentUserContext) -> None:
|
||||
if not current_user.is_admin:
|
||||
raise PermissionError("只有平台管理员可以维护企业或部门记忆。")
|
||||
|
||||
@staticmethod
|
||||
def _validate_expected_generation(entry: MemoryEntry, expected: int) -> None:
|
||||
if int(entry.generation or 0) != expected:
|
||||
raise OrganizationMemoryConflictError(
|
||||
"组织记忆已被其他管理员更新,请刷新后重试。"
|
||||
)
|
||||
|
||||
def _department_names(self, entries: list[MemoryEntry]) -> dict[str, str]:
|
||||
department_ids = {
|
||||
str(entry.scope_id)
|
||||
for entry in entries
|
||||
if entry.scope_type == "department" and str(entry.scope_id or "")
|
||||
}
|
||||
if not department_ids:
|
||||
return {}
|
||||
return {
|
||||
unit.id: str(unit.name or "").strip()
|
||||
for unit in self.db.scalars(
|
||||
select(OrganizationUnit).where(OrganizationUnit.id.in_(department_ids))
|
||||
).all()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_entry(
|
||||
entry: MemoryEntry,
|
||||
department_names: dict[str, str],
|
||||
) -> ExpenseApplicationMemoryRead:
|
||||
scope_type = str(entry.scope_type)
|
||||
if scope_type == "enterprise":
|
||||
scope_label = "企业统一规则"
|
||||
else:
|
||||
name = department_names.get(str(entry.scope_id), "")
|
||||
scope_label = f"部门规则({name})" if name else "部门规则"
|
||||
return ExpenseApplicationMemoryRead(
|
||||
id=entry.id,
|
||||
scene=entry.scene,
|
||||
field_key=entry.field_key,
|
||||
value=ExpenseApplicationOrganizationMemoryService._entry_value(entry),
|
||||
status=entry.status,
|
||||
scope_type=scope_type,
|
||||
scope_id=str(entry.scope_id),
|
||||
scope_label=scope_label,
|
||||
source=memory_source(scope_type),
|
||||
origin_type=str(getattr(entry, "origin_type", "admin_managed")),
|
||||
generation=int(entry.generation or 1),
|
||||
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=0,
|
||||
policy_version=str(
|
||||
getattr(entry, "policy_version", ORGANIZATION_MEMORY_POLICY_VERSION)
|
||||
),
|
||||
valid_from=entry.activated_at or entry.created_at,
|
||||
expires_at=entry.active_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 ""),
|
||||
managed_by=str(getattr(entry, "managed_by", "") or ""),
|
||||
managed_at=getattr(entry, "managed_at", None),
|
||||
management_reason=str(getattr(entry, "management_reason", "") or ""),
|
||||
superseded_by_id=str(entry.superseded_by_id or ""),
|
||||
can_revoke=entry.status == "active",
|
||||
created_at=entry.created_at,
|
||||
updated_at=entry.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _entry_value(entry: MemoryEntry) -> str:
|
||||
value_json = entry.value_json if isinstance(entry.value_json, dict) else {}
|
||||
return str(value_json.get("value") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _actor_id(current_user: CurrentUserContext) -> str:
|
||||
value = str(current_user.employee_id or current_user.username or "").strip()
|
||||
if not value:
|
||||
raise ValueError("当前管理员缺少稳定的操作人标识。")
|
||||
return value[:120]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reason(value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("组织记忆管理操作必须填写原因。")
|
||||
return normalized[:255]
|
||||
|
||||
@staticmethod
|
||||
def _is_expired(entry: MemoryEntry, now: datetime) -> bool:
|
||||
return (
|
||||
entry.active_expires_at is not None
|
||||
and ExpenseApplicationOrganizationMemoryService._aware(
|
||||
entry.active_expires_at
|
||||
)
|
||||
<= now
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aware(value: datetime | None) -> datetime:
|
||||
if value is None:
|
||||
raise ValueError("组织记忆缺少有效期。")
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
264
server/src/app/services/expense_application_memory_resolution.py
Normal file
264
server/src/app/services/expense_application_memory_resolution.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_memory import MemoryEntry
|
||||
from app.schemas.expense_application_memory import (
|
||||
ExpenseApplicationMemoryApplication,
|
||||
ExpenseApplicationMemoryConflict,
|
||||
)
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
MEMORY_SCENE = "travel_application"
|
||||
MEMORY_FIELD_KEY = "transport_mode"
|
||||
SUPPORTED_TRANSPORT_VALUES = {"飞机", "火车", "轮船"}
|
||||
SCOPE_PRIORITIES = {"enterprise": 300, "department": 200, "user": 100}
|
||||
|
||||
RefreshUserEntry = Callable[..., None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExpenseApplicationMemoryResolution:
|
||||
winner: MemoryEntry | None
|
||||
value: str
|
||||
conflicts: tuple[ExpenseApplicationMemoryConflict, ...]
|
||||
effective_confidence: float
|
||||
status: str = "applied"
|
||||
|
||||
def to_application(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryApplication:
|
||||
if self.winner is None:
|
||||
first = self.conflicts[0]
|
||||
return ExpenseApplicationMemoryApplication(
|
||||
memory_id="",
|
||||
value="",
|
||||
source="memory_conflict",
|
||||
status="conflict",
|
||||
confidence=0.0,
|
||||
effective_confidence=0.0,
|
||||
scope_type=first.scope_type,
|
||||
scope_id="",
|
||||
scope_label=first.scope_label,
|
||||
priority=first.priority,
|
||||
conflicts=list(self.conflicts),
|
||||
can_revoke=False,
|
||||
message="发现同一层级存在相互冲突的有效记忆,本次未自动填充,请联系管理员处理。",
|
||||
)
|
||||
|
||||
entry = self.winner
|
||||
scope_type = _scope_type(entry)
|
||||
return ExpenseApplicationMemoryApplication(
|
||||
memory_id=entry.id,
|
||||
value=self.value,
|
||||
source=memory_source(scope_type),
|
||||
status=self.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),
|
||||
effective_confidence=self.effective_confidence,
|
||||
expires_at=entry.active_expires_at,
|
||||
scope_type=scope_type,
|
||||
scope_id=str(entry.scope_id or ""),
|
||||
scope_label=memory_scope_label(entry, current_user),
|
||||
priority=SCOPE_PRIORITIES[scope_type],
|
||||
conflicts=list(self.conflicts),
|
||||
can_revoke=scope_type == "user",
|
||||
message=_application_message(scope_type),
|
||||
)
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryResolver:
|
||||
"""解析当前用户可见的企业、部门、个人记忆,并保守处理冲突。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
user_scope_id: str,
|
||||
refresh_user_entry: RefreshUserEntry,
|
||||
) -> ExpenseApplicationMemoryResolution | None:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
relevant_scopes = [("enterprise", tenant_id)]
|
||||
department_id = str(getattr(current_user, "department_id", "") or "").strip()
|
||||
if department_id:
|
||||
relevant_scopes.append(("department", department_id))
|
||||
relevant_scopes.append(("user", user_scope_id))
|
||||
|
||||
now = datetime.now(UTC)
|
||||
entries: list[MemoryEntry] = []
|
||||
for scope_type, scope_id in relevant_scopes:
|
||||
entries.extend(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == 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.generation.desc(),
|
||||
MemoryEntry.last_evidence_at.desc(),
|
||||
)
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
|
||||
user_entries = [entry for entry in entries if _scope_type(entry) == "user"]
|
||||
active_user_found = False
|
||||
for entry in user_entries:
|
||||
if entry.status != "active":
|
||||
continue
|
||||
refresh_user_entry(entry, now=now)
|
||||
active_user_found = active_user_found or entry.status == "active"
|
||||
for entry in user_entries:
|
||||
if entry.status != "candidate":
|
||||
continue
|
||||
refresh_user_entry(
|
||||
entry,
|
||||
now=now,
|
||||
allow_activation=not active_user_found,
|
||||
)
|
||||
active_user_found = active_user_found or entry.status == "active"
|
||||
|
||||
eligible: list[MemoryEntry] = []
|
||||
for entry in entries:
|
||||
if (
|
||||
_scope_type(entry) != "user"
|
||||
and entry.status == "active"
|
||||
and _is_expired(entry, now)
|
||||
):
|
||||
entry.status = "expired"
|
||||
entry.expired_at = now
|
||||
if entry.status != "active" or _is_expired(entry, now):
|
||||
continue
|
||||
if _entry_value(entry):
|
||||
eligible.append(entry)
|
||||
|
||||
if not eligible:
|
||||
return None
|
||||
|
||||
highest_priority = max(SCOPE_PRIORITIES[_scope_type(entry)] for entry in eligible)
|
||||
highest = [
|
||||
entry
|
||||
for entry in eligible
|
||||
if SCOPE_PRIORITIES[_scope_type(entry)] == highest_priority
|
||||
]
|
||||
top_values = {_entry_value(entry) for entry in highest}
|
||||
if len(top_values) > 1:
|
||||
conflicts = tuple(
|
||||
_conflict(entry, current_user, reason="same_priority_conflict")
|
||||
for entry in highest
|
||||
)
|
||||
return ExpenseApplicationMemoryResolution(
|
||||
winner=None,
|
||||
value="",
|
||||
conflicts=conflicts,
|
||||
effective_confidence=0.0,
|
||||
status="conflict",
|
||||
)
|
||||
|
||||
winner = highest[0]
|
||||
value = _entry_value(winner)
|
||||
conflicts = tuple(
|
||||
_conflict(entry, current_user, reason="lower_priority_overridden")
|
||||
for entry in eligible
|
||||
if SCOPE_PRIORITIES[_scope_type(entry)] < highest_priority
|
||||
and _entry_value(entry) != value
|
||||
)
|
||||
return ExpenseApplicationMemoryResolution(
|
||||
winner=winner,
|
||||
value=value,
|
||||
conflicts=conflicts,
|
||||
effective_confidence=_effective_confidence(winner, now),
|
||||
)
|
||||
|
||||
|
||||
def _entry_value(entry: MemoryEntry) -> str:
|
||||
value_json = entry.value_json if isinstance(entry.value_json, dict) else {}
|
||||
value = str(value_json.get("value") or "").strip()
|
||||
return value if value in SUPPORTED_TRANSPORT_VALUES else ""
|
||||
|
||||
|
||||
def _scope_type(entry: MemoryEntry) -> str:
|
||||
value = str(entry.scope_type or "user")
|
||||
return value if value in SCOPE_PRIORITIES else "user"
|
||||
|
||||
|
||||
def memory_source(scope_type: str) -> str:
|
||||
return {
|
||||
"enterprise": "enterprise_policy_memory",
|
||||
"department": "department_policy_memory",
|
||||
"user": "verified_user_history",
|
||||
}[scope_type]
|
||||
|
||||
|
||||
def memory_scope_label(entry: MemoryEntry, current_user: CurrentUserContext) -> str:
|
||||
scope_type = _scope_type(entry)
|
||||
if scope_type == "enterprise":
|
||||
return "企业统一规则"
|
||||
if scope_type == "department":
|
||||
department_name = str(current_user.department_name or "").strip()
|
||||
return f"部门规则({department_name})" if department_name else "部门规则"
|
||||
return "个人偏好"
|
||||
|
||||
|
||||
def _application_message(scope_type: str) -> str:
|
||||
return {
|
||||
"enterprise": "已按企业统一规则预填出行方式,可在规则允许范围内调整。",
|
||||
"department": "已按当前部门规则预填出行方式,可继续修改。",
|
||||
"user": "已按可信历史记忆预填常用出行方式,可继续修改。",
|
||||
}[scope_type]
|
||||
|
||||
|
||||
def _conflict(
|
||||
entry: MemoryEntry,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
reason: str,
|
||||
) -> ExpenseApplicationMemoryConflict:
|
||||
scope_type = _scope_type(entry)
|
||||
return ExpenseApplicationMemoryConflict(
|
||||
scope_type=scope_type,
|
||||
scope_label=memory_scope_label(entry, current_user),
|
||||
priority=SCOPE_PRIORITIES[scope_type],
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _effective_confidence(entry: MemoryEntry, now: datetime) -> float:
|
||||
base = max(0.0, min(1.0, float(entry.confidence or 0)))
|
||||
if base == 0.0 and str(getattr(entry, "origin_type", "learned")) == "admin_managed":
|
||||
base = 1.0
|
||||
started_at = entry.activated_at or entry.created_at
|
||||
expires_at = entry.active_expires_at
|
||||
if started_at is None or expires_at is None:
|
||||
return round(base, 4)
|
||||
started = _aware(started_at)
|
||||
expires = _aware(expires_at)
|
||||
total_seconds = max(1.0, (expires - started).total_seconds())
|
||||
remaining_ratio = max(0.0, min(1.0, (expires - now).total_seconds() / total_seconds))
|
||||
# 有效期内仅降低解释性置信度,不会因衰减而提前停止应用。
|
||||
return round(base * max(0.5, remaining_ratio), 4)
|
||||
|
||||
|
||||
def _is_expired(entry: MemoryEntry, now: datetime) -> bool:
|
||||
expires_at = entry.active_expires_at if entry.status == "active" else entry.candidate_expires_at
|
||||
return expires_at is not None and _aware(expires_at) <= now
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
202
server/src/app/services/expense_claim_historical_evidence.py
Normal file
202
server/src/app/services/expense_claim_historical_evidence.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.secret_box import decrypt_secret
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.system_model_setting import SystemModelSetting
|
||||
from app.services.embedding_provider import EmbeddingProvider
|
||||
from app.services.few_shot_retrieval import FewShotRetriever
|
||||
from app.services.few_shot_store import FewShotStore
|
||||
from app.services.knowledge_rag_runtime import RuntimeModelConfig
|
||||
|
||||
logger = get_logger("app.services.expense_claim_historical_evidence")
|
||||
|
||||
_SCENE_BY_BUSINESS_STAGE = {
|
||||
"expense_application": "expense_application",
|
||||
"reimbursement": "expense_reimbursement",
|
||||
}
|
||||
_LABEL_TEXT = {
|
||||
"confirmed": "历史已确认,仅供复核",
|
||||
"false_positive": "历史误报,仅供复核",
|
||||
}
|
||||
_SUMMARY_BY_LABEL = {
|
||||
"confirmed": "历史相似案例经人工复核确认风险成立。",
|
||||
"false_positive": "历史相似案例经人工复核判定为误报。",
|
||||
}
|
||||
_MAX_RULE_CONTEXTS = 3
|
||||
_MAX_EVIDENCE = 3
|
||||
|
||||
|
||||
class ExpenseClaimHistoricalEvidenceService:
|
||||
"""为费用预审补充历史案例证据,但不参与确定性决策。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
tenant_id: str,
|
||||
business_stage: str,
|
||||
findings: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
tenant = _text(tenant_id)
|
||||
scene_code = _SCENE_BY_BUSINESS_STAGE.get(_text(business_stage), "")
|
||||
if not tenant or not scene_code:
|
||||
return []
|
||||
|
||||
try:
|
||||
retriever = self._build_retriever()
|
||||
if retriever is None:
|
||||
return []
|
||||
query = self._build_query(claim, findings=findings)
|
||||
evidence: list[dict[str, Any]] = []
|
||||
emitted_sample_ids: set[str] = set()
|
||||
for policy_ref, rule_version in self._rule_contexts(findings):
|
||||
hits = retriever.retrieve_for_expense_case(
|
||||
tenant_id=tenant,
|
||||
scene=scene_code,
|
||||
policy_ref=policy_ref,
|
||||
rule_version=rule_version,
|
||||
query=query,
|
||||
top_k=_MAX_EVIDENCE,
|
||||
)
|
||||
for hit in hits:
|
||||
sample_id = _text(hit.get("sample_id"))
|
||||
if not sample_id or sample_id in emitted_sample_ids:
|
||||
continue
|
||||
public_item = self._to_public_evidence(hit, scene_code=scene_code)
|
||||
if not public_item:
|
||||
continue
|
||||
evidence.append(public_item)
|
||||
emitted_sample_ids.add(sample_id)
|
||||
if len(evidence) >= _MAX_EVIDENCE:
|
||||
return evidence
|
||||
return evidence
|
||||
except Exception:
|
||||
# 历史案例只作复核参考。检索、向量库或配置异常不得阻断预审。
|
||||
logger.warning(
|
||||
"费用预审历史案例检索失败 tenant_id=%s claim_id=%s",
|
||||
tenant,
|
||||
_text(claim.id),
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
def _build_retriever(self) -> FewShotRetriever | None:
|
||||
"""只读加载 embedding 配置,禁止在费用事务中触发配置初始化提交。"""
|
||||
|
||||
model_row = self.db.get(SystemModelSetting, "embedding")
|
||||
if model_row is None or not model_row.enabled:
|
||||
return None
|
||||
encrypted_api_key = _text(model_row.api_key_encrypted)
|
||||
try:
|
||||
api_key = decrypt_secret(encrypted_api_key) if encrypted_api_key else ""
|
||||
except ValueError:
|
||||
logger.warning("embedding 配置密钥无法解密,历史案例检索已跳过")
|
||||
return None
|
||||
provider = EmbeddingProvider(
|
||||
RuntimeModelConfig(
|
||||
slot="embedding",
|
||||
provider=_text(model_row.provider),
|
||||
model=_text(model_row.model_name),
|
||||
endpoint=_text(model_row.endpoint),
|
||||
api_key=api_key,
|
||||
capability=_text(model_row.capability) or "embedding",
|
||||
)
|
||||
)
|
||||
return FewShotRetriever(FewShotStore(provider), self.db)
|
||||
|
||||
@staticmethod
|
||||
def _rule_contexts(
|
||||
findings: list[dict[str, Any]],
|
||||
) -> list[tuple[str, str]]:
|
||||
contexts: list[tuple[str, str]] = []
|
||||
for finding in findings:
|
||||
context = (
|
||||
_text(finding.get("rule_code")),
|
||||
_text(finding.get("rule_version")),
|
||||
)
|
||||
if context != ("", "") and context not in contexts:
|
||||
contexts.append(context)
|
||||
if len(contexts) >= _MAX_RULE_CONTEXTS:
|
||||
break
|
||||
# 无规则命中时仍按租户和业务场景检索,但显式传递空的规则标识。
|
||||
return contexts or [("", "")]
|
||||
|
||||
@staticmethod
|
||||
def _build_query(
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
findings: list[dict[str, Any]],
|
||||
) -> str:
|
||||
parts = [
|
||||
_text(claim.expense_type),
|
||||
_text(claim.reason),
|
||||
_text(claim.location),
|
||||
*[
|
||||
_text(finding.get("message"))
|
||||
for finding in findings
|
||||
if _text(finding.get("message"))
|
||||
],
|
||||
]
|
||||
return "\n".join(part for part in parts if part).strip()
|
||||
|
||||
@staticmethod
|
||||
def _to_public_evidence(
|
||||
hit: dict[str, Any],
|
||||
*,
|
||||
scene_code: str,
|
||||
) -> dict[str, Any]:
|
||||
label = _text(hit.get("label")).lower()
|
||||
label_text = _LABEL_TEXT.get(label)
|
||||
summary = _SUMMARY_BY_LABEL.get(label)
|
||||
if not label_text or not summary:
|
||||
return {}
|
||||
return {
|
||||
"label": label,
|
||||
"label_text": label_text,
|
||||
"advisory_only": True,
|
||||
"score": round(float(hit.get("score") or 0.0), 4),
|
||||
"scene_code": _text(hit.get("scene")) or scene_code,
|
||||
"policy_ref": _text(hit.get("policy_ref")),
|
||||
"rule_version": _text(hit.get("rule_version")),
|
||||
"version_status": (
|
||||
"stale" if bool(hit.get("stale")) else "matched"
|
||||
),
|
||||
"stale": bool(hit.get("stale")),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def build_user_agent_historical_evidence_notice(claim: ExpenseClaim) -> str:
|
||||
"""生成脱敏的 User Agent 提示,不读取或回显历史案例原文。"""
|
||||
|
||||
flags = claim.risk_flags_json
|
||||
if isinstance(flags, dict):
|
||||
flags = [flags]
|
||||
if not isinstance(flags, list):
|
||||
return ""
|
||||
labels: list[str] = []
|
||||
for flag in flags:
|
||||
if (
|
||||
not isinstance(flag, dict)
|
||||
or _text(flag.get("source")) != "ai_pre_review"
|
||||
):
|
||||
continue
|
||||
for item in list(flag.get("historical_case_evidence") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label_text = _LABEL_TEXT.get(_text(item.get("label")).lower(), "")
|
||||
if label_text and label_text not in labels:
|
||||
labels.append(label_text)
|
||||
return "历史案例参考:" + ";".join(labels) if labels else ""
|
||||
@@ -6,6 +6,9 @@ from typing import Any
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_historical_evidence import (
|
||||
ExpenseClaimHistoricalEvidenceService,
|
||||
)
|
||||
from app.services.expense_claim_pre_review_decision import build_pre_review_decision
|
||||
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
@@ -45,6 +48,7 @@ class ExpenseClaimPreReviewMixin:
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
reviewed_at=datetime.now(UTC),
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if pre_review_flag is None:
|
||||
raise RuntimeError("无法生成费用预审结果。")
|
||||
@@ -83,6 +87,7 @@ class ExpenseClaimPreReviewMixin:
|
||||
*,
|
||||
decision_payload: dict[str, Any],
|
||||
business_stage: str,
|
||||
historical_case_evidence: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = str(decision_payload.get("decision") or "ready_with_review")
|
||||
passed = decision != "needs_fix"
|
||||
@@ -101,6 +106,7 @@ class ExpenseClaimPreReviewMixin:
|
||||
"passed": passed,
|
||||
"blocking_risk_count": blocking_count,
|
||||
**decision_payload,
|
||||
"historical_case_evidence": list(historical_case_evidence or []),
|
||||
"next_action": "next_step" if passed else "risk_explanation_required",
|
||||
"created_at": str(decision_payload.get("reviewed_at") or ""),
|
||||
},
|
||||
@@ -128,12 +134,14 @@ class ExpenseClaimPreReviewMixin:
|
||||
*,
|
||||
is_application_claim: bool | None = None,
|
||||
reviewed_at: datetime | None = None,
|
||||
tenant_id: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
"""业务变更事务内刷新预审快照,不提交、不单独写事件。"""
|
||||
return self._refresh_claim_pre_review_flags(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
reviewed_at=reviewed_at,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
def _refresh_claim_pre_review_flags(
|
||||
@@ -142,6 +150,7 @@ class ExpenseClaimPreReviewMixin:
|
||||
*,
|
||||
is_application_claim: bool | None = None,
|
||||
reviewed_at: datetime | None = None,
|
||||
tenant_id: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
if claim is None:
|
||||
return None
|
||||
@@ -187,9 +196,18 @@ class ExpenseClaimPreReviewMixin:
|
||||
platform_rule_set_fingerprint=platform_rule_set_fingerprint,
|
||||
reviewed_at=reviewed_at,
|
||||
)
|
||||
historical_case_evidence = ExpenseClaimHistoricalEvidenceService(
|
||||
self.db
|
||||
).retrieve(
|
||||
claim,
|
||||
tenant_id=tenant_id,
|
||||
business_stage=business_stage,
|
||||
findings=list(decision_payload.get("findings") or []),
|
||||
)
|
||||
pre_review_flag = self._build_ai_pre_review_flag(
|
||||
decision_payload=decision_payload,
|
||||
business_stage=business_stage,
|
||||
historical_case_evidence=historical_case_evidence,
|
||||
)
|
||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
||||
review_flags,
|
||||
|
||||
@@ -210,6 +210,48 @@ def pre_review_public_payload(flag: dict[str, Any] | None) -> dict[str, Any] | N
|
||||
for item in list(flag.get("findings") or [])
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"historical_case_evidence": _public_historical_evidence(flag),
|
||||
}
|
||||
|
||||
|
||||
def _public_historical_evidence(flag: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in list(flag.get("historical_case_evidence") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
public_item = _historical_evidence_public_payload(item)
|
||||
if public_item is not None:
|
||||
result.append(public_item)
|
||||
return result
|
||||
|
||||
|
||||
def _historical_evidence_public_payload(
|
||||
item: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
label = _text(item.get("label")).lower()
|
||||
if label not in {"confirmed", "false_positive"}:
|
||||
return None
|
||||
return {
|
||||
"label": label,
|
||||
"label_text": (
|
||||
"历史已确认,仅供复核"
|
||||
if label == "confirmed"
|
||||
else "历史误报,仅供复核"
|
||||
),
|
||||
"advisory_only": True,
|
||||
"score": round(float(item.get("score") or 0.0), 4),
|
||||
"scene_code": _text(item.get("scene_code")),
|
||||
"policy_ref": _text(item.get("policy_ref")),
|
||||
"rule_version": _text(item.get("rule_version")),
|
||||
"version_status": (
|
||||
"stale" if bool(item.get("stale")) else "matched"
|
||||
),
|
||||
"stale": bool(item.get("stale")),
|
||||
"summary": (
|
||||
"历史相似案例经人工复核确认风险成立。"
|
||||
if label == "confirmed"
|
||||
else "历史相似案例经人工复核判定为误报。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ class ExpenseClaimItemActionMixin:
|
||||
pre_review_flag = self.refresh_claim_pre_review_state(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if pre_review_flag is None:
|
||||
raise RuntimeError("无法生成提交前预审结果。")
|
||||
|
||||
@@ -31,6 +31,16 @@ LABEL_CONCLUSION_FALLBACK = {
|
||||
"false_positive": "经人工复核判定为误报,相似情形不应触发该风险规则。",
|
||||
}
|
||||
|
||||
CONTROL_STAGE_SCENES = {
|
||||
"application": "expense_application",
|
||||
"expense_application": "expense_application",
|
||||
"pre_application": "expense_application",
|
||||
"pre_reimbursement": "expense_reimbursement",
|
||||
"reimbursement": "expense_reimbursement",
|
||||
"claim": "expense_reimbursement",
|
||||
"post_payment": "expense_post_payment",
|
||||
}
|
||||
|
||||
|
||||
class FewShotIngestionService:
|
||||
"""把已确认的风险观测沉淀为 few-shot 样本。"""
|
||||
@@ -48,22 +58,34 @@ class FewShotIngestionService:
|
||||
label = observation.feedback_status
|
||||
if label not in CONFIRMED_LABELS:
|
||||
return None
|
||||
tenant_id = str(observation.tenant_id or "").strip()
|
||||
if not tenant_id:
|
||||
logger.warning("few-shot ingestion 缺少 tenant_id observation_id=%s", observation.id)
|
||||
return None
|
||||
|
||||
sample_key = f"obs:{observation.id}"
|
||||
sample = self.db.scalar(
|
||||
select(FewShotSample).where(FewShotSample.sample_key == sample_key)
|
||||
select(FewShotSample).where(
|
||||
FewShotSample.tenant_id == tenant_id,
|
||||
FewShotSample.sample_key == sample_key,
|
||||
)
|
||||
)
|
||||
|
||||
domain = self._extract_domain(observation)
|
||||
scene = self._extract_scene(observation)
|
||||
policy_ref, rule_version = self._extract_rule_identity(observation)
|
||||
case_text = self._build_case_text(observation)
|
||||
conclusion_text = self._build_conclusion_text(observation, feedback, label)
|
||||
payload = self._build_payload(observation, feedback, label)
|
||||
|
||||
if sample is None:
|
||||
sample = FewShotSample(
|
||||
tenant_id=tenant_id,
|
||||
sample_key=sample_key,
|
||||
source_observation_id=observation.id,
|
||||
scene="risk_rule_generation",
|
||||
scene=scene,
|
||||
policy_ref=policy_ref,
|
||||
rule_version=rule_version,
|
||||
domain=domain,
|
||||
risk_type=observation.risk_type or "",
|
||||
risk_level=observation.risk_level or "",
|
||||
@@ -75,7 +97,11 @@ class FewShotIngestionService:
|
||||
)
|
||||
self.db.add(sample)
|
||||
else:
|
||||
sample.tenant_id = tenant_id
|
||||
sample.label = label
|
||||
sample.scene = scene
|
||||
sample.policy_ref = policy_ref
|
||||
sample.rule_version = rule_version
|
||||
sample.domain = domain
|
||||
sample.risk_type = observation.risk_type or ""
|
||||
sample.risk_level = observation.risk_level or ""
|
||||
@@ -83,7 +109,6 @@ class FewShotIngestionService:
|
||||
sample.conclusion_text = conclusion_text
|
||||
sample.payload_json = payload
|
||||
sample.status = "active"
|
||||
sample.vector_id = sample.vector_id
|
||||
try:
|
||||
self.db.commit()
|
||||
self.db.refresh(sample)
|
||||
@@ -101,11 +126,17 @@ class FewShotIngestionService:
|
||||
logger.warning("few-shot vector_id 回写失败 sample_id=%s", sample.id)
|
||||
return sample
|
||||
|
||||
def retract_observation(self, observation_id: str) -> bool:
|
||||
def retract_observation(self, observation_id: str, *, tenant_id: str) -> bool:
|
||||
"""观测被撤销时删掉对应样本及其向量。"""
|
||||
|
||||
tenant = str(tenant_id or "").strip()
|
||||
if not tenant:
|
||||
raise ValueError("tenant_id is required")
|
||||
sample = self.db.scalar(
|
||||
select(FewShotSample).where(FewShotSample.source_observation_id == observation_id)
|
||||
select(FewShotSample).where(
|
||||
FewShotSample.tenant_id == tenant,
|
||||
FewShotSample.source_observation_id == observation_id,
|
||||
)
|
||||
)
|
||||
if sample is None:
|
||||
return False
|
||||
@@ -128,6 +159,34 @@ class FewShotIngestionService:
|
||||
ontology = observation.ontology_json or {}
|
||||
return str(ontology.get("domain") or "")
|
||||
|
||||
def _extract_scene(self, observation: RiskObservation) -> str:
|
||||
stage = str(observation.control_stage or "").strip().lower()
|
||||
if stage in CONTROL_STAGE_SCENES:
|
||||
return CONTROL_STAGE_SCENES[stage]
|
||||
return stage or "risk_rule_generation"
|
||||
|
||||
def _extract_rule_identity(self, observation: RiskObservation) -> tuple[str, str]:
|
||||
trace = observation.decision_trace_json or {}
|
||||
policy_ref = _text(
|
||||
trace.get("policy_ref") or trace.get("rule_code") or trace.get("policy_code")
|
||||
)
|
||||
if not policy_ref:
|
||||
for value in observation.policy_refs_json or []:
|
||||
if isinstance(value, dict):
|
||||
policy_ref = _text(
|
||||
value.get("policy_ref") or value.get("rule_code") or value.get("code")
|
||||
)
|
||||
else:
|
||||
policy_ref = _text(value)
|
||||
if policy_ref:
|
||||
break
|
||||
rule_version = _text(
|
||||
trace.get("rule_version")
|
||||
or trace.get("policy_version")
|
||||
or observation.algorithm_version
|
||||
)
|
||||
return policy_ref, rule_version
|
||||
|
||||
def _build_case_text(self, observation: RiskObservation) -> str:
|
||||
parts = [
|
||||
observation.title or "",
|
||||
@@ -162,6 +221,8 @@ class FewShotIngestionService:
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": observation.tenant_id,
|
||||
"scene": self._extract_scene(observation),
|
||||
"label": label,
|
||||
"risk_type": observation.risk_type,
|
||||
"risk_signal": observation.risk_signal,
|
||||
@@ -171,7 +232,13 @@ class FewShotIngestionService:
|
||||
"feedback_actor": feedback.actor or "",
|
||||
"ontology": observation.ontology_json or {},
|
||||
"policy_refs": observation.policy_refs_json or [],
|
||||
"policy_ref": self._extract_rule_identity(observation)[0],
|
||||
"rule_version": self._extract_rule_identity(observation)[1],
|
||||
"evidence": observation.evidence_json or [],
|
||||
"subject_label": observation.subject_label or "",
|
||||
"claim_no": observation.claim_no or "",
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
@@ -19,9 +19,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.few_shot_sample import FewShotSample
|
||||
from app.services.embedding_provider import EmbeddingProvider
|
||||
from app.services.few_shot_store import FewShotStore
|
||||
|
||||
@@ -38,17 +40,19 @@ MAX_HISTORICAL_SAMPLES = 3
|
||||
class FewShotRetriever:
|
||||
"""按 case 特征检索已确认样本,返回 prompt 可直接消费的结构。"""
|
||||
|
||||
def __init__(self, store: FewShotStore) -> None:
|
||||
def __init__(self, store: FewShotStore, session: Session | None = None) -> None:
|
||||
self._store = store
|
||||
self._session = session
|
||||
|
||||
@classmethod
|
||||
def from_session(cls, session: Session) -> "FewShotRetriever":
|
||||
def from_session(cls, session: Session) -> FewShotRetriever:
|
||||
provider = EmbeddingProvider.from_settings(session)
|
||||
return cls(FewShotStore(provider))
|
||||
return cls(FewShotStore(provider), session)
|
||||
|
||||
def retrieve_for_risk_rule_generation(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
domain: str = "",
|
||||
risk_type: str = "",
|
||||
natural_language: str,
|
||||
@@ -65,12 +69,125 @@ class FewShotRetriever:
|
||||
return []
|
||||
hits = self._store.search(
|
||||
case_text,
|
||||
tenant_id=tenant_id,
|
||||
scene="risk_rule_generation",
|
||||
labels=["confirmed", "false_positive"],
|
||||
top_k=top_k,
|
||||
)
|
||||
return self._hits_to_injection_blocks(hits)
|
||||
|
||||
def retrieve_for_expense_case(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scene: str,
|
||||
policy_ref: str,
|
||||
rule_version: str,
|
||||
query: str,
|
||||
top_k: int = MAX_HISTORICAL_SAMPLES,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""返回仅作建议的历史案例,并以关系库状态做二次授权校验。"""
|
||||
|
||||
tenant = str(tenant_id or "").strip()
|
||||
normalized_scene = str(scene or "").strip()
|
||||
normalized_policy = str(policy_ref or "").strip()
|
||||
normalized_version = str(rule_version or "").strip()
|
||||
if not tenant:
|
||||
raise ValueError("tenant_id is required for historical case retrieval")
|
||||
if not query or not normalized_scene or self._session is None:
|
||||
return []
|
||||
|
||||
hits = self._store.search(
|
||||
query,
|
||||
tenant_id=tenant,
|
||||
scene=normalized_scene,
|
||||
policy_ref=normalized_policy or None,
|
||||
rule_version=normalized_version or None,
|
||||
labels=["confirmed", "false_positive"],
|
||||
top_k=top_k,
|
||||
)
|
||||
# 精确版本不足时补检同规则旧版本,但输出会显式标记为 stale,绝不自动执行。
|
||||
if normalized_version and len(hits) < top_k:
|
||||
fallback_hits = self._store.search(
|
||||
query,
|
||||
tenant_id=tenant,
|
||||
scene=normalized_scene,
|
||||
policy_ref=normalized_policy or None,
|
||||
labels=["confirmed", "false_positive"],
|
||||
top_k=top_k * 2,
|
||||
)
|
||||
seen = {str(item.get("sample_id") or "") for item in hits}
|
||||
hits.extend(
|
||||
item for item in fallback_hits if str(item.get("sample_id") or "") not in seen
|
||||
)
|
||||
return self._validated_expense_case_hits(
|
||||
hits[: top_k * 2],
|
||||
tenant_id=tenant,
|
||||
scene=normalized_scene,
|
||||
policy_ref=normalized_policy,
|
||||
rule_version=normalized_version,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
||||
def _validated_expense_case_hits(
|
||||
self,
|
||||
hits: list[dict[str, Any]],
|
||||
*,
|
||||
tenant_id: str,
|
||||
scene: str,
|
||||
policy_ref: str,
|
||||
rule_version: str,
|
||||
top_k: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
sample_ids = [str(hit.get("sample_id") or "") for hit in hits]
|
||||
sample_ids = [sample_id for sample_id in sample_ids if sample_id]
|
||||
if not sample_ids or self._session is None:
|
||||
return []
|
||||
conditions = [
|
||||
FewShotSample.id.in_(sample_ids),
|
||||
FewShotSample.tenant_id == tenant_id,
|
||||
FewShotSample.scene == scene,
|
||||
FewShotSample.status == "active",
|
||||
]
|
||||
if policy_ref:
|
||||
conditions.append(FewShotSample.policy_ref == policy_ref)
|
||||
samples = {
|
||||
item.id: item
|
||||
for item in self._session.scalars(select(FewShotSample).where(*conditions)).all()
|
||||
}
|
||||
result: list[dict[str, Any]] = []
|
||||
emitted_sample_ids: set[str] = set()
|
||||
for hit in hits:
|
||||
sample_id = str(hit.get("sample_id") or "")
|
||||
sample = samples.get(sample_id)
|
||||
if sample is None or sample_id in emitted_sample_ids:
|
||||
continue
|
||||
version_matches = not rule_version or sample.rule_version == rule_version
|
||||
result.append(
|
||||
{
|
||||
"source": "historical_case",
|
||||
"advisory_only": True,
|
||||
"sample_id": sample.id,
|
||||
"label": sample.label,
|
||||
"score": round(float(hit.get("score") or 0.0), 4),
|
||||
"scene": sample.scene,
|
||||
"policy_ref": sample.policy_ref,
|
||||
"rule_version": sample.rule_version,
|
||||
"version_status": "matched" if version_matches else "stale",
|
||||
"stale": not version_matches,
|
||||
"conclusion": sample.conclusion_text[:SINGLE_SAMPLE_MAX_CHARS],
|
||||
"evidence": {
|
||||
"risk_type": sample.risk_type,
|
||||
"risk_level": sample.risk_level,
|
||||
"payload": sample.payload_json or {},
|
||||
},
|
||||
}
|
||||
)
|
||||
emitted_sample_ids.add(sample_id)
|
||||
if len(result) >= top_k:
|
||||
break
|
||||
return result
|
||||
|
||||
def _build_case_text(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -20,6 +20,17 @@ from app.services.knowledge_rag import _resolve_default_qdrant_url
|
||||
logger = get_logger("app.services.few_shot_store")
|
||||
|
||||
FEW_SHOT_COLLECTION = "few_shot_samples"
|
||||
FEW_SHOT_VECTOR_NAMESPACE = uuid.UUID("0ecb5868-cf75-47c1-b3bf-81e4a6ce1df3")
|
||||
|
||||
|
||||
def stable_vector_id(*, tenant_id: str, sample_id: str) -> str:
|
||||
"""同一租户样本始终映射到同一个 Qdrant point。"""
|
||||
|
||||
tenant = str(tenant_id or "").strip()
|
||||
sample = str(sample_id or "").strip()
|
||||
if not tenant or not sample:
|
||||
raise ValueError("tenant_id and sample_id are required")
|
||||
return str(uuid.uuid5(FEW_SHOT_VECTOR_NAMESPACE, f"{tenant}:{sample}"))
|
||||
|
||||
|
||||
def _resolve_qdrant_config() -> tuple[str, str]:
|
||||
@@ -73,26 +84,29 @@ class FewShotStore:
|
||||
|
||||
try:
|
||||
client.get_collection(FEW_SHOT_COLLECTION)
|
||||
self._ensured = True
|
||||
return True
|
||||
except UnexpectedResponse as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
# collection 不存在则创建
|
||||
dim = self._embedding_provider.dimension()
|
||||
dim = self._embedding_provider.dimension()
|
||||
from qdrant_client.http.models import Distance, VectorParams
|
||||
|
||||
client.create_collection(
|
||||
collection_name=FEW_SHOT_COLLECTION,
|
||||
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
|
||||
)
|
||||
logger.info("few-shot collection 创建成功 dim=%s", dim)
|
||||
|
||||
# 老 collection 也要补齐过滤索引,不能只在首次建表时创建。
|
||||
from qdrant_client.http.models import (
|
||||
Distance,
|
||||
VectorParams,
|
||||
PayloadSchemaType,
|
||||
)
|
||||
|
||||
client.create_collection(
|
||||
collection_name=FEW_SHOT_COLLECTION,
|
||||
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
|
||||
)
|
||||
for field, field_type in [
|
||||
("sample_id", PayloadSchemaType.KEYWORD),
|
||||
("tenant_id", PayloadSchemaType.KEYWORD),
|
||||
("scene", PayloadSchemaType.KEYWORD),
|
||||
("policy_ref", PayloadSchemaType.KEYWORD),
|
||||
("rule_version", PayloadSchemaType.KEYWORD),
|
||||
("label", PayloadSchemaType.KEYWORD),
|
||||
("domain", PayloadSchemaType.KEYWORD),
|
||||
("risk_type", PayloadSchemaType.KEYWORD),
|
||||
@@ -107,7 +121,6 @@ class FewShotStore:
|
||||
except Exception:
|
||||
logger.debug("payload index 创建跳过 field=%s", field, exc_info=True)
|
||||
self._ensured = True
|
||||
logger.info("few-shot collection 创建成功 dim=%s", dim)
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("few-shot collection 初始化失败,本轮操作跳过", exc_info=True)
|
||||
@@ -116,18 +129,31 @@ class FewShotStore:
|
||||
def upsert(self, sample: Any) -> str | None:
|
||||
"""把一条样本向量化并写入 Qdrant,返回 vector_id,失败返回 None。"""
|
||||
|
||||
tenant_id = str(getattr(sample, "tenant_id", "") or "").strip()
|
||||
sample_id = str(getattr(sample, "id", "") or "").strip()
|
||||
if not tenant_id or not sample_id:
|
||||
logger.warning("few-shot upsert 缺少 tenant_id/sample_id,已拒绝")
|
||||
return None
|
||||
if not self._ensure_collection():
|
||||
return None
|
||||
client = self._client
|
||||
try:
|
||||
vector = self._embedding_provider.embed([sample.case_text])[0]
|
||||
except Exception:
|
||||
logger.warning("few-shot embedding 失败 sample_key=%s", getattr(sample, "sample_key", ""), exc_info=True)
|
||||
logger.warning(
|
||||
"few-shot embedding 失败 sample_key=%s",
|
||||
getattr(sample, "sample_key", ""),
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
vector_id = uuid.uuid4().hex
|
||||
vector_id = stable_vector_id(tenant_id=tenant_id, sample_id=sample_id)
|
||||
previous_vector_id = str(getattr(sample, "vector_id", "") or "").strip()
|
||||
payload = {
|
||||
"sample_id": sample.id,
|
||||
"sample_id": sample_id,
|
||||
"tenant_id": tenant_id,
|
||||
"scene": sample.scene,
|
||||
"policy_ref": getattr(sample, "policy_ref", "") or "",
|
||||
"rule_version": getattr(sample, "rule_version", "") or "",
|
||||
"label": sample.label,
|
||||
"domain": sample.domain,
|
||||
"risk_type": sample.risk_type,
|
||||
@@ -137,25 +163,51 @@ class FewShotStore:
|
||||
"payload_json": sample.payload_json,
|
||||
}
|
||||
try:
|
||||
points = [{"id": vector_id, "vector": vector, "payload": payload}]
|
||||
if previous_vector_id and previous_vector_id != vector_id:
|
||||
# 先用最新判定覆盖旧 point,避免后续删除短暂失败时暴露陈旧标签。
|
||||
points.append({"id": previous_vector_id, "vector": vector, "payload": payload})
|
||||
client.upsert(
|
||||
collection_name=FEW_SHOT_COLLECTION,
|
||||
points=[{"id": vector_id, "vector": vector, "payload": payload}],
|
||||
points=points,
|
||||
)
|
||||
if previous_vector_id and previous_vector_id != vector_id:
|
||||
try:
|
||||
client.delete(
|
||||
collection_name=FEW_SHOT_COLLECTION,
|
||||
points_selector=[previous_vector_id],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"few-shot 旧向量清理失败,已保留同内容副本 vector_id=%s",
|
||||
previous_vector_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return vector_id
|
||||
except Exception:
|
||||
logger.warning("few-shot upsert 失败 sample_key=%s", getattr(sample, "sample_key", ""), exc_info=True)
|
||||
logger.warning(
|
||||
"few-shot upsert 失败 sample_key=%s",
|
||||
getattr(sample, "sample_key", ""),
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def search(
|
||||
self,
|
||||
case_text: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scene: str | None = None,
|
||||
policy_ref: str | None = None,
|
||||
rule_version: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
top_k: int = 3,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按 case_text 检索相似样本,可按 scene/label 过滤。失败返回空列表。"""
|
||||
|
||||
tenant = str(tenant_id or "").strip()
|
||||
if not tenant:
|
||||
raise ValueError("tenant_id is required for few-shot search")
|
||||
if not case_text or not self._ensure_collection():
|
||||
return []
|
||||
client = self._client
|
||||
@@ -164,9 +216,16 @@ class FewShotStore:
|
||||
except Exception:
|
||||
logger.warning("few-shot 检索 embedding 失败", exc_info=True)
|
||||
return []
|
||||
must: list[dict[str, Any]] = [{"key": "status", "match": {"value": "active"}}]
|
||||
must: list[dict[str, Any]] = [
|
||||
{"key": "tenant_id", "match": {"value": tenant}},
|
||||
{"key": "status", "match": {"value": "active"}},
|
||||
]
|
||||
if scene:
|
||||
must.append({"key": "scene", "match": {"value": scene}})
|
||||
if policy_ref:
|
||||
must.append({"key": "policy_ref", "match": {"value": policy_ref}})
|
||||
if rule_version:
|
||||
must.append({"key": "rule_version", "match": {"value": rule_version}})
|
||||
if labels:
|
||||
must.append({"key": "label", "match": {"any": labels}})
|
||||
try:
|
||||
@@ -188,6 +247,10 @@ class FewShotStore:
|
||||
hits.append(
|
||||
{
|
||||
"sample_id": payload.get("sample_id"),
|
||||
"tenant_id": payload.get("tenant_id"),
|
||||
"scene": payload.get("scene"),
|
||||
"policy_ref": payload.get("policy_ref") or "",
|
||||
"rule_version": payload.get("rule_version") or "",
|
||||
"score": float(getattr(point, "score", 0.0)),
|
||||
"label": payload.get("label"),
|
||||
"domain": payload.get("domain"),
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.core.logging import get_logger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
logger = get_logger("app.services.hermes_risk_scanner")
|
||||
@@ -38,43 +39,60 @@ class HermesRiskScannerService:
|
||||
logger.info(f"Fetched {len(claims)} claims to analyze.")
|
||||
observation_service = RiskObservationService(self.db)
|
||||
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in claims],
|
||||
target_claim_ids={claim.id for claim in claims},
|
||||
history_stats=observation_service.build_history_stats(
|
||||
expense_types={str(claim.expense_type or "") for claim in claims},
|
||||
),
|
||||
)
|
||||
)
|
||||
claims_by_id = {claim.id: claim for claim in claims}
|
||||
|
||||
for observation in result.observations:
|
||||
claim = claims_by_id.get(observation.claim_id)
|
||||
if claim is None:
|
||||
continue
|
||||
observation_service.upsert_observation(
|
||||
observation,
|
||||
run_id=run_id,
|
||||
execution_log_id=log_id,
|
||||
)
|
||||
claim.hermes_risk_flag = True
|
||||
claim.risk_flags_json = self._append_algorithm_flag(claim, observation.as_dict())
|
||||
|
||||
if log_id:
|
||||
self.db.add(
|
||||
HermesRiskReport(
|
||||
claim_id=observation.claim_id,
|
||||
execution_log_id=log_id,
|
||||
risk_level=observation.risk_level,
|
||||
risk_type=observation.risk_signal,
|
||||
risk_description=observation.description,
|
||||
related_claim_ids=[
|
||||
observation.claim_id,
|
||||
*observation.similar_case_claim_ids,
|
||||
],
|
||||
)
|
||||
observation_count = 0
|
||||
graph_node_count = 0
|
||||
graph_edge_count = 0
|
||||
for tenant_id, tenant_claims in self._group_claims_by_tenant(claims).items():
|
||||
result = evaluate_financial_risk_graph(
|
||||
RiskGraphEvaluationContext(
|
||||
claims=[
|
||||
RiskGraphClaimSnapshot.from_orm(claim)
|
||||
for claim in tenant_claims
|
||||
],
|
||||
target_claim_ids={claim.id for claim in tenant_claims},
|
||||
history_stats=observation_service.build_history_stats(
|
||||
tenant_id=tenant_id,
|
||||
expense_types={
|
||||
str(claim.expense_type or "") for claim in tenant_claims
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
claims_by_id = {claim.id: claim for claim in tenant_claims}
|
||||
observation_count += len(result.observations)
|
||||
graph_node_count += len(result.nodes)
|
||||
graph_edge_count += len(result.edges)
|
||||
|
||||
for observation in result.observations:
|
||||
claim = claims_by_id.get(observation.claim_id)
|
||||
if claim is None:
|
||||
continue
|
||||
observation_service.upsert_observation(
|
||||
observation,
|
||||
tenant_id=tenant_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=log_id,
|
||||
)
|
||||
claim.hermes_risk_flag = True
|
||||
claim.risk_flags_json = self._append_algorithm_flag(
|
||||
claim,
|
||||
observation.as_dict(),
|
||||
)
|
||||
|
||||
if log_id:
|
||||
self.db.add(
|
||||
HermesRiskReport(
|
||||
claim_id=observation.claim_id,
|
||||
execution_log_id=log_id,
|
||||
risk_level=observation.risk_level,
|
||||
risk_type=observation.risk_signal,
|
||||
risk_description=observation.description,
|
||||
related_claim_ids=[
|
||||
observation.claim_id,
|
||||
*observation.similar_case_claim_ids,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for claim in claims:
|
||||
@@ -83,15 +101,28 @@ class HermesRiskScannerService:
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"Hermes risk graph scan completed. Found %s observations.",
|
||||
len(result.observations),
|
||||
observation_count,
|
||||
)
|
||||
return {
|
||||
"scanned_claim_count": len(claims),
|
||||
"risk_observation_count": len(result.observations),
|
||||
"graph_node_count": len(result.nodes),
|
||||
"graph_edge_count": len(result.edges),
|
||||
"risk_observation_count": observation_count,
|
||||
"graph_node_count": graph_node_count,
|
||||
"graph_edge_count": graph_edge_count,
|
||||
}
|
||||
|
||||
def _group_claims_by_tenant(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
) -> dict[str, list[ExpenseClaim]]:
|
||||
grouped: dict[str, list[ExpenseClaim]] = {}
|
||||
for claim in claims:
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id(
|
||||
self.db,
|
||||
claim.id,
|
||||
)
|
||||
grouped.setdefault(tenant_id, []).append(claim)
|
||||
return grouped
|
||||
|
||||
def _fetch_unscanned_claims(self) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
|
||||
82
server/src/app/services/organization_memory_locks.py
Normal file
82
server/src/app/services/organization_memory_locks.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from hashlib import sha256
|
||||
from threading import Lock, RLock
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
_FALLBACK_LOCKS_GUARD = Lock()
|
||||
_FALLBACK_LOCKS: dict[str, RLock] = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def organization_memory_operation_locks(
|
||||
db: Session,
|
||||
*lock_keys: str,
|
||||
) -> Iterator[None]:
|
||||
"""串行化组织记忆作用域和幂等键,锁的生命周期覆盖当前事务。"""
|
||||
|
||||
normalized_keys = sorted({str(key) for key in lock_keys if str(key)})
|
||||
bind = db.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
for lock_key in normalized_keys:
|
||||
db.execute(
|
||||
select(
|
||||
func.pg_advisory_xact_lock(
|
||||
organization_memory_advisory_lock_id(lock_key)
|
||||
)
|
||||
)
|
||||
)
|
||||
yield
|
||||
return
|
||||
|
||||
# SQLite 等方言没有事务级 advisory lock。进程内锁配合数据库唯一索引
|
||||
# 提供安全退化,保证测试与单进程部署不会出现空集合竞态。
|
||||
fallback_locks = [_fallback_lock(lock_key) for lock_key in normalized_keys]
|
||||
for fallback_lock in fallback_locks:
|
||||
fallback_lock.acquire()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for fallback_lock in reversed(fallback_locks):
|
||||
fallback_lock.release()
|
||||
|
||||
|
||||
def organization_memory_scope_lock_key(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scene: str,
|
||||
field_key: str,
|
||||
) -> str:
|
||||
return "|".join(
|
||||
(
|
||||
"organization-memory-scope",
|
||||
tenant_id,
|
||||
scope_type,
|
||||
scope_id,
|
||||
scene,
|
||||
field_key,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def organization_memory_request_lock_key(tenant_id: str, request_id: str) -> str:
|
||||
return f"organization-memory-request|{tenant_id}|{request_id}"
|
||||
|
||||
|
||||
def _fallback_lock(lock_key: str) -> RLock:
|
||||
with _FALLBACK_LOCKS_GUARD:
|
||||
return _FALLBACK_LOCKS.setdefault(lock_key, RLock())
|
||||
|
||||
|
||||
def organization_memory_advisory_lock_id(lock_key: str) -> int:
|
||||
return int.from_bytes(
|
||||
sha256(lock_key.encode("utf-8")).digest()[:8],
|
||||
byteorder="big",
|
||||
signed=True,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from app.algorithem.risk_graph import RiskHistoryStats, RiskObservationDraft
|
||||
from app.core.logging import get_logger
|
||||
from app.db.base import Base
|
||||
from app.models.expense_case import ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.schemas.risk_observation import (
|
||||
@@ -34,6 +35,7 @@ FEEDBACK_STATUS_MAP = {
|
||||
"ignore": ("ignored", "ignored"),
|
||||
"resolve": ("resolved", "resolved"),
|
||||
}
|
||||
DEFAULT_TENANT_ID = "default"
|
||||
|
||||
|
||||
class RiskObservationService:
|
||||
@@ -61,6 +63,7 @@ class RiskObservationService:
|
||||
self,
|
||||
observation: RiskObservationDraft | dict[str, Any],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
execution_log_id: str | None = None,
|
||||
) -> RiskObservation:
|
||||
@@ -73,12 +76,22 @@ class RiskObservationService:
|
||||
observation_key = str(payload.get("observation_key") or "").strip()
|
||||
if not observation_key:
|
||||
raise ValueError("Risk observation requires observation_key.")
|
||||
normalized_tenant_id = self._resolve_tenant_id(
|
||||
tenant_id=tenant_id or _optional_text(payload.get("tenant_id")),
|
||||
claim_id=_optional_text(payload.get("claim_id")),
|
||||
)
|
||||
|
||||
item = self.db.scalar(
|
||||
select(RiskObservation).where(RiskObservation.observation_key == observation_key)
|
||||
select(RiskObservation).where(
|
||||
RiskObservation.tenant_id == normalized_tenant_id,
|
||||
RiskObservation.observation_key == observation_key,
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
item = RiskObservation(observation_key=observation_key)
|
||||
item = RiskObservation(
|
||||
tenant_id=normalized_tenant_id,
|
||||
observation_key=observation_key,
|
||||
)
|
||||
self.db.add(item)
|
||||
|
||||
item.subject_type = _text(payload.get("subject_type"))
|
||||
@@ -118,9 +131,14 @@ class RiskObservationService:
|
||||
claim: ExpenseClaim,
|
||||
flags: list[dict[str, Any]],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
execution_log_id: str | None = None,
|
||||
) -> list[RiskObservation]:
|
||||
normalized_tenant_id = self._resolve_tenant_id(
|
||||
tenant_id=tenant_id,
|
||||
claim_id=claim.id,
|
||||
)
|
||||
observations: list[RiskObservation] = []
|
||||
for flag in flags:
|
||||
if not isinstance(flag, dict):
|
||||
@@ -187,6 +205,7 @@ class RiskObservationService:
|
||||
"action": _text(flag.get("action")),
|
||||
},
|
||||
},
|
||||
tenant_id=normalized_tenant_id,
|
||||
run_id=run_id,
|
||||
execution_log_id=execution_log_id,
|
||||
)
|
||||
@@ -196,6 +215,7 @@ class RiskObservationService:
|
||||
def build_history_stats(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
risk_signals: set[str] | None = None,
|
||||
expense_types: set[str] | None = None,
|
||||
limit: int = 2000,
|
||||
@@ -204,6 +224,7 @@ class RiskObservationService:
|
||||
stmt = (
|
||||
select(RiskObservation, ExpenseClaim.expense_type)
|
||||
.outerjoin(ExpenseClaim, RiskObservation.claim_id == ExpenseClaim.id)
|
||||
.where(RiskObservation.tenant_id == _normalize_tenant_id(tenant_id))
|
||||
.order_by(RiskObservation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
@@ -238,6 +259,7 @@ class RiskObservationService:
|
||||
def list_observations(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
claim_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
execution_log_id: str | None = None,
|
||||
@@ -249,7 +271,7 @@ class RiskObservationService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[RiskObservation], int]:
|
||||
self.ensure_storage_ready()
|
||||
conditions = []
|
||||
conditions = [RiskObservation.tenant_id == _normalize_tenant_id(tenant_id)]
|
||||
if claim_id:
|
||||
conditions.append(RiskObservation.claim_id == claim_id)
|
||||
if run_id:
|
||||
@@ -270,31 +292,52 @@ class RiskObservationService:
|
||||
RiskObservation.risk_score.desc(),
|
||||
RiskObservation.created_at.desc(),
|
||||
)
|
||||
if conditions:
|
||||
count_stmt = count_stmt.where(*conditions)
|
||||
stmt = stmt.where(*conditions)
|
||||
count_stmt = count_stmt.where(*conditions)
|
||||
stmt = stmt.where(*conditions)
|
||||
|
||||
total = int(self.db.scalar(count_stmt) or 0)
|
||||
items = list(self.db.scalars(stmt.offset(offset).limit(limit)).all())
|
||||
return items, total
|
||||
|
||||
def get_observation(self, observation_key_or_id: str) -> RiskObservation | None:
|
||||
def get_observation(
|
||||
self,
|
||||
observation_key_or_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> RiskObservation | None:
|
||||
self.ensure_storage_ready()
|
||||
value = str(observation_key_or_id or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return self.db.scalar(
|
||||
select(RiskObservation).where(
|
||||
(RiskObservation.observation_key == value) | (RiskObservation.id == value)
|
||||
RiskObservation.tenant_id == _normalize_tenant_id(tenant_id),
|
||||
(RiskObservation.observation_key == value) | (RiskObservation.id == value),
|
||||
)
|
||||
)
|
||||
|
||||
def list_claim_observations(self, claim_id: str) -> list[RiskObservation]:
|
||||
items, _ = self.list_observations(claim_id=claim_id, limit=100, offset=0)
|
||||
def list_claim_observations(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[RiskObservation]:
|
||||
items, _ = self.list_observations(
|
||||
tenant_id=tenant_id,
|
||||
claim_id=claim_id,
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
return items
|
||||
|
||||
def list_execution_log_observations(self, execution_log_id: str) -> list[RiskObservation]:
|
||||
def list_execution_log_observations(
|
||||
self,
|
||||
execution_log_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[RiskObservation]:
|
||||
items, _ = self.list_observations(
|
||||
tenant_id=tenant_id,
|
||||
execution_log_id=execution_log_id,
|
||||
limit=200,
|
||||
offset=0,
|
||||
@@ -305,9 +348,15 @@ class RiskObservationService:
|
||||
self,
|
||||
observation_key_or_id: str,
|
||||
payload: RiskObservationFeedbackCreate,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
actor: str | None = None,
|
||||
) -> RiskObservationFeedback:
|
||||
self.ensure_storage_ready()
|
||||
observation = self.get_observation(observation_key_or_id)
|
||||
observation = self.get_observation(
|
||||
observation_key_or_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if observation is None:
|
||||
raise LookupError("Risk observation not found.")
|
||||
|
||||
@@ -315,7 +364,7 @@ class RiskObservationService:
|
||||
observation_id=observation.id,
|
||||
feedback_type=payload.feedback_type,
|
||||
action=payload.action or "",
|
||||
actor=payload.actor or "",
|
||||
actor=_text(actor) or "system",
|
||||
comment=payload.comment,
|
||||
payload_json=payload.payload_json,
|
||||
)
|
||||
@@ -336,7 +385,8 @@ class RiskObservationService:
|
||||
) -> None:
|
||||
"""人工确认/误报后把样本沉淀进 few-shot 池,任何失败都不影响主流程。"""
|
||||
|
||||
if os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true").strip().lower() in {"0", "false", "no"}:
|
||||
few_shot_enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true")
|
||||
if few_shot_enabled.strip().lower() in {"0", "false", "no"}:
|
||||
return
|
||||
if observation.feedback_status not in {"confirmed", "false_positive"}:
|
||||
return
|
||||
@@ -350,15 +400,20 @@ class RiskObservationService:
|
||||
def summarize_dashboard(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
window_days: int = 30,
|
||||
limit: int = 500,
|
||||
) -> RiskObservationDashboardRead:
|
||||
self.ensure_storage_ready()
|
||||
normalized_tenant_id = _normalize_tenant_id(tenant_id)
|
||||
since = datetime.now(UTC) - timedelta(days=window_days)
|
||||
stmt = (
|
||||
select(RiskObservation)
|
||||
.options(joinedload(RiskObservation.claim))
|
||||
.where(RiskObservation.created_at >= since)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant_id,
|
||||
RiskObservation.created_at >= since,
|
||||
)
|
||||
.order_by(RiskObservation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
@@ -371,7 +426,14 @@ class RiskObservationService:
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(RiskObservationFeedback)
|
||||
.where(RiskObservationFeedback.created_at >= since)
|
||||
.join(
|
||||
RiskObservation,
|
||||
RiskObservation.id == RiskObservationFeedback.observation_id,
|
||||
)
|
||||
.where(
|
||||
RiskObservation.tenant_id == normalized_tenant_id,
|
||||
RiskObservationFeedback.created_at >= since,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
@@ -435,6 +497,28 @@ class RiskObservationService:
|
||||
][:10],
|
||||
)
|
||||
|
||||
def _resolve_tenant_id(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
claim_id: str | None,
|
||||
) -> str:
|
||||
explicit_tenant_id = str(tenant_id or "").strip()
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
linked_tenant_id = self.db.scalar(
|
||||
select(ExpenseCaseLink.tenant_id).where(
|
||||
ExpenseCaseLink.resource_type == "expense_claim",
|
||||
ExpenseCaseLink.resource_id == normalized_claim_id,
|
||||
)
|
||||
) if normalized_claim_id else None
|
||||
claim_tenant_id = _normalize_tenant_id(linked_tenant_id)
|
||||
if explicit_tenant_id:
|
||||
normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id)
|
||||
if linked_tenant_id and claim_tenant_id != normalized_tenant_id:
|
||||
raise PermissionError("Risk observation tenant does not match claim tenant.")
|
||||
return normalized_tenant_id
|
||||
return claim_tenant_id
|
||||
|
||||
|
||||
def _count_by(items: list[RiskObservation], field: str) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
@@ -537,7 +621,8 @@ def _supplier_names(item: RiskObservation) -> list[str]:
|
||||
names.append(text.split(":", 1)[1] or text)
|
||||
for evidence in item.evidence_json or []:
|
||||
if isinstance(evidence, dict):
|
||||
metadata = evidence.get("metadata") if isinstance(evidence.get("metadata"), dict) else {}
|
||||
metadata_value = evidence.get("metadata")
|
||||
metadata = metadata_value if isinstance(metadata_value, dict) else {}
|
||||
for key in ("supplier_name", "vendor_name", "merchant_name", "supplier", "vendor"):
|
||||
name = _text(evidence.get(key)) or _text(metadata.get(key))
|
||||
if name:
|
||||
@@ -603,6 +688,10 @@ def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _normalize_tenant_id(value: Any) -> str:
|
||||
return _text(value) or DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
def _canonical_key(value: Any) -> str:
|
||||
return "_".join(_text(value).lower().split())
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_claim_risk_stage import infer_risk_domain
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_generation_ontology import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
DOMAIN_FIELD_PREFIXES,
|
||||
@@ -26,16 +29,13 @@ from app.services.risk_rule_generation_ontology import (
|
||||
RiskRuleField,
|
||||
)
|
||||
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload
|
||||
from app.services.risk_rule_generation_semantics import (
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPE,
|
||||
CITY_CONSISTENCY_SEMANTIC_TYPES,
|
||||
build_city_consistency_draft,
|
||||
build_city_consistency_params,
|
||||
)
|
||||
from app.services.risk_rule_generation_semantic_plan import unwrap_semantic_plan_payload
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
@@ -57,6 +57,7 @@ class RiskRuleGenerationService:
|
||||
self,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
@@ -81,6 +82,7 @@ class RiskRuleGenerationService:
|
||||
created_at = datetime.now(UTC)
|
||||
fields = self._resolve_fields(natural_language, domain=domain)
|
||||
draft = self._compile_with_model(
|
||||
tenant_id=tenant_id,
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
@@ -174,6 +176,7 @@ class RiskRuleGenerationService:
|
||||
"ontology_signal": payload.get("ontology_signal"),
|
||||
"evaluator": payload.get("evaluator"),
|
||||
"generated_by": "natural_language",
|
||||
"tenant_id": str(tenant_id or "").strip(),
|
||||
"source_ref": "自然语言风险规则",
|
||||
"last_operation": {
|
||||
"action": "create",
|
||||
@@ -217,6 +220,7 @@ class RiskRuleGenerationService:
|
||||
def _compile_with_model(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
natural_language: str,
|
||||
domain: str,
|
||||
business_stage: str,
|
||||
@@ -235,6 +239,7 @@ class RiskRuleGenerationService:
|
||||
for item in fields
|
||||
]
|
||||
few_shot_samples = self._retrieve_few_shot_samples(
|
||||
tenant_id=tenant_id,
|
||||
domain=domain,
|
||||
natural_language=natural_language,
|
||||
)
|
||||
@@ -271,6 +276,7 @@ class RiskRuleGenerationService:
|
||||
def _retrieve_few_shot_samples(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
domain: str,
|
||||
natural_language: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -280,11 +286,15 @@ class RiskRuleGenerationService:
|
||||
|
||||
if os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true").strip().lower() in {"0", "false", "no"}:
|
||||
return []
|
||||
normalized_tenant_id = str(tenant_id or "").strip()
|
||||
if not normalized_tenant_id:
|
||||
return []
|
||||
try:
|
||||
from app.services.few_shot_retrieval import FewShotRetriever
|
||||
|
||||
retriever = FewShotRetriever.from_session(self.db)
|
||||
return retriever.retrieve_for_risk_rule_generation(
|
||||
tenant_id=normalized_tenant_id,
|
||||
domain=domain,
|
||||
natural_language=natural_language,
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ class RiskRuleGenerationJobService:
|
||||
self,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
@@ -96,6 +97,7 @@ class RiskRuleGenerationJobService:
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"generated_by": "natural_language",
|
||||
"tenant_id": str(tenant_id or "").strip(),
|
||||
"generation_status": AgentAssetStatus.GENERATING.value,
|
||||
"generation_started_at": created_at.isoformat(),
|
||||
"generation_request": self._dump_generation_request(body),
|
||||
@@ -130,6 +132,7 @@ class RiskRuleGenerationJobService:
|
||||
asset_id: str,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
@@ -137,7 +140,13 @@ class RiskRuleGenerationJobService:
|
||||
asset = self.db.get(AgentAsset, asset_id)
|
||||
if asset is None or asset.status != AgentAssetStatus.GENERATING.value:
|
||||
return
|
||||
self._complete_rule_asset(asset, body, actor=actor, request_id=request_id)
|
||||
self._complete_rule_asset(
|
||||
asset,
|
||||
body,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - 后台任务必须把失败写回资产状态
|
||||
self.mark_generation_failed(
|
||||
asset_id,
|
||||
@@ -190,6 +199,7 @@ class RiskRuleGenerationJobService:
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleGenerateRequest,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
@@ -205,6 +215,7 @@ class RiskRuleGenerationJobService:
|
||||
fields = self.generator._resolve_fields(natural_language, domain=domain)
|
||||
|
||||
draft = self.generator._compile_with_model(
|
||||
tenant_id=tenant_id,
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
@@ -282,6 +293,7 @@ class RiskRuleGenerationJobService:
|
||||
"ontology_signal": payload.get("ontology_signal"),
|
||||
"evaluator": payload.get("evaluator"),
|
||||
"generated_by": "natural_language",
|
||||
"tenant_id": str(tenant_id or "").strip(),
|
||||
"source_ref": "自然语言风险规则",
|
||||
"generation_status": "completed",
|
||||
"generation_completed_at": datetime.now(UTC).isoformat(),
|
||||
|
||||
@@ -31,7 +31,11 @@ from app.services.expense_application_draft_events import (
|
||||
ExpenseApplicationDraftEventService,
|
||||
)
|
||||
from app.services.expense_application_learning import ExpenseApplicationLearningService
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_historical_evidence import (
|
||||
build_user_agent_historical_evidence_notice,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
from app.services.user_agent_application_dates import (
|
||||
@@ -884,6 +888,12 @@ class UserAgentApplicationPersistenceMixin:
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
# 非默认租户的费用单必须先建立 tenant-scoped Case Link,后续
|
||||
# submit_claim() 的访问策略才能在同一事务内重新查询到刚创建的记录。
|
||||
ExpenseCaseService(self.db).ensure_case_for_claim(
|
||||
claim,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
if not submit:
|
||||
_, draft_event = draft_event_service.record(
|
||||
payload,
|
||||
@@ -1349,6 +1359,9 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
facts["application_claim_id"] = application_claim.id
|
||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||
facts["historical_case_evidence_notice"] = (
|
||||
build_user_agent_historical_evidence_notice(application_claim)
|
||||
)
|
||||
return UserAgentResponse(
|
||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||
citations=[],
|
||||
@@ -1417,6 +1430,9 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
||||
if step == "submitted":
|
||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||
historical_notice = str(
|
||||
facts.get("historical_case_evidence_notice") or ""
|
||||
).strip()
|
||||
submitted_title = (
|
||||
"申请单据已修改并重新提交,已进入审批流程。"
|
||||
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
|
||||
@@ -1427,6 +1443,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
||||
submitted_title,
|
||||
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
||||
f"申请单号:{application_no}",
|
||||
*([historical_notice] if historical_notice else []),
|
||||
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user