120 lines
5.9 KiB
Python
120 lines
5.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.
|
|
"""
|
|
|
|
import enum
|
|
import uuid
|
|
|
|
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import func
|
|
|
|
from app.database import Base
|
|
|
|
DEFAULT_FAQ_CATEGORY = "General"
|
|
# Column widths, kept here so the API schemas bound input to exactly what the
|
|
# columns accept instead of restating the numbers (see models.schemas.faq).
|
|
FAQ_SLUG_MAX_LENGTH = 80
|
|
FAQ_META_TITLE_MAX_LENGTH = 120
|
|
FAQ_META_DESCRIPTION_MAX_LENGTH = 300
|
|
FAQ_URL_PATH_MAX_LENGTH = 400
|
|
FAQ_SOURCE_URL_MAX_LENGTH = 2048
|
|
|
|
|
|
class FAQStatus(str, enum.Enum):
|
|
DRAFT = "draft"
|
|
PUBLISHED = "published"
|
|
|
|
|
|
class FAQ(Base):
|
|
"""A single question/answer pair shown on the org's public help center.
|
|
|
|
FAQs are a standalone projection of the knowledge base: generation only
|
|
reads knowledge content, and editing/deleting FAQs never touches the
|
|
knowledge tables. Drafts stay private until explicitly published.
|
|
"""
|
|
__tablename__ = "faqs"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
# No single-column index: both composite indexes below lead with org id.
|
|
organization_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
question = Column(Text, nullable=False)
|
|
# Answer is authored/stored as Markdown; rendered to sanitized HTML on the
|
|
# public article page (app.services.help_center_content).
|
|
answer = Column(Text, nullable=False)
|
|
# URL slug for the public article page (/a/{slug}). Assigned from the
|
|
# question at creation and kept stable so published article URLs don't churn
|
|
# on edits — but editable by hand for SEO (app.api.help_center.faqs).
|
|
# Unique per org (see ix_faqs_org_slug); NULL until assigned.
|
|
slug = Column(String(FAQ_SLUG_MAX_LENGTH), nullable=True)
|
|
# The original URL path of an article migrated in from another help center.
|
|
# When set, the article is SERVED at this exact path and /a/{slug} 301s to
|
|
# it, so the org keeps the URLs it already ranks for instead of losing that
|
|
# SEO to a migration. Root-relative, no trailing slash, and percent-DECODED
|
|
# because that is the form the ASGI scope hands us to match against (every
|
|
# emitted URL re-encodes it — see help_center_seo.article_url).
|
|
# Unique per org (see ix_faqs_org_url_path); NULL means "served at /a/{slug}",
|
|
# which is every article that wasn't migrated.
|
|
url_path = Column(String(FAQ_URL_PATH_MAX_LENGTH), nullable=True)
|
|
# Where an imported article was fetched from, post-redirect. Provenance only
|
|
# — never routed on — and kept even when url_path had to be dropped, so the
|
|
# original URL is still recoverable.
|
|
source_url = Column(String(FAQ_SOURCE_URL_MAX_LENGTH), nullable=True)
|
|
# Per-article SEO overrides for the public page's <title> and meta
|
|
# description. NULL means "derive from the content": the question and an
|
|
# excerpt of the answer (see app.api.help_center_public). Lengths mirror
|
|
# help_center_settings.title/description.
|
|
meta_title = Column(String(FAQ_META_TITLE_MAX_LENGTH), nullable=True)
|
|
meta_description = Column(String(FAQ_META_DESCRIPTION_MAX_LENGTH), nullable=True)
|
|
# Free-form category assigned by the LLM grouping step (or the user).
|
|
category = Column(String(100), nullable=False, default=DEFAULT_FAQ_CATEGORY)
|
|
# Plain string (values from FAQStatus), like knowledge_queue.status — new
|
|
# states never need an ALTER TYPE migration.
|
|
status = Column(String, nullable=False, default=FAQStatus.DRAFT)
|
|
# Provenance: the knowledge source it was generated from (if any) plus a
|
|
# human-readable label ("example.com/docs", "Imported from support.x.com",
|
|
# "Added manually") that survives source deletion.
|
|
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
source_label = Column(String(255), nullable=True)
|
|
generation_job_id = Column(
|
|
Integer, ForeignKey("faq_generation_jobs.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
sort_order = Column(Integer, nullable=False, default=0)
|
|
# "Was this helpful?" tallies (de-duplicated per IP/day at the API).
|
|
helpful_yes = Column(Integer, nullable=False, default=0, server_default="0")
|
|
helpful_no = Column(Integer, nullable=False, default=0, server_default="0")
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
|
|
organization = relationship("Organization")
|
|
knowledge = relationship("Knowledge")
|
|
|
|
__table_args__ = (
|
|
Index("ix_faqs_org_status", "organization_id", "status"),
|
|
Index("ix_faqs_org_category", "organization_id", "category"),
|
|
# Serves the public /a/{slug} lookup and enforces per-org uniqueness.
|
|
# Multiple NULL slugs coexist (Postgres treats NULLs as distinct).
|
|
Index("ix_faqs_org_slug", "organization_id", "slug", unique=True),
|
|
# Serves the preserved-path lookup made for any URL no other public
|
|
# route claimed, and enforces per-org uniqueness. NULLs coexist.
|
|
Index("ix_faqs_org_url_path", "organization_id", "url_path", unique=True),
|
|
)
|