Initial commit: import from sinmb79/Gov-chat-bot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
FAQ CRUD API — 편집장(editor) 이상.
|
||||
POST /api/admin/faqs — FAQ 생성
|
||||
GET /api/admin/faqs — FAQ 목록
|
||||
PUT /api/admin/faqs/{id} — FAQ 수정
|
||||
DELETE /api/admin/faqs/{id} — FAQ 삭제
|
||||
POST /api/admin/faqs/{id}/index — FAQ 벡터 색인
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import require_editor, get_current_admin
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.knowledge import FAQ
|
||||
from app.services.audit import log_action
|
||||
from app.services.faq_search import FAQSearchService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/faqs", tags=["admin-faq"])
|
||||
|
||||
|
||||
class FAQCreate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
question: str
|
||||
answer: str
|
||||
keywords: Optional[list[str]] = None
|
||||
|
||||
|
||||
class FAQUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
answer: Optional[str] = None
|
||||
keywords: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FAQOut(BaseModel):
|
||||
id: str
|
||||
category: Optional[str] = None
|
||||
question: str
|
||||
answer: str
|
||||
keywords: Optional[list] = None
|
||||
hit_count: int
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, response_model=FAQOut)
|
||||
async def create_faq(
|
||||
body: FAQCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
faq = FAQ(
|
||||
tenant_id=current_user.tenant_id,
|
||||
category=body.category,
|
||||
question=body.question,
|
||||
answer=body.answer,
|
||||
keywords=body.keywords,
|
||||
created_by=current_user.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(faq)
|
||||
await db.flush()
|
||||
|
||||
# 벡터 색인
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
if providers.get("embedding") and providers.get("vectordb"):
|
||||
service = FAQSearchService(providers["embedding"], providers["vectordb"], db)
|
||||
try:
|
||||
await service.index_faq(current_user.tenant_id, faq)
|
||||
except Exception:
|
||||
pass # 색인 실패해도 FAQ 저장은 성공
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(faq)
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="faq.create",
|
||||
target_type="faq",
|
||||
target_id=faq.id,
|
||||
diff={"question": body.question},
|
||||
)
|
||||
return faq
|
||||
|
||||
|
||||
@router.get("", response_model=list[FAQOut])
|
||||
async def list_faqs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(FAQ)
|
||||
.where(FAQ.tenant_id == current_user.tenant_id)
|
||||
.order_by(FAQ.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.put("/{faq_id}", response_model=FAQOut)
|
||||
async def update_faq(
|
||||
faq_id: str,
|
||||
body: FAQUpdate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(FAQ).where(FAQ.id == faq_id, FAQ.tenant_id == current_user.tenant_id)
|
||||
)
|
||||
faq = result.scalar_one_or_none()
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="FAQ not found")
|
||||
|
||||
diff = {}
|
||||
if body.question is not None:
|
||||
diff["question"] = {"before": faq.question, "after": body.question}
|
||||
faq.question = body.question
|
||||
if body.answer is not None:
|
||||
diff["answer"] = "updated"
|
||||
faq.answer = body.answer
|
||||
if body.category is not None:
|
||||
faq.category = body.category
|
||||
if body.keywords is not None:
|
||||
faq.keywords = body.keywords
|
||||
if body.is_active is not None:
|
||||
faq.is_active = body.is_active
|
||||
|
||||
# 재색인
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
if providers.get("embedding") and providers.get("vectordb") and faq.is_active:
|
||||
service = FAQSearchService(providers["embedding"], providers["vectordb"], db)
|
||||
try:
|
||||
await service.index_faq(current_user.tenant_id, faq)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(faq)
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="faq.update",
|
||||
target_type="faq",
|
||||
target_id=faq_id,
|
||||
diff=diff,
|
||||
)
|
||||
return faq
|
||||
|
||||
|
||||
@router.delete("/{faq_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_faq(
|
||||
faq_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(FAQ).where(FAQ.id == faq_id, FAQ.tenant_id == current_user.tenant_id)
|
||||
)
|
||||
faq = result.scalar_one_or_none()
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="FAQ not found")
|
||||
|
||||
await db.delete(faq)
|
||||
await db.commit()
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="faq.delete",
|
||||
target_type="faq",
|
||||
target_id=faq_id,
|
||||
)
|
||||
Reference in New Issue
Block a user