feat: add entitlement plans and topic sessions
This commit is contained in:
@@ -11,10 +11,12 @@ from sqlalchemy.orm import Session
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.rag_service import RagService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
class ChatService:
|
||||
@@ -75,11 +77,25 @@ class ChatService:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
user,
|
||||
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||
)
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
deduct_quota=entitlement.deduct_quota,
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
@@ -87,6 +103,7 @@ class ChatService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
TopicSessionService.attach_user_message(user_message, topic)
|
||||
db.flush()
|
||||
|
||||
started_at = perf_counter()
|
||||
@@ -141,6 +158,7 @@ class ChatService:
|
||||
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=completion.answer,
|
||||
@@ -152,6 +170,12 @@ class ChatService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
TopicSessionService.attach_assistant_message(
|
||||
assistant_message,
|
||||
topic,
|
||||
token_input=completion.input_token,
|
||||
token_output=completion.output_token,
|
||||
)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
@@ -198,6 +222,18 @@ class ChatService:
|
||||
if user.daily_chat_used >= user.daily_chat_limit:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_topic_quota(db: Session, user: User, session: ChatSession, entitlement) -> None:
|
||||
if not entitlement.deduct_quota or entitlement.monthly_topic_limit is None:
|
||||
return
|
||||
if TopicSessionService.active_for_session(db, user=user, session=session) is not None:
|
||||
return
|
||||
if entitlement.monthly_topic_used >= entitlement.monthly_topic_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="本月深度主题使用较多,建议先完成已有功课;如需继续高频使用,可以联系运营老师确认权益。",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prepare_daily_quota(db: Session, user: User) -> User:
|
||||
locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
@@ -16,11 +16,13 @@ from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.chat_service import ChatService, _title_from_question
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.human_attention_service import HumanAttentionService
|
||||
from app.services.model_stream_service import ModelStreamService
|
||||
from app.services.rag_async_service import AsyncRagService
|
||||
from app.services.rag_service import RagService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
class ChatStreamService:
|
||||
@@ -29,11 +31,25 @@ class ChatStreamService:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
user,
|
||||
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||
)
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
deduct_quota=entitlement.deduct_quota,
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
@@ -41,6 +57,7 @@ class ChatStreamService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
TopicSessionService.attach_user_message(user_message, topic)
|
||||
db.flush()
|
||||
|
||||
history = list(
|
||||
@@ -122,6 +139,7 @@ class ChatStreamService:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
@@ -133,6 +151,12 @@ class ChatStreamService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
TopicSessionService.attach_assistant_message(
|
||||
assistant_message,
|
||||
topic,
|
||||
token_input=model_response.input_token if model_response is not None else None,
|
||||
token_output=_rough_token_count(answer),
|
||||
)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
@@ -163,11 +187,25 @@ class ChatStreamService:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
user,
|
||||
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||
)
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
deduct_quota=entitlement.deduct_quota,
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
@@ -175,6 +213,7 @@ class ChatStreamService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
TopicSessionService.attach_user_message(user_message, topic)
|
||||
db.flush()
|
||||
|
||||
history = list(
|
||||
@@ -262,6 +301,7 @@ class ChatStreamService:
|
||||
model_response=model_response,
|
||||
started_at=started_at,
|
||||
now=now,
|
||||
topic=topic,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,10 +324,12 @@ def _write_success(
|
||||
model_response,
|
||||
started_at: float,
|
||||
now: datetime,
|
||||
topic,
|
||||
) -> None:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id if topic is not None else None,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
@@ -299,6 +341,13 @@ def _write_success(
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
if topic is not None:
|
||||
TopicSessionService.attach_assistant_message(
|
||||
assistant_message,
|
||||
topic,
|
||||
token_input=model_response.input_token if model_response is not None else None,
|
||||
token_output=_rough_token_count(answer),
|
||||
)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
DEFAULT_PLAN_TYPE = "basic"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntitlementView:
|
||||
plan_id: int | None
|
||||
name: str
|
||||
plan_type: str
|
||||
monthly_topic_limit: int | None
|
||||
monthly_topic_used: int
|
||||
enable_growth_profile: bool
|
||||
enable_periodic_reports: bool
|
||||
allow_help_card: bool
|
||||
allow_share_draft: bool
|
||||
deduct_quota: bool
|
||||
effective_at: datetime | None = None
|
||||
expired_at: datetime | None = None
|
||||
source: str = "legacy"
|
||||
|
||||
@property
|
||||
def monthly_topic_remaining(self) -> int | None:
|
||||
if self.monthly_topic_limit is None:
|
||||
return None
|
||||
return max(0, self.monthly_topic_limit - self.monthly_topic_used)
|
||||
|
||||
|
||||
class EntitlementService:
|
||||
@staticmethod
|
||||
def list_plans(db: Session, *, include_disabled: bool = False) -> list[EntitlementPlan]:
|
||||
query = select(EntitlementPlan)
|
||||
if not include_disabled:
|
||||
query = query.where(EntitlementPlan.status == 1)
|
||||
return list(db.scalars(query.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())))
|
||||
|
||||
@staticmethod
|
||||
def default_plan(db: Session) -> EntitlementPlan | None:
|
||||
plan = db.scalar(
|
||||
select(EntitlementPlan)
|
||||
.where(EntitlementPlan.plan_type == DEFAULT_PLAN_TYPE, EntitlementPlan.status == 1)
|
||||
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if plan is not None:
|
||||
return plan
|
||||
return db.scalar(
|
||||
select(EntitlementPlan)
|
||||
.where(EntitlementPlan.status == 1)
|
||||
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def active_entitlement(db: Session, user: User, *, monthly_topic_used: int = 0) -> EntitlementView:
|
||||
now = _now()
|
||||
row = db.execute(
|
||||
select(UserEntitlement, EntitlementPlan)
|
||||
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
|
||||
.where(
|
||||
UserEntitlement.user_id == user.id,
|
||||
UserEntitlement.status == "active",
|
||||
EntitlementPlan.status == 1,
|
||||
)
|
||||
.where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now))
|
||||
.where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now))
|
||||
.order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
if row:
|
||||
entitlement, plan = row
|
||||
return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=entitlement, source="assigned")
|
||||
|
||||
plan = EntitlementService.default_plan(db)
|
||||
if plan is not None:
|
||||
return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=None, source="default")
|
||||
|
||||
return EntitlementView(
|
||||
plan_id=None,
|
||||
name="旧版每日额度",
|
||||
plan_type="legacy",
|
||||
monthly_topic_limit=None,
|
||||
monthly_topic_used=monthly_topic_used,
|
||||
enable_growth_profile=False,
|
||||
enable_periodic_reports=False,
|
||||
allow_help_card=True,
|
||||
allow_share_draft=True,
|
||||
deduct_quota=True,
|
||||
source="legacy",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def assign_user_plan(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
plan_id: int,
|
||||
operated_by: int | None,
|
||||
effective_at: datetime | None = None,
|
||||
expired_at: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> UserEntitlement:
|
||||
plan = db.get(EntitlementPlan, plan_id)
|
||||
if plan is None or plan.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
|
||||
now = _now()
|
||||
if effective_at is not None:
|
||||
effective_at = effective_at.replace(tzinfo=None)
|
||||
if expired_at is None and plan.validity_days:
|
||||
start = effective_at or now
|
||||
expired_at = start + timedelta(days=plan.validity_days)
|
||||
elif expired_at is not None:
|
||||
expired_at = expired_at.replace(tzinfo=None)
|
||||
if expired_at is not None and effective_at is not None and expired_at < effective_at:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="权益到期时间不能早于生效时间")
|
||||
|
||||
current = db.scalar(
|
||||
select(UserEntitlement)
|
||||
.where(UserEntitlement.user_id == user.id, UserEntitlement.status == "active")
|
||||
.order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
from_plan_id = current.plan_id if current else None
|
||||
if current is not None:
|
||||
current.status = "replaced"
|
||||
db.add(current)
|
||||
|
||||
entitlement = UserEntitlement(
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
status="active",
|
||||
effective_at=effective_at,
|
||||
expired_at=expired_at,
|
||||
assigned_by=operated_by,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(entitlement)
|
||||
db.flush()
|
||||
db.add(
|
||||
UserEntitlementLog(
|
||||
user_id=user.id,
|
||||
entitlement_id=entitlement.id,
|
||||
from_plan_id=from_plan_id,
|
||||
to_plan_id=plan.id,
|
||||
action="assign",
|
||||
detail_json=json.dumps(
|
||||
{
|
||||
"effectiveAt": effective_at.isoformat() if effective_at else None,
|
||||
"expiredAt": expired_at.isoformat() if expired_at else None,
|
||||
"remark": remark,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
operated_by=operated_by,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
return entitlement
|
||||
|
||||
|
||||
def plan_dict(plan: EntitlementPlan) -> dict:
|
||||
return {
|
||||
"id": plan.id,
|
||||
"name": plan.name,
|
||||
"planType": plan.plan_type,
|
||||
"description": plan.description,
|
||||
"validityDays": plan.validity_days,
|
||||
"monthlyTopicLimit": plan.monthly_topic_limit,
|
||||
"enableGrowthProfile": bool(plan.enable_growth_profile),
|
||||
"enablePeriodicReports": bool(plan.enable_periodic_reports),
|
||||
"allowHelpCard": bool(plan.allow_help_card),
|
||||
"allowShareDraft": bool(plan.allow_share_draft),
|
||||
"deductQuota": bool(plan.deduct_quota),
|
||||
"status": plan.status,
|
||||
"sortOrder": plan.sort_order,
|
||||
"createdAt": plan.created_at,
|
||||
"updatedAt": plan.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def entitlement_dict(view: EntitlementView) -> dict:
|
||||
return {
|
||||
"planId": view.plan_id,
|
||||
"name": view.name,
|
||||
"planType": view.plan_type,
|
||||
"monthlyTopicLimit": view.monthly_topic_limit,
|
||||
"monthlyTopicUsed": view.monthly_topic_used,
|
||||
"monthlyTopicRemaining": view.monthly_topic_remaining,
|
||||
"enableGrowthProfile": view.enable_growth_profile,
|
||||
"enablePeriodicReports": view.enable_periodic_reports,
|
||||
"allowHelpCard": view.allow_help_card,
|
||||
"allowShareDraft": view.allow_share_draft,
|
||||
"deductQuota": view.deduct_quota,
|
||||
"effectiveAt": view.effective_at,
|
||||
"expiredAt": view.expired_at,
|
||||
"source": view.source,
|
||||
}
|
||||
|
||||
|
||||
def view_from_plan(
|
||||
plan: EntitlementPlan,
|
||||
*,
|
||||
monthly_topic_used: int,
|
||||
entitlement: UserEntitlement | None,
|
||||
source: str,
|
||||
) -> EntitlementView:
|
||||
return EntitlementView(
|
||||
plan_id=plan.id,
|
||||
name=plan.name,
|
||||
plan_type=plan.plan_type,
|
||||
monthly_topic_limit=plan.monthly_topic_limit,
|
||||
monthly_topic_used=monthly_topic_used,
|
||||
enable_growth_profile=bool(plan.enable_growth_profile),
|
||||
enable_periodic_reports=bool(plan.enable_periodic_reports),
|
||||
allow_help_card=bool(plan.allow_help_card),
|
||||
allow_share_draft=bool(plan.allow_share_draft),
|
||||
deduct_quota=bool(plan.deduct_quota),
|
||||
effective_at=entitlement.effective_at if entitlement else None,
|
||||
expired_at=entitlement.expired_at if entitlement else None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -61,6 +61,7 @@ SYNONYM_RULES = (
|
||||
(re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"),
|
||||
(re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"),
|
||||
(re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"),
|
||||
(re.compile(r"(都有哪些|有哪些|都有什么|有什么|全部|所有)"), "有哪些"),
|
||||
(re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"),
|
||||
(re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import extract, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class TopicSessionService:
|
||||
@staticmethod
|
||||
def active_for_session(db: Session, *, user: User, session: ChatSession) -> TopicSession | None:
|
||||
return db.scalar(
|
||||
select(TopicSession)
|
||||
.where(
|
||||
TopicSession.user_id == user.id,
|
||||
TopicSession.chat_session_id == session.id,
|
||||
TopicSession.status == "active",
|
||||
)
|
||||
.order_by(TopicSession.created_at.desc(), TopicSession.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def monthly_used_count(db: Session, user_id: int, *, at: datetime | None = None) -> int:
|
||||
current = at or _now()
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(TopicSession.id)).where(
|
||||
TopicSession.user_id == user_id,
|
||||
extract("year", TopicSession.started_at) == current.year,
|
||||
extract("month", TopicSession.started_at) == current.month,
|
||||
TopicSession.quota_deducted == 1,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_active(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
session: ChatSession,
|
||||
question: str,
|
||||
deduct_quota: bool,
|
||||
) -> TopicSession:
|
||||
topic = TopicSessionService.active_for_session(db, user=user, session=session)
|
||||
if topic is not None:
|
||||
return topic
|
||||
topic = TopicSession(
|
||||
user_id=user.id,
|
||||
chat_session_id=session.id,
|
||||
title=_title_from_question(question),
|
||||
core_question=question.strip(),
|
||||
status="active",
|
||||
message_count=0,
|
||||
token_input=0,
|
||||
token_output=0,
|
||||
quota_deducted=1 if deduct_quota else 0,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add(topic)
|
||||
db.flush()
|
||||
return topic
|
||||
|
||||
@staticmethod
|
||||
def attach_user_message(message: ChatMessage, topic: TopicSession) -> None:
|
||||
message.topic_session_id = topic.id
|
||||
topic.message_count += 1
|
||||
|
||||
@staticmethod
|
||||
def attach_assistant_message(
|
||||
message: ChatMessage,
|
||||
topic: TopicSession,
|
||||
*,
|
||||
token_input: int | None,
|
||||
token_output: int | None,
|
||||
) -> None:
|
||||
message.topic_session_id = topic.id
|
||||
topic.message_count += 1
|
||||
topic.token_input += int(token_input or 0)
|
||||
topic.token_output += int(token_output or 0)
|
||||
|
||||
@staticmethod
|
||||
def topic_dict(topic: TopicSession) -> dict:
|
||||
return {
|
||||
"id": topic.id,
|
||||
"userId": topic.user_id,
|
||||
"chatSessionId": topic.chat_session_id,
|
||||
"title": topic.title,
|
||||
"coreQuestion": topic.core_question,
|
||||
"status": topic.status,
|
||||
"messageCount": topic.message_count,
|
||||
"tokenInput": topic.token_input,
|
||||
"tokenOutput": topic.token_output,
|
||||
"quotaDeducted": bool(topic.quota_deducted),
|
||||
"helpCardGenerated": bool(topic.help_card_generated),
|
||||
"shareDraftGenerated": bool(topic.share_draft_generated),
|
||||
"startedAt": topic.started_at,
|
||||
"endedAt": topic.ended_at,
|
||||
"updatedAt": topic.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _title_from_question(question: str) -> str:
|
||||
title = question.strip().replace("\n", " ")
|
||||
return title[:40] if title else "新主题"
|
||||
Reference in New Issue
Block a user