Initial commit: import from sinmb79/Gov-chat-bot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
airkjw
2026-03-26 12:49:43 +09:00
commit a16c972dbb
104 changed files with 8063 additions and 0 deletions
View File
+47
View File
@@ -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)
+72
View File
@@ -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
]
+165
View File
@@ -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,
)
+190
View File
@@ -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,
)
+189
View File
@@ -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,
)
+85
View File
@@ -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,
}
+106
View File
@@ -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}
+112
View File
@@ -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,
)
+48
View File
@@ -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}
+116
View File
@@ -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)