One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
203 lines
6.9 KiB
Python
203 lines
6.9 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
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
|