Files

125 lines
5.5 KiB
Python
Raw Permalink Normal View History

"""
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
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import expression, func
from app.database import Base
class FAQJobType(str, enum.Enum):
# Generate from every knowledge source in the org (the "Generate" button).
GENERATE_ALL = "generate_all"
# Generate from one newly processed source (the auto-generation hook).
GENERATE_SOURCE = "generate_source"
# Extract Q&A pairs from an external FAQ page URL (migration).
IMPORT_URL = "import_url"
# Crawl an external help-center index and import linked articles as-is
# (HTML→Markdown, no LLM).
IMPORT_ARTICLES = "import_articles"
# Extract Q&A pairs from an uploaded PDF.
IMPORT_PDF = "import_pdf"
class FAQJobStatus(str, enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class FAQJobStage(str, enum.Enum):
NOT_STARTED = "not_started"
ANALYZING_SOURCES = "analyzing_sources"
EXTRACTING = "extracting"
DRAFTING = "drafting"
GROUPING = "grouping"
COMPLETED = "completed"
# Statuses that count as "still running" for enqueue guards and UI polling —
# derived so a future status defaults to "active" rather than silently
# escaping the duplicate-job guard.
TERMINAL_FAQ_JOB_STATUSES = {FAQJobStatus.COMPLETED, FAQJobStatus.FAILED}
ACTIVE_FAQ_JOB_STATUSES = [s.value for s in FAQJobStatus if s not in TERMINAL_FAQ_JOB_STATUSES]
class FAQGenerationJob(Base):
"""Async FAQ generation/import job, processed by the FAQ worker.
Mirrors the KnowledgeQueue shape (string status/stage + float progress) so
the established worker and frontend polling patterns carry over.
"""
__tablename__ = "faq_generation_jobs"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
UUID(as_uuid=True),
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Nullable: auto-generation jobs may be enqueued without an acting user.
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
job_type = Column(String, nullable=False, default=FAQJobType.GENERATE_ALL)
# GENERATE_SOURCE: the knowledge source to read. NULL for GENERATE_ALL/
# IMPORT_URL. SET NULL (not CASCADE) so deleting a source mid-run doesn't
# yank the job row out from under the worker and the UI's polling.
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
# GENERATE_ALL: optional explicit narrowing to specific knowledge sources
# (overrides the default skip-already-generated behaviour).
knowledge_ids = Column(JSON, nullable=True)
# IMPORT_URL: the external page to extract from. IMPORT_PDF: the stored
# upload path/URL the worker reads the file from.
source_url = Column(String, nullable=True)
# IMPORT_PDF: the original client filename, used for source_label.
source_file_name = Column(String(255), nullable=True)
# IMPORT_ARTICLES: keep every imported article at the URL path it already
# has on the source help center (FAQ.url_path), so migrating in doesn't
# move — and cost the org — the URLs it already ranks for.
preserve_source_urls = Column(
Boolean, nullable=False, default=False, server_default=expression.false()
)
# LLM calls attempted by this job (retries included) — the usage unit for
# hosted-model metering. `metered` is stamped at enqueue time from the
# org's AI config so the period sum never depends on later config changes.
llm_calls = Column(Integer, nullable=False, default=0, server_default="0")
metered = Column(Boolean, nullable=False, default=True, server_default=expression.true())
status = Column(String, nullable=False, default=FAQJobStatus.PENDING)
stage = Column(String, nullable=False, default=FAQJobStage.NOT_STARTED)
progress_percentage = Column(Float, nullable=False, default=0.0)
faqs_created = Column(Integer, nullable=False, default=0)
error = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
__table_args__ = (
# Partial index keeping the worker's poll O(active rows).
Index(
"ix_faq_generation_jobs_active",
"status",
postgresql_where=(status.in_(ACTIVE_FAQ_JOB_STATUSES)),
),
# NOTE: production also has uq_faq_generation_jobs_one_active — a
# UNIQUE partial index on (organization_id, job_type,
# COALESCE(knowledge_id, -1)) WHERE status is active, closing the
# enqueue check-then-insert race. It lives only in the migration:
# declaring it here would create it WITHOUT the partial WHERE on
# sqlite (tests), wrongly blocking re-enqueues after completion.
)