Files
replypier/backend/app/models/schemas/faq.py
T
WangShen007 5e0615f36d feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
2026-09-23 22:05:00 +08:00

203 lines
6.9 KiB
Python

"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from datetime import datetime
from typing import List, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Reuse the ORM enums so the API contract can never drift from the DB.
from app.models.faq import (
DEFAULT_FAQ_CATEGORY,
FAQ_META_DESCRIPTION_MAX_LENGTH,
FAQ_META_TITLE_MAX_LENGTH,
FAQ_SLUG_MAX_LENGTH,
FAQ_URL_PATH_MAX_LENGTH,
FAQStatus,
)
from app.models.schemas.pagination import Pagination
from app.utils.urls import normalize_url
MAX_QUESTION_LENGTH = 300
# Articles imported as-is (help-center pages with steps/images) routinely run
# long; the column is TEXT and the public renderer paginates nothing, so the
# cap only guards against runaway payloads.
MAX_ANSWER_LENGTH = 20000
MAX_BULK_IDS = 200
class SeoFields(BaseModel):
"""Optional per-article SEO overrides, shared by create and update.
Blank strings are coerced to None so clearing a field in the admin UI
restores the derived default instead of publishing an empty tag. `slug` is
normalized and de-duplicated server-side (see resolve_faq_slug) — validation
here only bounds the input, to exactly what the columns accept (values that
are too long are rejected rather than silently truncated).
"""
slug: Optional[str] = Field(default=None, max_length=FAQ_SLUG_MAX_LENGTH)
# A URL path carried over from a help center the org migrated from, so the
# article stays reachable at the URL it already ranks for. Accepts a full
# URL or a bare path; normalized and checked server-side (normalize_url_path
# / resolve_faq_url_path). Blank clears it, restoring /a/{slug}.
url_path: Optional[str] = Field(default=None, max_length=FAQ_URL_PATH_MAX_LENGTH)
meta_title: Optional[str] = Field(default=None, max_length=FAQ_META_TITLE_MAX_LENGTH)
meta_description: Optional[str] = Field(
default=None, max_length=FAQ_META_DESCRIPTION_MAX_LENGTH
)
@field_validator("slug", "url_path", "meta_title", "meta_description")
@classmethod
def _blank_to_none(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
return v.strip() or None
class FAQBase(BaseModel):
question: str = Field(min_length=1, max_length=MAX_QUESTION_LENGTH)
answer: str = Field(min_length=1, max_length=MAX_ANSWER_LENGTH)
category: str = Field(default=DEFAULT_FAQ_CATEGORY, min_length=1, max_length=100)
@field_validator("question", "answer", "category")
@classmethod
def _strip(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
class FAQCreate(FAQBase, SeoFields):
status: FAQStatus = FAQStatus.DRAFT
class FAQUpdate(SeoFields):
"""Partial update — omitted fields keep their current values (apply with
model_dump(exclude_unset=True)), so e.g. a status-only PATCH can't silently
reset the category to its default."""
question: Optional[str] = Field(default=None, min_length=1, max_length=MAX_QUESTION_LENGTH)
answer: Optional[str] = Field(default=None, min_length=1, max_length=MAX_ANSWER_LENGTH)
category: Optional[str] = Field(default=None, min_length=1, max_length=100)
status: Optional[FAQStatus] = None
@field_validator("question", "answer", "category")
@classmethod
def _strip(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
class FAQResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
question: str
answer: str
category: str
slug: Optional[str] = None
url_path: Optional[str] = None
source_url: Optional[str] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
status: FAQStatus
knowledge_id: Optional[int] = None
source_label: Optional[str] = None
helpful_yes: int = 0
helpful_no: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class FAQListResponse(BaseModel):
faqs: List[FAQResponse]
pagination: Pagination
class FAQBulkStatusRequest(BaseModel):
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
status: FAQStatus
class FAQBulkDeleteRequest(BaseModel):
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
class GenerateRequest(BaseModel):
"""Optional narrowing to specific knowledge sources; empty = new sources
only (those without FAQs yet)."""
knowledge_ids: Optional[List[int]] = Field(default=None, max_length=MAX_BULK_IDS)
class GenerationSourceResponse(BaseModel):
"""One knowledge source in the generation picker."""
id: int
name: str
source_type: str
has_faqs: bool
pages: int
estimated_calls: int
class GenerationEstimateResponse(BaseModel):
"""Confirm-dialog / source-picker numbers for a generation run.
remaining_credits is None when unlimited (OSS, no cap, or own model key)."""
total_sources: int
new_sources: int
pages: int
estimated_calls: int
metered: bool
remaining_credits: Optional[int] = None
sources: List[GenerationSourceResponse] = []
class ImportRequest(BaseModel):
url: str = Field(min_length=1, max_length=2048)
# qa = single page, LLM extracts Q&A pairs (uses credits).
# articles = crawl the page's linked articles and import each as-is
# (HTML -> Markdown, images re-hosted, no LLM).
mode: Literal["qa", "articles"] = "qa"
# articles mode only: serve each imported article at the URL path it already
# has on the source help center, so the org's search rankings and inbound
# links survive the migration. Ignored for qa.
preserve_urls: bool = False
@field_validator("url")
@classmethod
def _https_url(cls, v: str) -> str:
# Scheme-level validation only; the import worker re-checks the URL
# against the SSRF guards in app.knowledge.url_safety before fetching.
return normalize_url(v, require_https=True)
class GenerationJobResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
job_type: str
status: str
stage: str
progress_percentage: float
faqs_created: int
source_url: Optional[str] = None
error: Optional[str] = None
created_at: Optional[datetime] = None