Files
WangShen007 e0baca15a9 feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
2026-10-17 22:05:00 +08:00

181 lines
6.4 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.
FAQ generation usage/credits: estimate how many LLM calls a run will make,
decide whether those calls are metered (hosted model), and enforce the org's
message budget before enqueueing. OSS/self-hosted (no enterprise module) is
never metered and never blocked.
"""
import math
from dataclasses import dataclass
from typing import List, Optional
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.knowledge.page_editor import PAGE_ID_EXPR
from app.models.knowledge import Knowledge
from app.repositories.faq import FAQRepository
# Shared hosted-model metering helpers; re-exported here so existing FAQ
# callers keep importing them from faq_usage.
from app.services.usage_metering import ( # noqa: F401
HOSTED_MODEL_TYPE,
is_hosted_model,
remaining_message_credits,
)
logger = get_logger(__name__)
# Rough pages-per-LLM-call heuristic for the confirm-dialog estimate (matches
# today's floor batch of ~3 average pages). An estimate, not billing.
PAGES_PER_CALL = 3
OVER_BUDGET_MESSAGE = (
"Not enough message credits left in your plan for this generation "
"(~{estimated} needed, {remaining} remaining). Upgrade your plan or "
"switch to your own AI model key."
)
@dataclass
class SourceEstimate:
id: int
name: str
source_type: str
has_faqs: bool
pages: int
estimated_calls: int
@dataclass
class GenerationEstimate:
total_sources: int
new_sources: int
pages: int
estimated_calls: int
sources: List[SourceEstimate]
def generation_is_metered(db: Session, organization_id: UUID) -> bool:
"""Generation costs credits only on the hosted model — own-key orgs pay
their provider directly. FAQ_METER_OWN_KEY flips to always-meter."""
return settings.FAQ_METER_OWN_KEY or is_hosted_model(db, organization_id)
def count_source_pages(db: Session, knowledge: Knowledge) -> Optional[int]:
"""Distinct stored pages for a source, capped like generation itself.
None when the vector table is missing/unreadable."""
if not knowledge.schema or not knowledge.table_name:
return None
query = text(
f"SELECT COUNT(*) FROM (SELECT {PAGE_ID_EXPR} "
f'FROM {knowledge.schema}."{knowledge.table_name}" '
"WHERE name = :source GROUP BY 1 LIMIT :max_pages) pages"
)
try:
count = db.execute(
query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE}
).scalar()
return int(count or 0)
except Exception as e:
logger.warning(f"Page count failed for knowledge {knowledge.id}: {e}")
db.rollback()
return None
def estimate_generation_calls(
db: Session,
organization_id: UUID,
knowledge_ids: Optional[List[int]] = None,
count_pages: bool = True,
) -> GenerationEstimate:
"""Confirm-dialog numbers for a GENERATE_ALL run: which sources it would
read (new-only unless explicitly targeted) and roughly how many LLM calls
that costs. Unreadable sources count as one call each.
Always returns the FULL per-source list (for the generation source picker),
each with its own page/call estimate and whether it already has FAQs. The
aggregate counts (new_sources/pages/estimated_calls) cover the run's TARGET
sources: the explicit `knowledge_ids` selection, or — when none is given —
every source that doesn't yet have FAQs.
count_pages=False skips the per-source page COUNT over the vector tables
(cheap — for surfaces that only need counts, like the Generate button
label); per-source estimated_calls is then a 1-per-source lower bound."""
all_sources = (
db.query(Knowledge).filter(Knowledge.organization_id == organization_id).all()
)
generated = FAQRepository(db).knowledge_ids_with_faqs(organization_id)
existing_ids = {s.id for s in all_sources}
target_ids = (
(set(knowledge_ids) & existing_ids)
if knowledge_ids
else {s.id for s in all_sources if s.id not in generated}
)
source_list: List[SourceEstimate] = []
agg_pages = 0
agg_calls = 0
for source in all_sources:
source_pages = count_source_pages(db, source) if count_pages else None
pages = source_pages or 0
if source_pages is None:
# Unreadable now, or page-count skipped (count_pages=False) — assume
# one call so the estimate isn't zero.
calls = 1
else:
# A readable but empty source produces nothing → costs no call.
calls = max(1, math.ceil(pages / PAGES_PER_CALL)) if pages else 0
source_list.append(
SourceEstimate(
id=source.id,
name=source.source,
source_type=source.source_type.value if hasattr(source.source_type, "value") else str(source.source_type),
has_faqs=source.id in generated,
pages=pages,
estimated_calls=calls,
)
)
if source.id in target_ids:
agg_pages += pages
agg_calls += calls
return GenerationEstimate(
total_sources=len(all_sources),
new_sources=len(target_ids),
pages=agg_pages,
estimated_calls=agg_calls,
sources=source_list,
)
def ensure_generation_budget(db: Session, organization_id: UUID, estimated_calls: int) -> None:
"""402 when a metered run would exceed the org's remaining credits."""
if estimated_calls <= 0 or not generation_is_metered(db, organization_id):
return
remaining = remaining_message_credits(db, organization_id)
if remaining is None:
return
if estimated_calls > remaining:
raise HTTPException(
status_code=402,
detail=OVER_BUDGET_MESSAGE.format(estimated=estimated_calls, remaining=remaining),
)