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,47 @@
|
||||
"""
|
||||
관리자 인증 API.
|
||||
POST /api/admin/auth/login — 로그인 (JWT 발급)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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.security import verify_password, create_admin_token
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
router = APIRouter(prefix="/api/admin/auth", tags=["admin-auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
tenant_id: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(AdminUser).where(
|
||||
AdminUser.tenant_id == body.tenant_id,
|
||||
AdminUser.email == body.email,
|
||||
AdminUser.is_active == True,
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not verify_password(body.password, user.hashed_pw):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="이메일 또는 비밀번호가 올바르지 않습니다.",
|
||||
)
|
||||
|
||||
token = create_admin_token(user.id, user.tenant_id, user.role.value)
|
||||
return LoginResponse(access_token=token, role=user.role.value)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
민원 이력 조회 API — viewer 이상.
|
||||
GET /api/admin/complaints — 민원 이력 목록 (마스킹 상태로 노출)
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_admin
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.complaint import ComplaintLog
|
||||
|
||||
router = APIRouter(prefix="/api/admin/complaints", tags=["admin-complaints"])
|
||||
|
||||
|
||||
class ComplaintOut(BaseModel):
|
||||
id: str
|
||||
user_key: str # 해시값만 노출
|
||||
utterance_masked: Optional[str] = None # 마스킹된 발화
|
||||
channel: Optional[str] = None
|
||||
response_tier: Optional[str] = None
|
||||
response_source: Optional[str] = None
|
||||
response_ms: Optional[int] = None
|
||||
is_timeout: bool
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("", response_model=list[ComplaintOut])
|
||||
async def list_complaints(
|
||||
limit: int = Query(default=50, le=200),
|
||||
tier: Optional[str] = Query(default=None, description="A|B|C|D"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
"""
|
||||
민원 이력 조회.
|
||||
- utterance는 마스킹 상태로만 노출 (관리자도 원문 열람 불가)
|
||||
- user_key는 해시값만 노출
|
||||
"""
|
||||
query = (
|
||||
select(ComplaintLog)
|
||||
.where(ComplaintLog.tenant_id == current_user.tenant_id)
|
||||
.order_by(desc(ComplaintLog.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
if tier:
|
||||
query = query.where(ComplaintLog.response_tier == tier)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
return [
|
||||
ComplaintOut(
|
||||
id=log.id,
|
||||
user_key=log.user_key or "",
|
||||
utterance_masked=log.utterance_masked,
|
||||
channel=log.channel,
|
||||
response_tier=log.response_tier,
|
||||
response_source=log.response_source,
|
||||
response_ms=log.response_ms,
|
||||
is_timeout=bool(log.is_timeout),
|
||||
created_at=log.created_at,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
크롤러 관리 API.
|
||||
POST /api/admin/crawler/urls — URL 등록
|
||||
GET /api/admin/crawler/urls — URL 목록
|
||||
POST /api/admin/crawler/run/{url_id} — 수동 크롤링 실행
|
||||
DELETE /api/admin/crawler/urls/{id} — URL 삭제
|
||||
"""
|
||||
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
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.knowledge import CrawlerURL, Document
|
||||
from app.services.crawler import CrawlerService
|
||||
from app.services.document_processor import DocumentProcessor
|
||||
from app.services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/admin/crawler", tags=["admin-crawler"])
|
||||
|
||||
|
||||
class CrawlerURLCreate(BaseModel):
|
||||
url: str
|
||||
url_type: str = "page"
|
||||
interval_hours: int = 24
|
||||
|
||||
|
||||
class CrawlerURLOut(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
url_type: str
|
||||
interval_hours: int
|
||||
is_active: bool
|
||||
last_crawled: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/urls", status_code=status.HTTP_201_CREATED)
|
||||
async def register_url(
|
||||
body: CrawlerURLCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""크롤러 URL 등록."""
|
||||
crawler_url = CrawlerURL(
|
||||
tenant_id=current_user.tenant_id,
|
||||
url=body.url,
|
||||
url_type=body.url_type,
|
||||
interval_hours=body.interval_hours,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(crawler_url)
|
||||
await db.commit()
|
||||
await db.refresh(crawler_url)
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="crawler.approve",
|
||||
target_type="crawler_url",
|
||||
target_id=crawler_url.id,
|
||||
diff={"url": body.url},
|
||||
)
|
||||
|
||||
return {"id": crawler_url.id, "url": crawler_url.url}
|
||||
|
||||
|
||||
@router.get("/urls", response_model=list[CrawlerURLOut])
|
||||
async def list_urls(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(CrawlerURL).where(CrawlerURL.tenant_id == current_user.tenant_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/run/{url_id}")
|
||||
async def run_crawl(
|
||||
url_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""수동 크롤링 실행 → 텍스트 추출 → 문서로 저장."""
|
||||
tenant_id = current_user.tenant_id
|
||||
result = await db.execute(
|
||||
select(CrawlerURL).where(CrawlerURL.id == url_id, CrawlerURL.tenant_id == tenant_id)
|
||||
)
|
||||
crawler_url = result.scalar_one_or_none()
|
||||
if not crawler_url:
|
||||
raise HTTPException(status_code=404, detail="Crawler URL not found")
|
||||
|
||||
service = CrawlerService(db)
|
||||
text = await service.run(crawler_url, tenant_id)
|
||||
|
||||
if not text:
|
||||
raise HTTPException(status_code=422, detail="Failed to crawl or robots.txt disallowed")
|
||||
|
||||
# 크롤링 결과를 문서로 저장
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(crawler_url.url)
|
||||
filename = parsed.netloc + parsed.path.replace("/", "_") + ".txt"
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
filename=filename,
|
||||
source_type="crawler",
|
||||
source_url=crawler_url.url,
|
||||
is_active=False, # 편집장 검토 후 승인
|
||||
status="pending",
|
||||
)
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
processor = DocumentProcessor(
|
||||
embedding_provider=providers.get("embedding"),
|
||||
vectordb_provider=providers.get("vectordb"),
|
||||
db=db,
|
||||
)
|
||||
chunk_count = await processor.process(tenant_id, doc, text.encode("utf-8"))
|
||||
|
||||
return {
|
||||
"doc_id": doc.id,
|
||||
"url": crawler_url.url,
|
||||
"chunk_count": chunk_count,
|
||||
"status": doc.status,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/urls/{url_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_url(
|
||||
url_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(CrawlerURL).where(CrawlerURL.id == url_id, CrawlerURL.tenant_id == current_user.tenant_id)
|
||||
)
|
||||
crawler_url = result.scalar_one_or_none()
|
||||
if not crawler_url:
|
||||
raise HTTPException(status_code=404, detail="Crawler URL not found")
|
||||
|
||||
await db.delete(crawler_url)
|
||||
await db.commit()
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="crawler.reject",
|
||||
target_type="crawler_url",
|
||||
target_id=url_id,
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
문서 관리 API — 편집장(editor) 이상 접근.
|
||||
POST /api/admin/documents/upload — 문서 업로드
|
||||
POST /api/admin/documents/{id}/approve — 문서 승인 (is_active=True)
|
||||
GET /api/admin/documents — 문서 목록
|
||||
DELETE /api/admin/documents/{id} — 문서 삭제
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, 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, require_admin, get_current_admin
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.knowledge import Document
|
||||
from app.services.document_processor import DocumentProcessor
|
||||
from app.services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/admin/documents", tags=["admin-documents"])
|
||||
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
status: str
|
||||
is_active: bool
|
||||
chunk_count: int
|
||||
version: int
|
||||
published_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
published_at: Optional[str] = Form(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""문서 업로드 → 파싱·임베딩 → VectorDB 저장. is_active=False (승인 대기)."""
|
||||
tenant_id = current_user.tenant_id
|
||||
content = await file.read()
|
||||
|
||||
# 지원 형식 검사
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
||||
SUPPORTED = {"txt", "md", "html", "htm", "docx", "pdf"}
|
||||
if ext not in SUPPORTED:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file type: .{ext}")
|
||||
|
||||
# Document 레코드 생성
|
||||
published = None
|
||||
if published_at:
|
||||
try:
|
||||
published = datetime.fromisoformat(published_at)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
filename=file.filename,
|
||||
source_type="upload",
|
||||
is_active=False, # 편집장 승인 전
|
||||
status="pending",
|
||||
published_at=published,
|
||||
approved_by=None,
|
||||
)
|
||||
db.add(doc)
|
||||
await db.flush() # id 확보
|
||||
|
||||
# 문서 처리
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
processor = DocumentProcessor(
|
||||
embedding_provider=providers.get("embedding"),
|
||||
vectordb_provider=providers.get("vectordb"),
|
||||
db=db,
|
||||
)
|
||||
chunk_count = await processor.process(tenant_id, doc, content)
|
||||
|
||||
# 감사 로그
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="doc.upload",
|
||||
target_type="document",
|
||||
target_id=doc.id,
|
||||
diff={"filename": file.filename, "chunk_count": chunk_count},
|
||||
)
|
||||
|
||||
return {"id": doc.id, "filename": doc.filename, "status": doc.status, "chunk_count": chunk_count}
|
||||
|
||||
|
||||
@router.post("/{doc_id}/approve")
|
||||
async def approve_document(
|
||||
doc_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""문서 승인 → is_active=True."""
|
||||
tenant_id = current_user.tenant_id
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.tenant_id == tenant_id)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
if doc.status not in ("processed", "embedding_unavailable"):
|
||||
raise HTTPException(status_code=400, detail=f"Cannot approve document with status: {doc.status}")
|
||||
|
||||
doc.is_active = True
|
||||
doc.approved_by = current_user.id
|
||||
doc.approved_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="doc.approve",
|
||||
target_type="document",
|
||||
target_id=doc.id,
|
||||
)
|
||||
|
||||
return {"id": doc.id, "is_active": True}
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentOut])
|
||||
async def list_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
"""문서 목록 조회."""
|
||||
result = await db.execute(
|
||||
select(Document)
|
||||
.where(Document.tenant_id == current_user.tenant_id)
|
||||
.order_by(Document.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_document(
|
||||
doc_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""문서 삭제 (DB + VectorDB)."""
|
||||
tenant_id = current_user.tenant_id
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.tenant_id == tenant_id)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# VectorDB 청크 삭제
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
vectordb = providers.get("vectordb")
|
||||
if vectordb:
|
||||
processor = DocumentProcessor(
|
||||
embedding_provider=providers.get("embedding"),
|
||||
vectordb_provider=vectordb,
|
||||
db=db,
|
||||
)
|
||||
await processor.delete(tenant_id, doc_id)
|
||||
|
||||
await db.delete(doc)
|
||||
await db.commit()
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="doc.delete",
|
||||
target_type="document",
|
||||
target_id=doc_id,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
메트릭 조회 API — viewer 이상.
|
||||
GET /api/admin/metrics — Tier별 응답 통계 (ComplaintLog 기반, Redis 선택적)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_admin
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.complaint import ComplaintLog
|
||||
|
||||
router = APIRouter(prefix="/api/admin/metrics", tags=["admin-metrics"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_metrics(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
"""
|
||||
Tier별 응답 통계.
|
||||
Redis가 있으면 MetricsCollector, 없으면 DB 집계로 fallback.
|
||||
"""
|
||||
tenant_id = current_user.tenant_id
|
||||
|
||||
# Redis MetricsCollector 우선
|
||||
redis = getattr(request.app.state, "redis", None)
|
||||
if redis is not None:
|
||||
try:
|
||||
from app.services.metrics import MetricsCollector
|
||||
collector = MetricsCollector(redis)
|
||||
overview = await collector.get_overview(tenant_id)
|
||||
counts = overview.get("counts", {})
|
||||
total = counts.get("total_count", 0)
|
||||
return {
|
||||
"total_count": total,
|
||||
"tier_counts": {
|
||||
"A": counts.get("faq_hit_count", 0),
|
||||
"B": counts.get("rag_hit_count", 0),
|
||||
"C": counts.get("llm_hit_count", 0),
|
||||
"D": counts.get("fallback_count", 0),
|
||||
},
|
||||
"timeout_count": counts.get("timeout_count", 0),
|
||||
"avg_ms": overview.get("avg_ms", 0),
|
||||
"p95_ms": overview.get("p95_ms", 0),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DB fallback: ComplaintLog 집계
|
||||
total_result = await db.execute(
|
||||
select(func.count()).where(ComplaintLog.tenant_id == tenant_id)
|
||||
)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
tier_result = await db.execute(
|
||||
select(ComplaintLog.response_tier, func.count())
|
||||
.where(ComplaintLog.tenant_id == tenant_id)
|
||||
.group_by(ComplaintLog.response_tier)
|
||||
)
|
||||
tier_counts = {row[0]: row[1] for row in tier_result.all() if row[0]}
|
||||
|
||||
timeout_result = await db.execute(
|
||||
select(func.count()).where(
|
||||
ComplaintLog.tenant_id == tenant_id,
|
||||
ComplaintLog.is_timeout == True,
|
||||
)
|
||||
)
|
||||
timeout_count = timeout_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"total_count": total,
|
||||
"tier_counts": {
|
||||
"A": tier_counts.get("A", 0),
|
||||
"B": tier_counts.get("B", 0),
|
||||
"C": tier_counts.get("C", 0),
|
||||
"D": tier_counts.get("D", 0),
|
||||
},
|
||||
"timeout_count": timeout_count,
|
||||
"avg_ms": 0,
|
||||
"p95_ms": 0,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
악성 유저 관리 API — 편집장(editor) 이상.
|
||||
GET /api/admin/moderation — 제한 유저 목록
|
||||
POST /api/admin/moderation/{user_key}/release — 수동 해제
|
||||
POST /api/admin/moderation/{user_key}/escalate — 수동 레벨 상승
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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, require_admin
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.moderation import UserRestriction
|
||||
from app.services.moderation import ModerationService
|
||||
from app.services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/api/admin/moderation", tags=["admin-moderation"])
|
||||
|
||||
|
||||
class RestrictionOut(BaseModel):
|
||||
id: str
|
||||
user_key: str
|
||||
level: int
|
||||
reason: str | None = None
|
||||
auto_applied: bool
|
||||
expires_at: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class EscalateRequest(BaseModel):
|
||||
reason: str = "수동 조치"
|
||||
|
||||
|
||||
@router.get("", response_model=list[RestrictionOut])
|
||||
async def list_restrictions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""제한(level ≥ 1) 유저 목록."""
|
||||
result = await db.execute(
|
||||
select(UserRestriction).where(
|
||||
UserRestriction.tenant_id == current_user.tenant_id,
|
||||
UserRestriction.level > 0,
|
||||
)
|
||||
)
|
||||
restrictions = result.scalars().all()
|
||||
return [
|
||||
RestrictionOut(
|
||||
id=r.id,
|
||||
user_key=r.user_key,
|
||||
level=r.level,
|
||||
reason=r.reason,
|
||||
auto_applied=r.auto_applied,
|
||||
expires_at=r.expires_at.isoformat() if r.expires_at else None,
|
||||
)
|
||||
for r in restrictions
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{user_key}/release")
|
||||
async def release_restriction(
|
||||
user_key: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""수동 해제 (Level 4+ 포함)."""
|
||||
service = ModerationService(db)
|
||||
await service.release(current_user.tenant_id, user_key, current_user.id)
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="user.unblock",
|
||||
target_type="user_restriction",
|
||||
diff={"user_key": user_key},
|
||||
)
|
||||
return {"user_key": user_key, "released": True}
|
||||
|
||||
|
||||
@router.post("/{user_key}/escalate")
|
||||
async def escalate_restriction(
|
||||
user_key: str,
|
||||
body: EscalateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AdminUser = Depends(require_editor),
|
||||
):
|
||||
"""수동 레벨 상승."""
|
||||
service = ModerationService(db)
|
||||
new_level = await service.escalate(current_user.tenant_id, user_key, body.reason)
|
||||
|
||||
await log_action(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
actor_id=current_user.id,
|
||||
actor_type="admin_user",
|
||||
action="user.restrict",
|
||||
target_type="user_restriction",
|
||||
diff={"user_key": user_key, "new_level": new_level, "reason": body.reason},
|
||||
)
|
||||
return {"user_key": user_key, "new_level": new_level}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
POST /engine/query — 채널 공통 엔진 API (웹 시뮬레이터)
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.services.idempotency import IdempotencyCache
|
||||
from app.services.routing import ResponseRouter
|
||||
from app.services.complaint_logger import log_complaint
|
||||
from app.services.moderation import ModerationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EngineRequest(BaseModel):
|
||||
tenant: str
|
||||
utterance: str
|
||||
user_key: str
|
||||
channel: str = "web"
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class EngineResponse(BaseModel):
|
||||
answer: str
|
||||
tier: str
|
||||
source: str
|
||||
citations: list[dict] = []
|
||||
request_id: Optional[str] = None
|
||||
elapsed_ms: int = 0
|
||||
is_timeout: bool = False
|
||||
|
||||
|
||||
@router.post("/engine/query", response_model=EngineResponse)
|
||||
async def engine_query(
|
||||
body: EngineRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = body.tenant
|
||||
request_id = body.request_id or str(uuid.uuid4())
|
||||
|
||||
# Idempotency 캐시 확인
|
||||
redis_client = getattr(request.app.state, "redis", None)
|
||||
if redis_client:
|
||||
cache = IdempotencyCache(redis_client)
|
||||
cached = await cache.get(tenant_id, request_id)
|
||||
if cached:
|
||||
return EngineResponse(**cached)
|
||||
|
||||
# 악성 감지
|
||||
mod_service = ModerationService(db)
|
||||
mod_result = await mod_service.check(tenant_id, body.user_key)
|
||||
if not mod_result.allowed:
|
||||
return EngineResponse(
|
||||
answer=mod_result.message or "이용이 제한되었습니다. 담당 부서에 문의해 주세요.",
|
||||
tier="D",
|
||||
source="fallback",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
# 라우터 실행
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
tenant_config = getattr(request.app.state, "tenant_configs", {}).get(
|
||||
tenant_id, settings.model_dump()
|
||||
)
|
||||
router_svc = ResponseRouter(tenant_config=tenant_config, providers=providers)
|
||||
result = await router_svc.route(
|
||||
tenant_id=tenant_id,
|
||||
utterance=body.utterance,
|
||||
user_key=body.user_key,
|
||||
request_id=request_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# 경고 메시지 추가 (Level 1)
|
||||
if mod_result.message and mod_result.level == 1:
|
||||
result.answer = f"{mod_result.message}\n\n{result.answer}"
|
||||
|
||||
resp_dict = result.to_dict()
|
||||
|
||||
# Idempotency 캐시 저장
|
||||
if redis_client:
|
||||
await cache.set(tenant_id, request_id, resp_dict)
|
||||
|
||||
# 민원 이력 저장 (fire-and-forget: 실패해도 응답 영향 없음)
|
||||
try:
|
||||
await log_complaint(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
raw_utterance=body.utterance,
|
||||
raw_user_id=body.user_key,
|
||||
result=result,
|
||||
channel=body.channel,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return EngineResponse(
|
||||
answer=result.answer,
|
||||
tier=result.tier,
|
||||
source=result.source,
|
||||
citations=resp_dict.get("citations", []),
|
||||
request_id=request_id,
|
||||
elapsed_ms=result.elapsed_ms,
|
||||
is_timeout=result.is_timeout,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Response
|
||||
from sqlalchemy import text
|
||||
|
||||
import app.providers as providers_module
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "phase": "0B", "version": "0.2.0"}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready(response: Response):
|
||||
checks = {}
|
||||
all_ok = True
|
||||
|
||||
# DB 확인
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["db"] = "ok"
|
||||
except Exception as e:
|
||||
checks["db"] = f"error: {e}"
|
||||
all_ok = False
|
||||
|
||||
# Redis 확인
|
||||
try:
|
||||
r = aioredis.from_url(settings.REDIS_URL, socket_connect_timeout=2)
|
||||
await r.ping()
|
||||
await r.aclose()
|
||||
checks["redis"] = "ok"
|
||||
except Exception as e:
|
||||
checks["redis"] = f"error: {e}"
|
||||
all_ok = False
|
||||
|
||||
# Embedding 워밍업 상태
|
||||
checks["embedding"] = "warmed_up" if providers_module._embedding_warmed_up else "not_warmed_up"
|
||||
|
||||
if all_ok:
|
||||
return {"ready": True, "checks": checks}
|
||||
else:
|
||||
response.status_code = 503
|
||||
return {"ready": False, "checks": checks}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
POST /skill/{tenant_slug} — 카카오 스킬 API
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.services.masking import mask_text, hash_user_key
|
||||
from app.services.idempotency import IdempotencyCache
|
||||
from app.services.routing import ResponseRouter
|
||||
from app.services.complaint_logger import log_complaint
|
||||
from app.services.moderation import ModerationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class KakaoUserRequest(BaseModel):
|
||||
utterance: str
|
||||
user: Optional[dict] = None
|
||||
|
||||
|
||||
class KakaoSkillRequest(BaseModel):
|
||||
userRequest: KakaoUserRequest
|
||||
action: Optional[dict] = None
|
||||
|
||||
|
||||
def build_kakao_response(answer: str, doc_name: Optional[str] = None) -> dict:
|
||||
quick_replies = []
|
||||
if doc_name:
|
||||
quick_replies.append({
|
||||
"label": "출처 보기",
|
||||
"action": "message",
|
||||
"messageText": f"출처: {doc_name}",
|
||||
})
|
||||
response = {
|
||||
"version": "2.0",
|
||||
"template": {
|
||||
"outputs": [{"simpleText": {"text": answer}}],
|
||||
},
|
||||
}
|
||||
if quick_replies:
|
||||
response["template"]["quickReplies"] = quick_replies
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/skill/{tenant_slug}")
|
||||
async def kakao_skill(
|
||||
tenant_slug: str,
|
||||
body: KakaoSkillRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
utterance = body.userRequest.utterance
|
||||
raw_user_id = (body.userRequest.user or {}).get("id", "anonymous")
|
||||
|
||||
masked_utterance = mask_text(utterance)
|
||||
user_key = hash_user_key(raw_user_id)
|
||||
|
||||
action_params = (body.action or {}).get("params", {})
|
||||
request_id = action_params.get("request_id") or str(uuid.uuid4())
|
||||
|
||||
# Idempotency 캐시 확인
|
||||
redis_client = getattr(request.app.state, "redis", None)
|
||||
if redis_client:
|
||||
cache = IdempotencyCache(redis_client)
|
||||
cached = await cache.get(tenant_slug, request_id)
|
||||
if cached:
|
||||
return build_kakao_response(cached.get("answer", ""), cached.get("doc_name"))
|
||||
|
||||
# 악성 감지
|
||||
mod_service = ModerationService(db)
|
||||
mod_result = await mod_service.check(tenant_slug, user_key)
|
||||
if not mod_result.allowed:
|
||||
answer = mod_result.message or "이용이 제한되었습니다. 운영자에게 문의해 주세요."
|
||||
return build_kakao_response(answer)
|
||||
|
||||
# 라우터 실행
|
||||
providers = getattr(request.app.state, "providers", {})
|
||||
tenant_config = getattr(request.app.state, "tenant_configs", {}).get(
|
||||
tenant_slug, settings.model_dump()
|
||||
)
|
||||
router_svc = ResponseRouter(tenant_config=tenant_config, providers=providers)
|
||||
result = await router_svc.route(
|
||||
tenant_id=tenant_slug,
|
||||
utterance=utterance,
|
||||
user_key=user_key,
|
||||
request_id=request_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if mod_result.message and mod_result.level == 1:
|
||||
result.answer = f"{mod_result.message}\n\n{result.answer}"
|
||||
|
||||
# Idempotency 캐시 저장
|
||||
if redis_client:
|
||||
await cache.set(tenant_slug, request_id, result.to_dict())
|
||||
|
||||
# 민원 이력 저장
|
||||
try:
|
||||
await log_complaint(
|
||||
db=db,
|
||||
tenant_id=tenant_slug,
|
||||
raw_utterance=utterance,
|
||||
raw_user_id=raw_user_id,
|
||||
result=result,
|
||||
channel="kakao",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return build_kakao_response(result.answer, result.doc_name)
|
||||
Reference in New Issue
Block a user