```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
23
app/modules/personalization/__init__.py
Normal file
23
app/modules/personalization/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
PersonalDataErasureRequest,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.services import (
|
||||
ConversationService,
|
||||
PersonalDataErasureService,
|
||||
PersonalizationContextService,
|
||||
PreferenceService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AIConversation",
|
||||
"AIConversationMessage",
|
||||
"ConversationService",
|
||||
"PersonalDataErasureRequest",
|
||||
"PersonalDataErasureService",
|
||||
"PersonalizationContextService",
|
||||
"PreferenceService",
|
||||
"UserPreference",
|
||||
]
|
||||
101
app/modules/personalization/constants.py
Normal file
101
app/modules/personalization/constants.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PreferenceCategory(StrEnum):
|
||||
LANGUAGE = "language"
|
||||
TONE = "tone"
|
||||
DETAIL = "detail"
|
||||
TOPIC = "topic"
|
||||
INTEREST = "interest"
|
||||
|
||||
|
||||
class PreferenceSource(StrEnum):
|
||||
EXPLICIT = "explicit"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
class ConversationRole(StrEnum):
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
|
||||
|
||||
class ConversationChatType(StrEnum):
|
||||
PRIVATE = "private"
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class PersonalizationContextKey(StrEnum):
|
||||
SYSTEM_CONSTRAINTS = "system_constraints"
|
||||
COMPANY_RULES = "company_rules"
|
||||
PERSONAL_RULES = "personal_rules"
|
||||
CURRENT_REQUEST = "current_request"
|
||||
PREFERENCES = "preferences"
|
||||
INTERESTS = "interests"
|
||||
PERSONAL_MEMORY = "personal_memory"
|
||||
CONVERSATION_HISTORY = "conversation_history"
|
||||
|
||||
|
||||
PREFERENCE_CODE_PREFIX = "PREF"
|
||||
CONVERSATION_CODE_PREFIX = "CONV"
|
||||
PREFERENCE_MAX_VALUE_LENGTH = 1000
|
||||
CONVERSATION_MAX_CONTENT_LENGTH = 20_000
|
||||
CONVERSATION_RETENTION_DAYS = 30
|
||||
CONVERSATION_MAX_TURNS = 20
|
||||
CONVERSATION_MAX_MESSAGES = CONVERSATION_MAX_TURNS * 2
|
||||
ERASURE_CONFIRMATION_TTL_MINUTES = 10
|
||||
|
||||
UNAVAILABLE_AI_PROVIDERS = frozenset({"", "noop"})
|
||||
PREFERENCE_SIGNAL_TERMS = (
|
||||
"以后",
|
||||
"记住",
|
||||
"偏好",
|
||||
"喜欢",
|
||||
"希望",
|
||||
"请用",
|
||||
"请保持",
|
||||
"关注",
|
||||
"感兴趣",
|
||||
"prefer",
|
||||
"preference",
|
||||
"i like",
|
||||
"interested in",
|
||||
)
|
||||
|
||||
# These terms identify categories that must never become an inferred personal profile.
|
||||
# General topics such as public market news remain allowed; the financial terms below are
|
||||
# intentionally limited to private account, compensation, and confidential-company facts.
|
||||
SENSITIVE_PREFERENCE_TERMS = (
|
||||
"api key",
|
||||
"api_key",
|
||||
"access token",
|
||||
"access_token",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"密码",
|
||||
"密钥",
|
||||
"令牌",
|
||||
"健康",
|
||||
"病史",
|
||||
"疾病",
|
||||
"诊断",
|
||||
"医疗记录",
|
||||
"宗教",
|
||||
"信仰",
|
||||
"政治立场",
|
||||
"党派",
|
||||
"选举倾向",
|
||||
"性取向",
|
||||
"同性恋",
|
||||
"异性恋",
|
||||
"绩效",
|
||||
"考核结果",
|
||||
"银行账号",
|
||||
"银行卡",
|
||||
"工资",
|
||||
"薪资",
|
||||
"个人收入",
|
||||
"财务秘密",
|
||||
"未公开财务",
|
||||
"保密预算",
|
||||
)
|
||||
131
app/modules/personalization/models.py
Normal file
131
app/modules/personalization/models.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.personalization.constants import (
|
||||
CONVERSATION_CODE_PREFIX,
|
||||
PREFERENCE_CODE_PREFIX,
|
||||
PreferenceSource,
|
||||
)
|
||||
|
||||
|
||||
def _public_code(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid4().hex}"
|
||||
|
||||
|
||||
class UserPreference(Base):
|
||||
__tablename__ = "user_preferences"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"owner_id",
|
||||
"category",
|
||||
"normalized_value",
|
||||
name="uq_user_preference_owner_category_value",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default=lambda: _public_code(PREFERENCE_CODE_PREFIX),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser] = relationship()
|
||||
category: Mapped[str] = mapped_column(String(32), index=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
normalized_value: Mapped[str] = mapped_column(String(1000))
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=PreferenceSource.EXPLICIT,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
|
||||
|
||||
class AIConversation(Base):
|
||||
__tablename__ = "ai_conversations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"owner_id",
|
||||
"chat_type",
|
||||
"chat_key",
|
||||
name="uq_ai_conversation_owner_chat",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default=lambda: _public_code(CONVERSATION_CODE_PREFIX),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser] = relationship()
|
||||
chat_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
chat_key: Mapped[str] = mapped_column(String(256), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
index=True,
|
||||
)
|
||||
messages: Mapped[list["AIConversationMessage"]] = relationship(
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="AIConversationMessage.id",
|
||||
)
|
||||
|
||||
|
||||
class AIConversationMessage(Base):
|
||||
__tablename__ = "ai_conversation_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
conversation: Mapped[AIConversation] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PersonalDataErasureRequest(Base):
|
||||
__tablename__ = "personal_data_erasure_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser] = relationship()
|
||||
token_hash: Mapped[str] = mapped_column(String(64))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
65
app/modules/personalization/schemas.py
Normal file
65
app/modules/personalization/schemas.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.modules.personalization.constants import PreferenceCategory, PreferenceSource
|
||||
|
||||
|
||||
class PreferenceCreate(BaseModel):
|
||||
category: PreferenceCategory
|
||||
value: str = Field(..., min_length=1, max_length=1000)
|
||||
source: PreferenceSource = PreferenceSource.EXPLICIT
|
||||
|
||||
|
||||
class PreferenceUpdate(BaseModel):
|
||||
category: PreferenceCategory | None = None
|
||||
value: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class PreferenceRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
category: str
|
||||
value: str
|
||||
source: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExtractedPreference(BaseModel):
|
||||
category: PreferenceCategory
|
||||
value: str = Field(..., min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class PreferenceExtractionPayload(BaseModel):
|
||||
preferences: list[ExtractedPreference] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class ConversationMessageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ConversationRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
chat_type: str
|
||||
chat_key: str
|
||||
messages: list[ConversationMessageRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ErasureConfirmation(BaseModel):
|
||||
confirmation_code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ErasureResult(BaseModel):
|
||||
anonymous_id: str
|
||||
deleted: dict[str, int] = Field(default_factory=dict)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
15
app/modules/personalization/services/__init__.py
Normal file
15
app/modules/personalization/services/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.modules.personalization.services.context import (
|
||||
PersonalizationContext,
|
||||
PersonalizationContextService,
|
||||
)
|
||||
from app.modules.personalization.services.conversations import ConversationService
|
||||
from app.modules.personalization.services.erasure import PersonalDataErasureService
|
||||
from app.modules.personalization.services.preferences import PreferenceService
|
||||
|
||||
__all__ = [
|
||||
"ConversationService",
|
||||
"PersonalDataErasureService",
|
||||
"PersonalizationContext",
|
||||
"PersonalizationContextService",
|
||||
"PreferenceService",
|
||||
]
|
||||
191
app/modules/personalization/services/context.py
Normal file
191
app/modules/personalization/services/context.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_memory.constants import AIMemoryScope
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.personalization.constants import (
|
||||
PersonalizationContextKey,
|
||||
PreferenceCategory,
|
||||
)
|
||||
from app.modules.personalization.services.conversations import ConversationService
|
||||
from app.modules.personalization.services.preferences import PreferenceService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonalizationContext:
|
||||
"""Ordered, provider-neutral context sections for one AI request."""
|
||||
|
||||
system_constraints: str
|
||||
company_rules: list[dict[str, Any]]
|
||||
personal_rules: list[dict[str, Any]]
|
||||
current_request: str
|
||||
preferences: list[dict[str, Any]]
|
||||
interests: list[dict[str, Any]]
|
||||
personal_memory: list[dict[str, Any]]
|
||||
conversation_history: list[dict[str, Any]]
|
||||
provider_session_id: str | None = field(default=None)
|
||||
|
||||
def as_ordered_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
PersonalizationContextKey.SYSTEM_CONSTRAINTS: self.system_constraints,
|
||||
PersonalizationContextKey.COMPANY_RULES: self.company_rules,
|
||||
PersonalizationContextKey.PERSONAL_RULES: self.personal_rules,
|
||||
PersonalizationContextKey.CURRENT_REQUEST: self.current_request,
|
||||
PersonalizationContextKey.PREFERENCES: self.preferences,
|
||||
PersonalizationContextKey.INTERESTS: self.interests,
|
||||
PersonalizationContextKey.PERSONAL_MEMORY: self.personal_memory,
|
||||
PersonalizationContextKey.CONVERSATION_HISTORY: self.conversation_history,
|
||||
}
|
||||
|
||||
|
||||
class PersonalizationContextService:
|
||||
"""Load only the explicitly requested company and owner-scoped context layers."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.memory = AIMemoryService(db)
|
||||
self.preferences = PreferenceService(db)
|
||||
self.conversations = ConversationService(db)
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
owner_id: int | None,
|
||||
request: str,
|
||||
system_constraints: str,
|
||||
chat_type: str | None = None,
|
||||
chat_key: str | None = None,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
include_company_rules: bool = True,
|
||||
include_personal_context: bool = True,
|
||||
include_history: bool = True,
|
||||
) -> PersonalizationContext:
|
||||
company_rules = (
|
||||
self.memory.active_rules(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
owner_id=None,
|
||||
)
|
||||
if include_company_rules
|
||||
else []
|
||||
)
|
||||
personal_rules: list[dict[str, Any]] = []
|
||||
preference_items: list[dict[str, Any]] = []
|
||||
interests: list[dict[str, Any]] = []
|
||||
personal_memory: list[dict[str, Any]] = []
|
||||
history: list[dict[str, Any]] = []
|
||||
provider_session_id: str | None = None
|
||||
|
||||
if owner_id is not None and include_personal_context:
|
||||
personal_rules = self.memory.active_rules(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
all_preferences = self.preferences.list_preferences(owner_id)
|
||||
interest_categories = {
|
||||
PreferenceCategory.TOPIC,
|
||||
PreferenceCategory.INTEREST,
|
||||
}
|
||||
for preference in all_preferences:
|
||||
if preference["category"] in interest_categories:
|
||||
interests.append(preference)
|
||||
else:
|
||||
preference_items.append(preference)
|
||||
interests.extend(self._watchlist_interests(owner_id))
|
||||
personal_memory = self.memory.recall(
|
||||
query=request,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
actor=actor,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if include_history and chat_type and chat_key:
|
||||
history = self.conversations.history(owner_id, chat_type, chat_key)
|
||||
provider_session_id = self.conversations.provider_session_id(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
|
||||
return PersonalizationContext(
|
||||
system_constraints=system_constraints,
|
||||
company_rules=company_rules,
|
||||
personal_rules=personal_rules,
|
||||
current_request=request,
|
||||
preferences=preference_items,
|
||||
interests=interests,
|
||||
personal_memory=personal_memory,
|
||||
conversation_history=history,
|
||||
provider_session_id=provider_session_id,
|
||||
)
|
||||
|
||||
def build_private_scheduled(
|
||||
self,
|
||||
*,
|
||||
owner_id: int,
|
||||
request: str,
|
||||
system_constraints: str,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
) -> PersonalizationContext:
|
||||
"""Build private scheduled context without company data or conversation history."""
|
||||
|
||||
return self.build(
|
||||
owner_id=owner_id,
|
||||
request=request,
|
||||
system_constraints=system_constraints,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
actor=actor,
|
||||
include_company_rules=False,
|
||||
include_personal_context=True,
|
||||
include_history=False,
|
||||
)
|
||||
|
||||
def build_group_scheduled(
|
||||
self,
|
||||
*,
|
||||
request: str,
|
||||
system_constraints: str,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
) -> PersonalizationContext:
|
||||
"""Build group scheduled context without any creator profile."""
|
||||
|
||||
return self.build(
|
||||
owner_id=None,
|
||||
request=request,
|
||||
system_constraints=system_constraints,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
include_company_rules=True,
|
||||
include_personal_context=False,
|
||||
include_history=False,
|
||||
)
|
||||
|
||||
def _watchlist_interests(self, owner_id: int) -> list[dict[str, Any]]:
|
||||
symbols = self.db.execute(
|
||||
select(MarketWatchlist.symbol)
|
||||
.where(
|
||||
MarketWatchlist.owner_id == owner_id,
|
||||
MarketWatchlist.enabled.is_(True),
|
||||
)
|
||||
.order_by(MarketWatchlist.symbol.asc())
|
||||
).scalars()
|
||||
return [
|
||||
{
|
||||
"category": "watchlist",
|
||||
"value": symbol,
|
||||
"source": "market_watchlist",
|
||||
}
|
||||
for symbol in symbols
|
||||
]
|
||||
323
app/modules/personalization/services/conversations.py
Normal file
323
app/modules/personalization/services/conversations.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, exists, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.personalization.constants import (
|
||||
CONVERSATION_MAX_CONTENT_LENGTH,
|
||||
CONVERSATION_MAX_MESSAGES,
|
||||
CONVERSATION_RETENTION_DAYS,
|
||||
UNAVAILABLE_AI_PROVIDERS,
|
||||
ConversationChatType,
|
||||
ConversationRole,
|
||||
)
|
||||
from app.modules.personalization.models import AIConversation, AIConversationMessage
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""Persist isolated Feishu conversations with bounded history."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def history(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
self.cleanup_expired(owner_id=owner_id)
|
||||
conversation = self._find(owner_id, chat_type_value, chat_key_value)
|
||||
if conversation is None:
|
||||
self.db.commit()
|
||||
return []
|
||||
messages = list(
|
||||
self.db.execute(
|
||||
select(AIConversationMessage)
|
||||
.where(AIConversationMessage.conversation_id == conversation.id)
|
||||
.order_by(
|
||||
AIConversationMessage.created_at.desc(),
|
||||
AIConversationMessage.id.desc(),
|
||||
)
|
||||
.limit(CONVERSATION_MAX_MESSAGES)
|
||||
).scalars()
|
||||
)
|
||||
self.db.commit()
|
||||
messages.reverse()
|
||||
return [_serialize_message(message) for message in messages]
|
||||
|
||||
def record_turn(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
*,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
provider_name: str,
|
||||
ai_available: bool = True,
|
||||
) -> bool:
|
||||
"""Record one complete turn only after a real AI answer succeeds."""
|
||||
|
||||
if not ai_available or provider_name.strip().lower() in UNAVAILABLE_AI_PROVIDERS:
|
||||
return False
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
user_text = _message_content(user_content)
|
||||
assistant_text = _message_content(assistant_content)
|
||||
self.cleanup_expired(owner_id=owner_id)
|
||||
conversation = self._get_or_create(
|
||||
owner_id,
|
||||
chat_type_value,
|
||||
chat_key_value,
|
||||
)
|
||||
now = utc_now()
|
||||
self.db.add_all(
|
||||
[
|
||||
AIConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role=ConversationRole.USER,
|
||||
content=user_text,
|
||||
created_at=now,
|
||||
),
|
||||
AIConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role=ConversationRole.ASSISTANT,
|
||||
content=assistant_text,
|
||||
created_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
conversation.updated_at = now
|
||||
self.db.flush()
|
||||
self._trim(conversation.id)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def reset(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> bool:
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
conversation = self._find(owner_id, chat_type_value, chat_key_value)
|
||||
if conversation is None:
|
||||
return False
|
||||
self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id == conversation.id
|
||||
)
|
||||
)
|
||||
self.db.delete(conversation)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def cleanup_expired(self, owner_id: int | None = None) -> int:
|
||||
"""Stage retention cleanup for one owner or all owners.
|
||||
|
||||
The surrounding operation owns the transaction. Interactive history and
|
||||
write paths use this method before completing their own commit.
|
||||
"""
|
||||
|
||||
return self._cleanup_expired(owner_id)["conversation_messages"]
|
||||
|
||||
def cleanup_expired_globally(self) -> dict[str, int]:
|
||||
"""Delete expired messages for every owner and commit the maintenance run."""
|
||||
|
||||
deleted = self._cleanup_expired(owner_id=None)
|
||||
self.db.commit()
|
||||
return deleted
|
||||
|
||||
def _cleanup_expired(self, owner_id: int | None) -> dict[str, int]:
|
||||
cutoff = utc_now() - timedelta(days=CONVERSATION_RETENTION_DAYS)
|
||||
conversation_ids = select(AIConversation.id)
|
||||
if owner_id is not None:
|
||||
conversation_ids = conversation_ids.where(AIConversation.owner_id == owner_id)
|
||||
message_result = self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id.in_(conversation_ids),
|
||||
AIConversationMessage.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
empty_conversations = delete(AIConversation).where(
|
||||
~exists(
|
||||
select(AIConversationMessage.id).where(
|
||||
AIConversationMessage.conversation_id == AIConversation.id
|
||||
)
|
||||
)
|
||||
)
|
||||
if owner_id is not None:
|
||||
empty_conversations = empty_conversations.where(
|
||||
AIConversation.owner_id == owner_id
|
||||
)
|
||||
conversation_result = self.db.execute(empty_conversations)
|
||||
return {
|
||||
"conversation_messages": max(0, int(message_result.rowcount or 0)),
|
||||
"conversations": max(0, int(conversation_result.rowcount or 0)),
|
||||
}
|
||||
|
||||
def delete_owner_conversations(self, owner_id: int) -> dict[str, int]:
|
||||
conversation_ids = select(AIConversation.id).where(
|
||||
AIConversation.owner_id == owner_id
|
||||
)
|
||||
message_result = self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id.in_(conversation_ids)
|
||||
)
|
||||
)
|
||||
conversation_result = self.db.execute(
|
||||
delete(AIConversation).where(AIConversation.owner_id == owner_id)
|
||||
)
|
||||
return {
|
||||
"conversation_messages": max(0, int(message_result.rowcount or 0)),
|
||||
"conversations": max(0, int(conversation_result.rowcount or 0)),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def provider_session_id(
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> str:
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
digest = sha256(
|
||||
f"{owner_id}\0{chat_type_value}\0{chat_key_value}".encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"feishu-{digest}"
|
||||
|
||||
def _find(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str,
|
||||
chat_key: str,
|
||||
) -> AIConversation | None:
|
||||
return self.db.execute(
|
||||
select(AIConversation).where(
|
||||
AIConversation.owner_id == owner_id,
|
||||
AIConversation.chat_type == chat_type,
|
||||
AIConversation.chat_key == chat_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
def _get_or_create(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str,
|
||||
chat_key: str,
|
||||
) -> AIConversation:
|
||||
existing = self._find(owner_id, chat_type, chat_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
conversation = AIConversation(
|
||||
owner_id=owner_id,
|
||||
chat_type=chat_type,
|
||||
chat_key=chat_key,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(conversation)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
conversation = self._find(owner_id, chat_type, chat_key)
|
||||
if conversation is None:
|
||||
raise
|
||||
return conversation
|
||||
|
||||
def _trim(self, conversation_id: int) -> int:
|
||||
stale_ids = list(
|
||||
self.db.execute(
|
||||
select(AIConversationMessage.id)
|
||||
.where(AIConversationMessage.conversation_id == conversation_id)
|
||||
.order_by(
|
||||
AIConversationMessage.created_at.desc(),
|
||||
AIConversationMessage.id.desc(),
|
||||
)
|
||||
.offset(CONVERSATION_MAX_MESSAGES)
|
||||
).scalars()
|
||||
)
|
||||
if not stale_ids:
|
||||
return 0
|
||||
result = self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.id.in_(stale_ids)
|
||||
)
|
||||
)
|
||||
return max(0, int(result.rowcount or 0))
|
||||
|
||||
|
||||
def _conversation_identity(
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> tuple[int, str, str]:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid conversation owner is required",
|
||||
)
|
||||
raw_chat_type = str(chat_type).strip().lower()
|
||||
aliases = {
|
||||
"p2p": ConversationChatType.PRIVATE,
|
||||
"private": ConversationChatType.PRIVATE,
|
||||
"group": ConversationChatType.GROUP,
|
||||
"group_chat": ConversationChatType.GROUP,
|
||||
}
|
||||
try:
|
||||
chat_type_value = str(aliases[raw_chat_type])
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Unsupported conversation chat type",
|
||||
) from exc
|
||||
chat_key_value = str(chat_key).strip()
|
||||
if not chat_key_value or len(chat_key_value) > 256:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid conversation chat key is required",
|
||||
)
|
||||
return owner_id, chat_type_value, chat_key_value
|
||||
|
||||
|
||||
def _message_content(value: str) -> str:
|
||||
content = str(value).strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Conversation message is required",
|
||||
)
|
||||
if len(content) > CONVERSATION_MAX_CONTENT_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Conversation message is too long",
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def _serialize_message(message: AIConversationMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
}
|
||||
205
app/modules/personalization/services/erasure.py
Normal file
205
app/modules/personalization/services/erasure.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest
|
||||
from secrets import token_hex
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.personalization.constants import ERASURE_CONFIRMATION_TTL_MINUTES
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
PersonalDataErasureRequest,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.schemas import ErasureConfirmation, ErasureResult
|
||||
|
||||
ErasureHook = Callable[
|
||||
[Session, int, str],
|
||||
int | Mapping[str, int] | None,
|
||||
]
|
||||
|
||||
|
||||
class PersonalDataErasureService:
|
||||
"""Issue one-time confirmations and erase owner data in one transaction."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def request_confirmation(
|
||||
self,
|
||||
owner_id: int,
|
||||
*,
|
||||
ttl_minutes: int = ERASURE_CONFIRMATION_TTL_MINUTES,
|
||||
) -> ErasureConfirmation:
|
||||
_validate_owner(owner_id)
|
||||
if ttl_minutes <= 0 or ttl_minutes > 60:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid erasure confirmation lifetime",
|
||||
)
|
||||
confirmation_code = token_hex(4).upper()
|
||||
now = utc_now()
|
||||
expires_at = now + timedelta(minutes=ttl_minutes)
|
||||
record = self.db.execute(
|
||||
select(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.owner_id == owner_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = PersonalDataErasureRequest(
|
||||
owner_id=owner_id,
|
||||
token_hash=_token_hash(owner_id, confirmation_code),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.db.add(record)
|
||||
else:
|
||||
record.token_hash = _token_hash(owner_id, confirmation_code)
|
||||
record.expires_at = expires_at
|
||||
record.updated_at = now
|
||||
self.db.commit()
|
||||
return ErasureConfirmation(
|
||||
confirmation_code=confirmation_code,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def confirm_and_erase(
|
||||
self,
|
||||
owner_id: int,
|
||||
confirmation_code: str,
|
||||
*,
|
||||
before_hooks: Sequence[ErasureHook] = (),
|
||||
extra_hooks: Sequence[ErasureHook] = (),
|
||||
) -> ErasureResult:
|
||||
"""Erase core personal tables and run integration hooks before one commit."""
|
||||
|
||||
_validate_owner(owner_id)
|
||||
request = self.db.execute(
|
||||
select(PersonalDataErasureRequest)
|
||||
.where(PersonalDataErasureRequest.owner_id == owner_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if request is None or request.expires_at <= utc_now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Erasure confirmation is invalid or expired",
|
||||
)
|
||||
supplied_hash = _token_hash(owner_id, confirmation_code.strip().upper())
|
||||
if not compare_digest(supplied_hash, request.token_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Erasure confirmation is invalid or expired",
|
||||
)
|
||||
|
||||
anonymous_id = f"anonymous-{uuid4().hex}"
|
||||
deleted: dict[str, int] = {}
|
||||
extra: dict[str, Any] = {}
|
||||
try:
|
||||
for index, hook in enumerate(before_hooks):
|
||||
_merge_hook_result(
|
||||
hook(self.db, owner_id, anonymous_id),
|
||||
index=index,
|
||||
prefix="before",
|
||||
deleted=deleted,
|
||||
extra=extra,
|
||||
)
|
||||
conversation_ids = select(AIConversation.id).where(
|
||||
AIConversation.owner_id == owner_id
|
||||
)
|
||||
deleted["conversation_messages"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id.in_(conversation_ids)
|
||||
)
|
||||
).rowcount
|
||||
)
|
||||
deleted["conversations"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(AIConversation).where(AIConversation.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
deleted["preferences"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(UserPreference).where(UserPreference.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
deleted["ai_memory"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
deleted["watchlist"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
self.db.delete(request)
|
||||
|
||||
for index, hook in enumerate(extra_hooks):
|
||||
_merge_hook_result(
|
||||
hook(self.db, owner_id, anonymous_id),
|
||||
index=index,
|
||||
prefix="extra",
|
||||
deleted=deleted,
|
||||
extra=extra,
|
||||
)
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
return ErasureResult(
|
||||
anonymous_id=anonymous_id,
|
||||
deleted=deleted,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
def purge_expired_confirmations(self) -> int:
|
||||
result = self.db.execute(
|
||||
delete(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.expires_at <= utc_now()
|
||||
)
|
||||
)
|
||||
count = _row_count(result.rowcount)
|
||||
self.db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def _token_hash(owner_id: int, confirmation_code: str) -> str:
|
||||
return sha256(f"{owner_id}\0{confirmation_code}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _validate_owner(owner_id: int) -> None:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid erasure owner is required",
|
||||
)
|
||||
|
||||
|
||||
def _row_count(value: int | None) -> int:
|
||||
return max(0, int(value or 0))
|
||||
|
||||
|
||||
def _merge_hook_result(
|
||||
hook_result: int | Mapping[str, int] | None,
|
||||
*,
|
||||
index: int,
|
||||
prefix: str,
|
||||
deleted: dict[str, int],
|
||||
extra: dict[str, Any],
|
||||
) -> None:
|
||||
if isinstance(hook_result, Mapping):
|
||||
for key, value in hook_result.items():
|
||||
deleted[str(key)] = int(value)
|
||||
elif isinstance(hook_result, int):
|
||||
deleted[f"{prefix}_{index}"] = hook_result
|
||||
elif hook_result is not None:
|
||||
extra[f"{prefix}_{index}"] = hook_result
|
||||
315
app/modules/personalization/services/preferences.py
Normal file
315
app/modules/personalization/services/preferences.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.personalization.constants import (
|
||||
PREFERENCE_MAX_VALUE_LENGTH,
|
||||
PREFERENCE_SIGNAL_TERMS,
|
||||
SENSITIVE_PREFERENCE_TERMS,
|
||||
UNAVAILABLE_AI_PROVIDERS,
|
||||
PreferenceCategory,
|
||||
PreferenceSource,
|
||||
)
|
||||
from app.modules.personalization.models import UserPreference
|
||||
from app.modules.personalization.schemas import PreferenceExtractionPayload
|
||||
|
||||
|
||||
class PreferenceService:
|
||||
"""Manage explicit and safely extracted preferences inside one owner boundary."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def list_preferences(
|
||||
self,
|
||||
owner_id: int,
|
||||
category: str | PreferenceCategory | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
_validate_owner(owner_id)
|
||||
stmt = (
|
||||
select(UserPreference)
|
||||
.where(UserPreference.owner_id == owner_id)
|
||||
.order_by(UserPreference.category.asc(), UserPreference.id.asc())
|
||||
)
|
||||
if category is not None:
|
||||
stmt = stmt.where(UserPreference.category == _category_value(category))
|
||||
return [_serialize(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
owner_id: int,
|
||||
category: str | PreferenceCategory,
|
||||
value: str,
|
||||
source: str | PreferenceSource = PreferenceSource.EXPLICIT,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create one owner-scoped preference or reuse its normalized equivalent."""
|
||||
|
||||
_validate_owner(owner_id)
|
||||
category_value, clean_value, normalized = validate_preference(category, value)
|
||||
source_value = _source_value(source)
|
||||
existing = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.category == category_value,
|
||||
UserPreference.normalized_value == normalized,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.value = clean_value
|
||||
if source_value == PreferenceSource.EXPLICIT:
|
||||
existing.source = source_value
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
return _serialize(existing)
|
||||
|
||||
record = UserPreference(
|
||||
owner_id=owner_id,
|
||||
category=category_value,
|
||||
value=clean_value,
|
||||
normalized_value=normalized,
|
||||
source=source_value,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
record = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.category == category_value,
|
||||
UserPreference.normalized_value == normalized,
|
||||
)
|
||||
).scalar_one()
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return _serialize(record)
|
||||
|
||||
def update(
|
||||
self,
|
||||
owner_id: int,
|
||||
code: str,
|
||||
*,
|
||||
category: str | PreferenceCategory | None = None,
|
||||
value: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
record = self._owned_record(owner_id, code)
|
||||
next_category = category if category is not None else record.category
|
||||
next_value = value if value is not None else record.value
|
||||
category_value, clean_value, normalized = validate_preference(
|
||||
next_category,
|
||||
next_value,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
record.category = category_value
|
||||
record.value = clean_value
|
||||
record.normalized_value = normalized
|
||||
self.db.flush()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Preference already exists",
|
||||
) from exc
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return _serialize(record)
|
||||
|
||||
def delete(self, owner_id: int, code: str) -> None:
|
||||
record = self._owned_record(owner_id, code)
|
||||
self.db.delete(record)
|
||||
self.db.commit()
|
||||
|
||||
def delete_matching(
|
||||
self,
|
||||
owner_id: int,
|
||||
*,
|
||||
category: str | PreferenceCategory,
|
||||
value: str,
|
||||
) -> bool:
|
||||
category_value, _, normalized = validate_preference(category, value)
|
||||
record = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.category == category_value,
|
||||
UserPreference.normalized_value == normalized,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
self.db.delete(record)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def save_auto_extraction(
|
||||
self,
|
||||
owner_id: int,
|
||||
*,
|
||||
provider_name: str,
|
||||
user_text: str,
|
||||
structured_payload: str | dict[str, Any] | list[Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Persist only allowlisted, non-sensitive output from a real AI provider."""
|
||||
|
||||
if provider_name.strip().lower() in UNAVAILABLE_AI_PROVIDERS:
|
||||
return []
|
||||
if not contains_preference_signal(user_text):
|
||||
return []
|
||||
candidates = _parse_extraction_payload(structured_payload)
|
||||
saved: list[dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
saved.append(
|
||||
self.upsert(
|
||||
owner_id=owner_id,
|
||||
category=candidate.category,
|
||||
value=candidate.value,
|
||||
source=PreferenceSource.AUTO,
|
||||
commit=False,
|
||||
)
|
||||
)
|
||||
except HTTPException:
|
||||
# Automatic extraction is intentionally silent. Invalid or sensitive
|
||||
# candidates are discarded without creating a rejected profile row.
|
||||
continue
|
||||
if saved:
|
||||
self.db.commit()
|
||||
return saved
|
||||
|
||||
def delete_owner_preferences(self, owner_id: int) -> int:
|
||||
"""Stage deletion of every preference for an owner."""
|
||||
|
||||
result = self.db.execute(
|
||||
delete(UserPreference).where(UserPreference.owner_id == owner_id)
|
||||
)
|
||||
return max(0, int(result.rowcount or 0))
|
||||
|
||||
def _owned_record(self, owner_id: int, code: str) -> UserPreference:
|
||||
_validate_owner(owner_id)
|
||||
record = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.code == code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
# Deliberately indistinguishable from a nonexistent code.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Preference not found",
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def contains_preference_signal(value: str) -> bool:
|
||||
text = unicodedata.normalize("NFKC", value).casefold()
|
||||
return any(term in text for term in PREFERENCE_SIGNAL_TERMS)
|
||||
|
||||
|
||||
def validate_preference(
|
||||
category: str | PreferenceCategory,
|
||||
value: str,
|
||||
) -> tuple[str, str, str]:
|
||||
category_value = _category_value(category)
|
||||
clean_value = _clean_value(value)
|
||||
if is_sensitive_preference(clean_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Sensitive preference content is not allowed",
|
||||
)
|
||||
return category_value, clean_value, normalize_preference_value(clean_value)
|
||||
|
||||
|
||||
def normalize_preference_value(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKC", value)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
return normalized.casefold()
|
||||
|
||||
|
||||
def is_sensitive_preference(value: str) -> bool:
|
||||
normalized = unicodedata.normalize("NFKC", value).casefold()
|
||||
return any(term.casefold() in normalized for term in SENSITIVE_PREFERENCE_TERMS)
|
||||
|
||||
|
||||
def _parse_extraction_payload(
|
||||
payload: str | dict[str, Any] | list[Any],
|
||||
) -> list[Any]:
|
||||
parsed: Any = payload
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if isinstance(parsed, list):
|
||||
parsed = {"preferences": parsed}
|
||||
elif isinstance(parsed, dict) and "preferences" not in parsed:
|
||||
parsed = {"preferences": [parsed]}
|
||||
try:
|
||||
return PreferenceExtractionPayload.model_validate(parsed).preferences
|
||||
except ValidationError:
|
||||
return []
|
||||
|
||||
|
||||
def _category_value(category: str | PreferenceCategory) -> str:
|
||||
try:
|
||||
return str(PreferenceCategory(category))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Unsupported preference category",
|
||||
) from exc
|
||||
|
||||
|
||||
def _source_value(source: str | PreferenceSource) -> str:
|
||||
try:
|
||||
return str(PreferenceSource(source))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Unsupported preference source",
|
||||
) from exc
|
||||
|
||||
|
||||
def _clean_value(value: str) -> str:
|
||||
clean_value = unicodedata.normalize("NFKC", str(value)).strip()
|
||||
if not clean_value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Preference value is required",
|
||||
)
|
||||
if len(clean_value) > PREFERENCE_MAX_VALUE_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Preference value is too long",
|
||||
)
|
||||
return clean_value
|
||||
|
||||
|
||||
def _validate_owner(owner_id: int) -> None:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid preference owner is required",
|
||||
)
|
||||
|
||||
|
||||
def _serialize(record: UserPreference) -> dict[str, Any]:
|
||||
return {
|
||||
"code": record.code,
|
||||
"category": record.category,
|
||||
"value": record.value,
|
||||
"source": record.source,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"updated_at": record.updated_at.isoformat(),
|
||||
}
|
||||
Reference in New Issue
Block a user