```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
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