feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
"""
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.
Admin API for the FAQ manager + help center, split by concern
(settings/branding, FAQ content, generation jobs; the custom-domain router
joins in the domain-verification unit). Mounted at /api/v1/help-center.
"""
from fastapi import APIRouter
from app.api.help_center.branding import router as branding_router
from app.api.help_center.domain import router as domain_router
from app.api.help_center.faqs import router as faqs_router
from app.api.help_center.generation import router as generation_router
router = APIRouter()
router.include_router(branding_router)
router.include_router(faqs_router)
router.include_router(generation_router)
router.include_router(domain_router)
+245
View File
@@ -0,0 +1,245 @@
"""
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.
Help-center settings + branding endpoints (settings get/put, logo upload).
"""
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from sqlalchemy.orm import Session, selectinload
from app.core.auth import require_permissions
from app.core.config import settings as app_settings
from app.core.cors import update_cors_middleware
from app.database import get_db
from app.models.agent import Agent
from app.models.help_center import HelpCenterSettings
from app.models.schemas.help_center import (
DnsRecord,
DomainStatusResponse,
HelpCenterAgentOption,
HelpCenterSettingsResponse,
HelpCenterSettingsUpdate,
)
from app.models.user import User
from app.repositories.faq import FAQRepository
from app.repositories.help_center import HelpCenterRepository
from app.services.file_storage import resolve_public_url, store_upload
from app.services.help_center_access import check_help_center_access, help_center_allowed
from app.services.help_center_settings import get_or_create_settings, live_url
from app.services.help_center_images import absolute_upload_url
from app.services.image_security import sanitize_image
router = APIRouter()
MAX_LOGO_BYTES = 2 * 1024 * 1024
# Raster only — SVG is active markup and would be stored XSS on the public help
# center, so it is not accepted; the admin cropper rasterises to PNG client-side.
_LOGO_TYPES = {"image/png", "image/jpeg", "image/webp"}
# Reject anything larger than this on a side up front (decompression-bomb guard);
# the stored logo is then downscaled to LOGO_FIT_DIM (header renders it ~30px).
MAX_LOGO_DIM = 4000
LOGO_FIT_DIM = 512
# Favicon: same raster-only policy (SVG/ICO rejected — stored XSS / unsupported by
# sanitize_image). Stored small and square-ish; the browser scales the PNG.
MAX_FAVICON_BYTES = 1 * 1024 * 1024
_FAVICON_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_FAVICON_DIM = 2000
FAVICON_FIT_DIM = 128
def domain_status_response(row: HelpCenterSettings) -> DomainStatusResponse:
"""DNS-records table for the admin UI, shaped from stored state."""
records = []
if row.custom_domain:
records = [
DnsRecord(
type="CNAME",
host=row.custom_domain,
value=app_settings.HELP_CENTER_CNAME_TARGET,
verified=row.cname_record_verified,
),
DnsRecord(
type="TXT",
host=f"_replypier.{row.custom_domain}",
value=f"rp-verify={row.domain_verification_token}",
verified=row.txt_record_verified,
),
]
return DomainStatusResponse(
custom_domain=row.custom_domain,
domain_status=row.domain_status,
ssl_status=row.ssl_status,
records=records,
domain_verified_at=row.domain_verified_at,
)
async def settings_response(
db: Session, row: HelpCenterSettings, organization_id: UUID
) -> HelpCenterSettingsResponse:
agents = (
db.query(Agent)
.options(selectinload(Agent.widgets)) # avoid a lazy-load query per agent
.filter(Agent.organization_id == organization_id)
.order_by(Agent.name)
.all()
)
response = HelpCenterSettingsResponse.model_validate(row)
# Absolute (api-origin) URL: the admin runs on app.replypier.site, whose
# nginx serves any *.png path as a static asset — a host-relative
# /api/v1/uploads/*.png would 404 there. Anchor to the backend origin.
response.logo_url = (
absolute_upload_url(await resolve_public_url(row.logo_url)) if row.logo_url else None
)
response.favicon_url = (
absolute_upload_url(await resolve_public_url(row.favicon_url)) if row.favicon_url else None
)
response.live_url = live_url(row)
response.published_count = FAQRepository(db).count_published(organization_id)
response.plan_allowed = help_center_allowed(db, organization_id)
response.agents = [
HelpCenterAgentOption(
id=agent.id,
name=agent.display_name or agent.name,
has_widget=bool(agent.widgets),
)
for agent in agents
]
response.domain = domain_status_response(row)
return response
@router.get("/settings", response_model=HelpCenterSettingsResponse)
async def get_settings(
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Get-or-create the org's help center settings. Ungated read: the response
carries plan_allowed so the UI can render the upgrade lock."""
row = get_or_create_settings(db, current_user.organization)
return await settings_response(db, row, current_user.organization_id)
@router.put("/settings", response_model=HelpCenterSettingsResponse)
async def update_settings(
payload: HelpCenterSettingsUpdate,
request: Request,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
updates = payload.model_dump(exclude_unset=True)
if updates.get("agent_id") is not None:
agent = db.query(Agent).filter(
Agent.id == updates["agent_id"],
Agent.organization_id == current_user.organization_id,
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
was_enabled = row.enabled
for field, value in updates.items():
setattr(row, field, value)
row = HelpCenterRepository(db).update(row)
# Toggling `enabled` changes which {slug}.{base} origins the widget may call,
# so refresh the CORS allowlist (and propagate to other workers via Redis).
if "enabled" in updates and updates["enabled"] != was_enabled:
update_cors_middleware(request.app)
return await settings_response(db, row, current_user.organization_id)
@router.post("/logo", response_model=HelpCenterSettingsResponse)
async def upload_logo(
file: UploadFile = File(...),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
content = await file.read()
# Validate, bomb-guard, downscale and re-encode to a clean PNG/JPEG — the
# stored bytes carry no metadata or non-image payload.
safe_bytes, content_type, ext = sanitize_image(
content,
allowed_content_types=_LOGO_TYPES,
max_bytes=MAX_LOGO_BYTES,
max_dim=MAX_LOGO_DIM,
fit=LOGO_FIT_DIM,
)
file_name = f"{uuid4()}{ext}"
row.logo_url = await store_upload(
safe_bytes,
f"help_center/{current_user.organization_id}",
file_name,
content_type=content_type,
)
row = HelpCenterRepository(db).update(row)
return await settings_response(db, row, current_user.organization_id)
@router.delete("/logo", response_model=HelpCenterSettingsResponse)
async def remove_logo(
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
row.logo_url = None
row = HelpCenterRepository(db).update(row)
return await settings_response(db, row, current_user.organization_id)
@router.post("/favicon", response_model=HelpCenterSettingsResponse)
async def upload_favicon(
file: UploadFile = File(...),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
content = await file.read()
safe_bytes, content_type, ext = sanitize_image(
content,
allowed_content_types=_FAVICON_TYPES,
max_bytes=MAX_FAVICON_BYTES,
max_dim=MAX_FAVICON_DIM,
fit=FAVICON_FIT_DIM,
)
file_name = f"{uuid4()}{ext}"
row.favicon_url = await store_upload(
safe_bytes,
f"help_center/{current_user.organization_id}",
file_name,
content_type=content_type,
)
row = HelpCenterRepository(db).update(row)
return await settings_response(db, row, current_user.organization_id)
@router.delete("/favicon", response_model=HelpCenterSettingsResponse)
async def remove_favicon(
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
row.favicon_url = None
row = HelpCenterRepository(db).update(row)
return await settings_response(db, row, current_user.organization_id)
+101
View File
@@ -0,0 +1,101 @@
"""
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.
Custom-domain lifecycle for the help center: claim, verify (DNS + SSL),
inspect status, remove.
"""
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.auth import require_permissions
from app.core.cors import update_cors_middleware
from app.database import get_db
from app.models.schemas.help_center import DomainRequest, DomainStatusResponse
from app.models.user import User
from app.services.domain_verification import (
clear_custom_domain,
set_custom_domain,
verify_custom_domain,
)
from app.services.help_center_access import check_help_center_access
from app.services.help_center_settings import get_or_create_settings
router = APIRouter()
def _domain_response(row) -> DomainStatusResponse:
from app.api.help_center.branding import domain_status_response
return domain_status_response(row)
@router.post("/domain", response_model=DomainStatusResponse)
async def set_domain(
payload: DomainRequest,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
try:
row = set_custom_domain(db, row, payload.domain)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="That domain is already in use.")
return _domain_response(row)
@router.delete("/domain", response_model=DomainStatusResponse)
async def remove_domain(
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
return _domain_response(clear_custom_domain(db, row))
@router.post("/domain/verify", response_model=DomainStatusResponse)
async def verify_domain(
request: Request,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
row = get_or_create_settings(db, current_user.organization)
if not row.custom_domain:
raise HTTPException(status_code=400, detail="Set a custom domain first.")
# DNS + HTTPS probes block; keep them off the event loop.
row = await asyncio.to_thread(verify_custom_domain, db, row)
# Once verified, the custom domain becomes a widget origin — allow it in CORS.
if row.domain_verified:
update_cors_middleware(request.app)
return _domain_response(row)
@router.get("/domain/status", response_model=DomainStatusResponse)
async def domain_status(
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Stored state only — cheap enough for the UI's pending-domain polling.
POST /domain/verify is the endpoint that re-runs live DNS checks."""
row = get_or_create_settings(db, current_user.organization)
return _domain_response(row)
+240
View File
@@ -0,0 +1,240 @@
"""
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 typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.orm import Session
from app.core.auth import require_permissions
from app.database import get_db
from app.models.faq import FAQ, FAQStatus
from app.models.schemas.faq import (
FAQBulkDeleteRequest,
FAQBulkStatusRequest,
FAQCreate,
FAQListResponse,
FAQResponse,
FAQUpdate,
)
from app.models.schemas.pagination import Pagination
from app.models.user import User
from app.repositories.faq import FAQRepository
from app.services.help_center_access import check_help_center_access
from app.services.image_security import sanitize_image
from app.core.params import PageNumber
from app.services.help_center_images import (
FAQ_IMAGE_FIT_DIM,
FAQ_IMAGE_MAX_DIM,
FAQ_IMAGE_TYPES,
MAX_FAQ_IMAGE_BYTES,
store_article_image,
)
from app.services.help_center_settings import (
generate_faq_slug,
normalize_url_path,
resolve_faq_slug,
resolve_faq_url_path,
)
router = APIRouter()
def _get_owned_faq(faq_id: UUID, current_user: User, db: Session) -> FAQ:
faq = FAQRepository(db).get_by_id(faq_id, current_user.organization_id)
# 404 for both missing and cross-org ids (don't reveal existence).
if not faq:
raise HTTPException(status_code=404, detail="FAQ not found")
return faq
@router.get("/faqs", response_model=FAQListResponse)
async def list_faqs(
status: Optional[FAQStatus] = Query(default=None),
category: Optional[str] = Query(default=None),
q: Optional[str] = Query(default=None, max_length=200),
page: PageNumber = 1,
page_size: int = Query(default=100, ge=1, le=200),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
items, total = FAQRepository(db).list_for_org(
current_user.organization_id,
status=status,
category=category,
search=q,
skip=(page - 1) * page_size,
limit=page_size,
)
return FAQListResponse(
faqs=[FAQResponse.model_validate(item) for item in items],
pagination=Pagination.build(total=total, page=page, page_size=page_size),
)
@router.get("/faqs/categories", response_model=List[str])
async def list_categories(
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
return FAQRepository(db).get_categories(current_user.organization_id)
@router.post("/faqs", response_model=FAQResponse, status_code=201)
async def create_faq(
payload: FAQCreate,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
org_id = current_user.organization_id
faq = FAQ(
organization_id=org_id,
question=payload.question,
answer=payload.answer,
category=payload.category,
status=payload.status,
# A hand-typed slug is normalized and de-duplicated the same way a
# generated one is; omitting it derives the slug from the question.
slug=(
resolve_faq_slug(db, org_id, payload.slug)
if payload.slug
else generate_faq_slug(db, org_id, payload.question)
),
url_path=_resolve_url_path(db, org_id, payload.url_path),
meta_title=payload.meta_title,
meta_description=payload.meta_description,
source_label="Added manually",
created_by=current_user.id,
)
return FAQResponse.model_validate(FAQRepository(db).create(faq))
def _resolve_url_path(
db: Session, org_id: UUID, requested: Optional[str], exclude_id: Optional[UUID] = None
) -> Optional[str]:
"""Validate a hand-typed preserved URL path, or raise. None passes through
(no path requested / cleared). Unlike the slug this is never auto-corrected:
an altered path is no longer the URL the org migrated in, so a bad one is
surfaced instead of silently changed."""
if not requested:
return None
if normalize_url_path(requested) is None:
raise HTTPException(
status_code=400,
detail="That isn't a usable URL path — it can't be empty, or start with a "
"reserved prefix like /a/, /ask or /api.",
)
resolved = resolve_faq_url_path(db, org_id, requested, exclude_id=exclude_id)
if resolved is None:
raise HTTPException(status_code=409, detail="Another article already uses that URL path.")
return resolved
@router.post("/faqs/image")
async def upload_faq_image(
file: UploadFile = File(...),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Upload an image for embedding in an article's Markdown. Returns a stable
absolute URL the editor inserts as ![](url)."""
check_help_center_access(db, current_user.organization_id)
content = await file.read()
# Validate, bomb-guard, downscale and re-encode from decoded pixels — strips
# metadata/payloads and normalises to PNG/JPEG (content type is derived here,
# never from the client filename).
safe_bytes, content_type, _ext = sanitize_image(
content,
allowed_content_types=set(FAQ_IMAGE_TYPES),
max_bytes=MAX_FAQ_IMAGE_BYTES,
max_dim=FAQ_IMAGE_MAX_DIM,
fit=FAQ_IMAGE_FIT_DIM,
)
return {"url": await store_article_image(safe_bytes, content_type)}
@router.put("/faqs/{faq_id}", response_model=FAQResponse)
async def update_faq(
faq_id: UUID,
payload: FAQUpdate,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
faq = _get_owned_faq(faq_id, current_user, db)
updates = payload.model_dump(exclude_unset=True)
# The slug is never taken verbatim from the client — normalize and dedupe it
# (excluding this row, so re-saving an unchanged slug isn't a self-collision).
requested_slug = updates.pop("slug", None)
# Note the asymmetry with the slug: a blank slug is a no-op (an article must
# always have one), but a blank url_path CLEARS it, moving the article back
# to /a/{slug} — so this branches on presence, not truthiness.
has_url_path = "url_path" in updates
requested_url_path = updates.pop("url_path", None)
for field, value in updates.items():
setattr(faq, field, value)
if requested_slug:
faq.slug = resolve_faq_slug(
db, current_user.organization_id, requested_slug, exclude_id=faq.id
)
if has_url_path:
faq.url_path = _resolve_url_path(
db, current_user.organization_id, requested_url_path, exclude_id=faq.id
)
# Backfill a slug for legacy/generated FAQs the first time they're edited;
# otherwise keep it stable so published article URLs never change on their own.
if not faq.slug:
faq.slug = generate_faq_slug(db, current_user.organization_id, faq.question)
return FAQResponse.model_validate(FAQRepository(db).update(faq))
@router.delete("/faqs/{faq_id}", status_code=204)
async def delete_faq(
faq_id: UUID,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
check_help_center_access(db, current_user.organization_id)
faq = _get_owned_faq(faq_id, current_user, db)
FAQRepository(db).delete(faq)
@router.post("/faqs/bulk-status")
async def bulk_set_status(
payload: FAQBulkStatusRequest,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Publish/unpublish one or many FAQs in a single call."""
check_help_center_access(db, current_user.organization_id)
updated = FAQRepository(db).bulk_set_status(
current_user.organization_id, payload.faq_ids, payload.status
)
return {"updated": updated}
@router.post("/faqs/bulk-delete")
async def bulk_delete(
payload: FAQBulkDeleteRequest,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Delete many FAQs in one call (org-scoped; foreign ids are ignored)."""
check_help_center_access(db, current_user.organization_id)
deleted = FAQRepository(db).bulk_delete(current_user.organization_id, payload.faq_ids)
return {"deleted": deleted}
+281
View File
@@ -0,0 +1,281 @@
"""
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 typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.auth import require_permissions
from app.core.config import settings
from app.core.file_validation import PDF_MAGIC, read_validated, safe_filename
from app.database import get_db
from app.knowledge.url_safety import resolves_to_blocked_host
from app.models.faq_generation_job import FAQGenerationJob, FAQJobType
from app.models.knowledge import Knowledge
from app.models.schemas.faq import (
GenerateRequest,
GenerationEstimateResponse,
GenerationJobResponse,
GenerationSourceResponse,
ImportRequest,
)
from app.models.user import User
from app.repositories.ai_config import AIConfigRepository
from app.repositories.faq_generation_job import FAQGenerationJobRepository
from app.services.faq_usage import (
ensure_generation_budget,
estimate_generation_calls,
generation_is_metered,
remaining_message_credits,
)
from app.services.help_center_access import check_help_center_access
from app.core.params import DbIntId
router = APIRouter()
# Budget floor for imports whose call count isn't known up front — blocks only
# a fully exhausted plan; the per-call metering happens in the worker.
MIN_IMPORT_CALL_ESTIMATE = 1
MAX_IMPORT_PDF_BYTES = 25 * 1024 * 1024
# Stored under its own folder so import buffers never mix with article images.
_PDF_IMPORT_FOLDER = "help_center_imports"
def _require_ai_config(db: Session, organization_id) -> None:
if not AIConfigRepository(db).get_active_config(organization_id):
raise HTTPException(
status_code=400,
detail="No active AI configuration. Set up an AI model in AI Configuration first.",
)
def _enqueue(
db: Session,
current_user: User,
job_type: FAQJobType,
estimated_calls: int = MIN_IMPORT_CALL_ESTIMATE,
requires_llm: bool = True,
**fields,
) -> FAQGenerationJob:
"""Shared enqueue guard: one active job per type per org (409 on dupes),
fail fast when no AI model is configured, 402 when a metered run exceeds
the remaining message credits. Stamps the job's `metered` flag from the
org's current AI config. requires_llm=False (article import) skips the
AI-config and credit checks entirely."""
org_id = current_user.organization_id
check_help_center_access(db, org_id)
if requires_llm:
_require_ai_config(db, org_id)
ensure_generation_budget(db, org_id, estimated_calls)
job_repo = FAQGenerationJobRepository(db)
# Clear jobs a dead worker left in 'processing' so they don't block this
# enqueue (the one-active-per-type guard + partial unique index).
job_repo.fail_orphaned_processing(
"Worker stopped before the job finished — please try again.",
older_than_seconds=settings.FAQ_JOB_STALE_SECONDS,
organization_id=org_id,
)
if job_repo.get_active_for_org(org_id, job_type):
raise HTTPException(status_code=409, detail="A job of this type is already running.")
try:
return job_repo.create(
FAQGenerationJob(
organization_id=org_id,
user_id=current_user.id,
job_type=job_type.value,
metered=requires_llm and generation_is_metered(db, org_id),
**fields,
)
)
except IntegrityError:
# Concurrent enqueue lost the race against the partial unique index.
db.rollback()
raise HTTPException(status_code=409, detail="A job of this type is already running.")
def _validate_org_knowledge_ids(db: Session, organization_id, knowledge_ids) -> None:
if not knowledge_ids:
return
owned = {
row[0]
for row in db.query(Knowledge.id)
.filter(Knowledge.organization_id == organization_id, Knowledge.id.in_(knowledge_ids))
.all()
}
if set(knowledge_ids) - owned:
raise HTTPException(status_code=400, detail="Unknown knowledge source in selection.")
@router.get("/generate/estimate", response_model=GenerationEstimateResponse)
async def generation_estimate(
include_pages: bool = Query(
default=True,
description="False skips the per-source page scan — cheap counts for button labels",
),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Pre-generation numbers for the confirm dialog: new (ungenerated)
sources, page count, estimated LLM calls and remaining credits."""
org_id = current_user.organization_id
check_help_center_access(db, org_id)
estimate = estimate_generation_calls(db, org_id, count_pages=include_pages)
metered = generation_is_metered(db, org_id)
return GenerationEstimateResponse(
total_sources=estimate.total_sources,
new_sources=estimate.new_sources,
pages=estimate.pages,
estimated_calls=estimate.estimated_calls,
metered=metered,
remaining_credits=remaining_message_credits(db, org_id) if metered else None,
sources=[
GenerationSourceResponse(
id=s.id, name=s.name, source_type=s.source_type,
has_faqs=s.has_faqs, pages=s.pages, estimated_calls=s.estimated_calls,
)
for s in estimate.sources
],
)
@router.post("/generate", response_model=GenerationJobResponse, status_code=202)
async def start_generation(
payload: Optional[GenerateRequest] = None,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Draft FAQs from the knowledge base (additive; the worker dedups against
existing questions). By default only sources without FAQs are read;
knowledge_ids explicitly targets sources instead."""
org_id = current_user.organization_id
# Plan gate and AI-config checks first: a locked plan must see 403 and a
# missing model its setup hint, not source-count errors.
check_help_center_access(db, org_id)
_require_ai_config(db, org_id)
knowledge_ids = payload.knowledge_ids if payload else None
_validate_org_knowledge_ids(db, org_id, knowledge_ids)
estimate = estimate_generation_calls(db, org_id, knowledge_ids)
if estimate.total_sources == 0:
raise HTTPException(status_code=400, detail="No knowledge sources to generate from. Add knowledge first.")
if estimate.new_sources == 0:
raise HTTPException(
status_code=409,
detail="All knowledge sources already have FAQs. Delete a source's FAQs to regenerate it.",
)
job = _enqueue(
db,
current_user,
FAQJobType.GENERATE_ALL,
estimated_calls=estimate.estimated_calls,
knowledge_ids=knowledge_ids,
)
return GenerationJobResponse.model_validate(job)
@router.post("/import", response_model=GenerationJobResponse, status_code=202)
async def start_import(
payload: ImportRequest,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Migrate an existing help center. mode=qa extracts Q&A pairs from the
page with the org's model (uses credits); mode=articles crawls the page's
linked article pages and imports each one as-is (Markdown, no LLM)."""
# Early SSRF rejection for immediate feedback; the worker re-validates at
# fetch time (including every redirect hop).
if resolves_to_blocked_host(payload.url):
raise HTTPException(status_code=400, detail="That URL points to a blocked or internal host.")
if payload.mode == "articles":
# No LLM → no AI-config requirement, no credit checks.
job = _enqueue(
db, current_user, FAQJobType.IMPORT_ARTICLES,
requires_llm=False, source_url=payload.url,
preserve_source_urls=payload.preserve_urls,
)
else:
job = _enqueue(db, current_user, FAQJobType.IMPORT_URL, source_url=payload.url)
return GenerationJobResponse.model_validate(job)
@router.post("/import/pdf", response_model=GenerationJobResponse, status_code=202)
async def start_pdf_import(
file: UploadFile = File(...),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""Extract Q&A pairs from an uploaded PDF with the org's model. The file
is persisted via shared storage (the worker runs in another container)
and deleted after processing."""
from app.services.file_storage import store_upload
content = await read_validated(
file,
max_size=MAX_IMPORT_PDF_BYTES,
allowed_content_types=("application/pdf",),
magic_prefix=PDF_MAGIC,
label="PDF",
)
stored = await store_upload(
content=content,
folder=_PDF_IMPORT_FOLDER,
file_name=f"{uuid4().hex}.pdf",
content_type="application/pdf",
)
# safe_filename strips traversal; keep only the readable tail for the label.
original = safe_filename(file.filename or "document.pdf").split("_", 1)[-1][:255]
try:
job = _enqueue(
db, current_user, FAQJobType.IMPORT_PDF,
source_url=stored, source_file_name=original,
)
except HTTPException:
# Enqueue refused (plan/config/duplicate/budget) — don't leak the buffer.
from app.services.file_storage import delete_upload
await delete_upload(stored)
raise
return GenerationJobResponse.model_validate(job)
@router.get("/jobs", response_model=Optional[GenerationJobResponse])
async def get_job(
active: bool = Query(default=True, description="Active job only; false = most recent job"),
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
"""The UI's polling endpoint: the running job if any (active=true), or the
most recent job of any status (active=false) for showing the last outcome."""
job_repo = FAQGenerationJobRepository(db)
if active:
job = job_repo.get_active_for_org(current_user.organization_id)
else:
job = job_repo.get_latest_for_org(current_user.organization_id)
return GenerationJobResponse.model_validate(job) if job else None
@router.get("/jobs/{job_id}", response_model=GenerationJobResponse)
async def get_job_by_id(
job_id: DbIntId,
current_user: User = Depends(require_permissions("manage_knowledge")),
db: Session = Depends(get_db),
):
job = FAQGenerationJobRepository(db).get_by_id_for_org(job_id, current_user.organization_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return GenerationJobResponse.model_validate(job)