feat: scaffold v2 project structure with Docker, FastAPI, and config
동작하는 최소 코드 수준의 v2 스캐폴딩: - docker-compose.yml: postgres, fastapi, kordoc, frontend, caddy - app/: FastAPI 백엔드 (main, core, models, ai, prompts) - services/kordoc/: Node.js 문서 파싱 마이크로서비스 - gpu-server/: AI Gateway + GPU docker-compose - frontend/: SvelteKit 기본 구조 - migrations/: PostgreSQL 초기 스키마 (documents, tasks, processing_queue) - tests/: pytest conftest 기본 설정 - config.yaml, Caddyfile, credentials.env.example 갱신 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -23,3 +23,11 @@ data/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Node.js (frontend, kordoc)
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
|
||||
# Docker volumes
|
||||
pgdata/
|
||||
caddy_data/
|
||||
|
||||
13
Caddyfile
Normal file
13
Caddyfile
Normal file
@@ -0,0 +1,13 @@
|
||||
pkm.hyungi.net {
|
||||
reverse_proxy fastapi:8000
|
||||
}
|
||||
|
||||
# Synology Office 프록시
|
||||
office.hyungi.net {
|
||||
reverse_proxy https://ds1525.hyungi.net:5001 {
|
||||
header_up Host {upstream_hostport}
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/Dockerfile
Normal file
10
app/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
app/ai/__init__.py
Normal file
0
app/ai/__init__.py
Normal file
79
app/ai/client.py
Normal file
79
app/ai/client.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""AI 추상화 레이어 — 통합 클라이언트. 기본값은 항상 Qwen3.5."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config import settings
|
||||
|
||||
# 프롬프트 로딩
|
||||
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||
|
||||
|
||||
def _load_prompt(name: str) -> str:
|
||||
return (PROMPTS_DIR / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
CLASSIFY_PROMPT = _load_prompt("classify.txt") if (PROMPTS_DIR / "classify.txt").exists() else ""
|
||||
|
||||
|
||||
class AIClient:
|
||||
"""AI Gateway를 통한 통합 클라이언트. 기본값은 항상 Qwen3.5."""
|
||||
|
||||
def __init__(self):
|
||||
self.ai = settings.ai
|
||||
self._http = httpx.AsyncClient(timeout=120)
|
||||
|
||||
async def classify(self, text: str) -> dict:
|
||||
"""문서 분류 — 항상 primary(Qwen3.5) 사용"""
|
||||
prompt = CLASSIFY_PROMPT.replace("{document_text}", text)
|
||||
response = await self._call_chat(self.ai.primary, prompt)
|
||||
return response
|
||||
|
||||
async def summarize(self, text: str, force_premium: bool = False) -> str:
|
||||
"""문서 요약 — 기본 Qwen3.5, 장문이거나 명시적 요청 시만 Claude"""
|
||||
model = self.ai.primary
|
||||
if force_premium or len(text) > 15000:
|
||||
model = self.ai.premium
|
||||
return await self._call_chat(model, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""벡터 임베딩 — GPU 서버 전용"""
|
||||
response = await self._http.post(
|
||||
self.ai.embedding.endpoint,
|
||||
json={"model": self.ai.embedding.model, "prompt": text},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def ocr(self, image_bytes: bytes) -> str:
|
||||
"""이미지 OCR — GPU 서버 전용"""
|
||||
# TODO: Qwen2.5-VL-7B 비전 모델 호출 구현
|
||||
raise NotImplementedError("OCR는 Phase 1에서 구현")
|
||||
|
||||
async def _call_chat(self, model_config, prompt: str) -> str:
|
||||
"""OpenAI 호환 API 호출 + 자동 폴백"""
|
||||
try:
|
||||
return await self._request(model_config, prompt)
|
||||
except (httpx.TimeoutException, httpx.ConnectError):
|
||||
if model_config == self.ai.primary:
|
||||
return await self._request(self.ai.fallback, prompt)
|
||||
raise
|
||||
|
||||
async def _request(self, model_config, prompt: str) -> str:
|
||||
"""단일 모델 API 호출"""
|
||||
response = await self._http.post(
|
||||
model_config.endpoint,
|
||||
json={
|
||||
"model": model_config.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": model_config.max_tokens,
|
||||
},
|
||||
timeout=model_config.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
51
app/core/auth.py
Normal file
51
app/core/auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""JWT + TOTP 2FA 인증"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pyotp
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT 설정
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {"sub": subject, "exp": expire, "type": "access"}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
payload = {"sub": subject, "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_totp(code: str) -> bool:
|
||||
"""TOTP 코드 검증"""
|
||||
if not settings.totp_secret:
|
||||
return True # TOTP 미설정 시 스킵
|
||||
totp = pyotp.TOTP(settings.totp_secret)
|
||||
return totp.verify(code)
|
||||
93
app/core/config.py
Normal file
93
app/core/config.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""설정 로딩 — config.yaml + credentials.env"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AIModelConfig(BaseModel):
|
||||
endpoint: str
|
||||
model: str
|
||||
max_tokens: int = 4096
|
||||
timeout: int = 60
|
||||
daily_budget_usd: float | None = None
|
||||
require_explicit_trigger: bool = False
|
||||
|
||||
|
||||
class AIConfig(BaseModel):
|
||||
gateway_endpoint: str
|
||||
primary: AIModelConfig
|
||||
fallback: AIModelConfig
|
||||
premium: AIModelConfig
|
||||
embedding: AIModelConfig
|
||||
vision: AIModelConfig
|
||||
rerank: AIModelConfig
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
# DB
|
||||
database_url: str = ""
|
||||
|
||||
# AI
|
||||
ai: AIConfig | None = None
|
||||
|
||||
# NAS
|
||||
nas_mount_path: str = "/documents"
|
||||
nas_pkm_root: str = "/documents/PKM"
|
||||
|
||||
# 인증
|
||||
jwt_secret: str = ""
|
||||
totp_secret: str = ""
|
||||
|
||||
# kordoc
|
||||
kordoc_endpoint: str = "http://kordoc-service:3100"
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""config.yaml + 환경변수에서 설정 로딩"""
|
||||
# 환경변수 (docker-compose에서 주입)
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
jwt_secret = os.getenv("JWT_SECRET", "")
|
||||
totp_secret = os.getenv("TOTP_SECRET", "")
|
||||
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
|
||||
|
||||
# config.yaml
|
||||
config_path = Path(__file__).parent.parent.parent / "config.yaml"
|
||||
ai_config = None
|
||||
nas_mount = "/documents"
|
||||
nas_pkm = "/documents/PKM"
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if "ai" in raw:
|
||||
ai_raw = raw["ai"]
|
||||
ai_config = AIConfig(
|
||||
gateway_endpoint=ai_raw.get("gateway", {}).get("endpoint", ""),
|
||||
primary=AIModelConfig(**ai_raw["models"]["primary"]),
|
||||
fallback=AIModelConfig(**ai_raw["models"]["fallback"]),
|
||||
premium=AIModelConfig(**ai_raw["models"]["premium"]),
|
||||
embedding=AIModelConfig(**ai_raw["models"]["embedding"]),
|
||||
vision=AIModelConfig(**ai_raw["models"]["vision"]),
|
||||
rerank=AIModelConfig(**ai_raw["models"]["rerank"]),
|
||||
)
|
||||
|
||||
if "nas" in raw:
|
||||
nas_mount = raw["nas"].get("mount_path", nas_mount)
|
||||
nas_pkm = raw["nas"].get("pkm_root", nas_pkm)
|
||||
|
||||
return Settings(
|
||||
database_url=database_url,
|
||||
ai=ai_config,
|
||||
nas_mount_path=nas_mount,
|
||||
nas_pkm_root=nas_pkm,
|
||||
jwt_secret=jwt_secret,
|
||||
totp_secret=totp_secret,
|
||||
kordoc_endpoint=kordoc_endpoint,
|
||||
)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
34
app/core/database.py
Normal file
34
app/core/database.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""PostgreSQL 연결 — SQLAlchemy async engine + session factory"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""DB 연결 확인 (스키마는 migrations/로 관리)"""
|
||||
async with engine.begin() as conn:
|
||||
# 연결 테스트
|
||||
await conn.execute(
|
||||
__import__("sqlalchemy").text("SELECT 1")
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""FastAPI Depends용 세션 제공"""
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
46
app/core/utils.py
Normal file
46
app/core/utils.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""공통 유틸리티 — v1 pkm_utils.py에서 AppleScript 제거, 나머지 포팅"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def setup_logger(name: str, log_dir: str = "logs") -> logging.Logger:
|
||||
"""로거 설정"""
|
||||
Path(log_dir).mkdir(exist_ok=True)
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if not logger.handlers:
|
||||
# 파일 핸들러
|
||||
fh = logging.FileHandler(f"{log_dir}/{name}.log", encoding="utf-8")
|
||||
fh.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
))
|
||||
logger.addHandler(fh)
|
||||
|
||||
# 콘솔 핸들러
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
||||
logger.addHandler(ch)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def file_hash(path: str | Path) -> str:
|
||||
"""파일 SHA-256 해시 계산"""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
def count_log_errors(log_path: str) -> int:
|
||||
"""로그 파일에서 ERROR 건수 카운트"""
|
||||
try:
|
||||
with open(log_path, encoding="utf-8") as f:
|
||||
return sum(1 for line in f if "[ERROR]" in line)
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
41
app/main.py
Normal file
41
app/main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""hyungi_Document_Server — FastAPI 엔트리포인트"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from core.config import settings
|
||||
from core.database import init_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
|
||||
# 시작: DB 연결, 스케줄러 등록
|
||||
await init_db()
|
||||
# TODO: APScheduler 시작 (Phase 3)
|
||||
yield
|
||||
# 종료: 리소스 정리
|
||||
# TODO: 스케줄러 종료, DB 연결 해제
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="hyungi_Document_Server",
|
||||
description="Self-hosted PKM 웹 애플리케이션 API",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "version": "2.0.0"}
|
||||
|
||||
|
||||
# TODO: 라우터 등록 (Phase 0~2)
|
||||
# from api import documents, search, tasks, dashboard, export
|
||||
# app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
|
||||
# app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
|
||||
# app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
# app.include_router(export.router, prefix="/api/export", tags=["export"])
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
64
app/models/document.py
Normal file
64
app/models/document.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""documents 테이블 ORM"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
|
||||
# 1계층: 원본 파일
|
||||
file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
file_format: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
file_size: Mapped[int | None] = mapped_column(BigInteger)
|
||||
file_type: Mapped[str] = mapped_column(
|
||||
Enum("immutable", "editable", "note", name="doc_type"),
|
||||
default="immutable"
|
||||
)
|
||||
import_source: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 2계층: 텍스트 추출
|
||||
extracted_text: Mapped[str | None] = mapped_column(Text)
|
||||
extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
extractor_version: Mapped[str | None] = mapped_column(String(50))
|
||||
|
||||
# 2계층: AI 가공
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text)
|
||||
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
|
||||
ai_domain: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_sub_group: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
ai_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 3계층: 벡터 임베딩
|
||||
embedding = mapped_column(Vector(768), nullable=True)
|
||||
embed_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 메타데이터
|
||||
source_channel: Mapped[str | None] = mapped_column(
|
||||
Enum("law_monitor", "devonagent", "email", "web_clip",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync",
|
||||
name="source_channel")
|
||||
)
|
||||
data_origin: Mapped[str | None] = mapped_column(
|
||||
Enum("work", "external", name="data_origin")
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 타임스탬프
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
|
||||
)
|
||||
34
app/models/queue.py
Normal file
34
app/models/queue.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""processing_queue 테이블 ORM (비동기 가공 큐)"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class ProcessingQueue(Base):
|
||||
__tablename__ = "processing_queue"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
document_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("documents.id"), nullable=False)
|
||||
stage: Mapped[str] = mapped_column(
|
||||
Enum("extract", "classify", "embed", name="process_stage"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum("pending", "processing", "completed", "failed", name="process_status"),
|
||||
default="pending"
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(SmallInteger, default=3)
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_id", "stage", "status"),
|
||||
)
|
||||
29
app/models/task.py
Normal file
29
app/models/task.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""tasks 테이블 ORM (CalDAV 캐시)"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, SmallInteger, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
caldav_uid: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
priority: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
document_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("documents.id"))
|
||||
source: Mapped[str | None] = mapped_column(String(50))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
|
||||
)
|
||||
51
app/prompts/classify.txt
Normal file
51
app/prompts/classify.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
당신은 문서 분류 AI입니다. 아래 문서를 분석하고 반드시 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.
|
||||
|
||||
## 응답 형식
|
||||
{
|
||||
"tags": ["태그1", "태그2", "태그3"],
|
||||
"domain": "도메인경로",
|
||||
"sub_group": "하위그룹",
|
||||
"sourceChannel": "유입경로",
|
||||
"dataOrigin": "work 또는 external"
|
||||
}
|
||||
|
||||
## 도메인 선택지 (NAS 폴더 경로)
|
||||
- Knowledge/Philosophy — 철학, 사상, 인문학
|
||||
- Knowledge/Language — 어학, 번역, 언어학
|
||||
- Knowledge/Engineering — 공학 전반 기술 문서
|
||||
- Knowledge/Industrial_Safety — 산업안전, 규정, 인증
|
||||
- Knowledge/Programming — 개발, 코드, IT 기술
|
||||
- Knowledge/General — 일반 도서, 독서 노트, 메모
|
||||
- Reference — 도면, 참고자료, 규격표
|
||||
|
||||
## 하위 그룹 예시 (도메인별)
|
||||
- Knowledge/Industrial_Safety: Legislation, Standards, Cases
|
||||
- Knowledge/Programming: Language, Framework, DevOps, AI_ML
|
||||
- Knowledge/Engineering: Mechanical, Electrical, Network
|
||||
- 잘 모르겠으면: (비워둠)
|
||||
|
||||
## 태그 체계
|
||||
태그는 최대 5개, 한글 사용. 아래 계층 구조 중에서 선택:
|
||||
- @상태/: 처리중, 검토필요, 완료, 아카이브
|
||||
- #주제/기술/: 서버관리, 네트워크, AI-ML
|
||||
- #주제/산업안전/: 법령, 위험성평가, 순회점검, 안전교육, 사고사례, 신고보고, 안전관리자, 보건관리자
|
||||
- #주제/업무/: 프로젝트, 회의, 보고서
|
||||
- $유형/: 논문, 법령, 기사, 메모, 이메일, 채팅로그, 도면, 체크리스트
|
||||
- !우선순위/: 긴급, 중요, 참고
|
||||
|
||||
## sourceChannel 값
|
||||
- tksafety: TKSafety API 업무 실적
|
||||
- devonagent: 자동 수집 뉴스
|
||||
- law_monitor: 법령 API 법령 변경
|
||||
- inbox_route: Inbox AI 분류 (이 프롬프트에 의한 분류)
|
||||
- email: MailPlus 이메일
|
||||
- web_clip: Web Clipper 스크랩
|
||||
- manual: 직접 추가
|
||||
- drive_sync: Synology Drive 동기화
|
||||
|
||||
## dataOrigin 값
|
||||
- work: 자사 업무 관련 (TK, 테크니컬코리아, 공장, 생산, 사내)
|
||||
- external: 외부 참고 자료 (뉴스, 논문, 법령, 일반 정보)
|
||||
|
||||
## 분류 대상 문서
|
||||
{document_text}
|
||||
16
app/requirements.txt
Normal file
16
app/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncpg>=0.29.0
|
||||
pgvector>=0.3.0
|
||||
python-dotenv>=1.0.0
|
||||
pyyaml>=6.0
|
||||
httpx>=0.27.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
pyotp>=2.9.0
|
||||
caldav>=1.3.0
|
||||
apscheduler>=3.10.0
|
||||
anthropic>=0.40.0
|
||||
markdown>=3.5.0
|
||||
python-multipart>=0.0.9
|
||||
0
app/workers/__init__.py
Normal file
0
app/workers/__init__.py
Normal file
48
config.yaml
Normal file
48
config.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
# hyungi_Document_Server 설정
|
||||
|
||||
ai:
|
||||
gateway:
|
||||
endpoint: "http://gpu-server:8080"
|
||||
|
||||
models:
|
||||
primary:
|
||||
endpoint: "http://host.docker.internal:8800/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.5-35B-A3B-4bit"
|
||||
max_tokens: 4096
|
||||
timeout: 60
|
||||
|
||||
fallback:
|
||||
endpoint: "http://gpu-server:11434/v1/chat/completions"
|
||||
model: "qwen3.5:35b-a3b"
|
||||
max_tokens: 4096
|
||||
timeout: 120
|
||||
|
||||
premium:
|
||||
endpoint: "https://api.anthropic.com/v1/messages"
|
||||
model: "claude-sonnet-4-20250514"
|
||||
max_tokens: 8192
|
||||
daily_budget_usd: 5.00
|
||||
require_explicit_trigger: true
|
||||
|
||||
embedding:
|
||||
endpoint: "http://gpu-server:11434/api/embeddings"
|
||||
model: "nomic-embed-text"
|
||||
|
||||
vision:
|
||||
endpoint: "http://gpu-server:11434/api/generate"
|
||||
model: "Qwen2.5-VL-7B"
|
||||
|
||||
rerank:
|
||||
endpoint: "http://gpu-server:11434/api/rerank"
|
||||
model: "bge-reranker-v2-m3"
|
||||
|
||||
nas:
|
||||
mount_path: "/documents"
|
||||
pkm_root: "/documents/PKM"
|
||||
|
||||
schedule:
|
||||
law_monitor: "07:00"
|
||||
mailplus_archive: ["07:00", "18:00"]
|
||||
daily_digest: "20:00"
|
||||
file_watcher_interval_minutes: 5
|
||||
queue_consumer_interval_minutes: 10
|
||||
@@ -1,29 +1,57 @@
|
||||
# ═══════════════════════════════════════════════════
|
||||
# PKM 시스템 인증 정보
|
||||
# 이 파일은 템플릿입니다. 실제 값은 Mac mini의
|
||||
# ~/.config/pkm/credentials.env 에 별도 관리합니다.
|
||||
# hyungi_Document_Server — 인증 정보 템플릿
|
||||
# 실제 값을 채워서 credentials.env로 저장
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
# ─── Claude API (AI 고급 처리용) ───
|
||||
# ─── PostgreSQL ───
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=pkm
|
||||
POSTGRES_USER=pkm
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# ─── AI: Mac mini MLX (Qwen3.5, 기본 모델) ───
|
||||
MLX_ENDPOINT=http://localhost:8800/v1/chat/completions
|
||||
MLX_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit
|
||||
|
||||
# ─── AI: GPU 서버 ───
|
||||
GPU_SERVER_IP=
|
||||
GPU_EMBED_PORT=11434
|
||||
|
||||
# ─── AI: Claude API (종량제, 복잡한 분석 전용) ───
|
||||
CLAUDE_API_KEY=
|
||||
|
||||
# ─── AI Gateway (GPU 서버) ───
|
||||
AI_GATEWAY_ENDPOINT=http://gpu-server:8080
|
||||
|
||||
# ─── Synology NAS ───
|
||||
NAS_SMB_PATH=/Volumes/Document_Server
|
||||
NAS_DOMAIN=ds1525.hyungi.net
|
||||
NAS_TAILSCALE_IP=100.101.79.37
|
||||
NAS_PORT=15001
|
||||
|
||||
# ─── Synology MailPlus (이메일 수집 + SMTP 알림) ───
|
||||
MAILPLUS_HOST=mailplus.hyungi.net
|
||||
MAILPLUS_PORT=993
|
||||
MAILPLUS_SMTP_PORT=465
|
||||
MAILPLUS_USER=hyungi
|
||||
MAILPLUS_PASS=
|
||||
|
||||
# ─── Synology Calendar (CalDAV, 태스크 관리) ───
|
||||
CALDAV_URL=https://ds1525.hyungi.net/caldav/
|
||||
CALDAV_USER=hyungi
|
||||
CALDAV_PASS=
|
||||
|
||||
# ─── kordoc 마이크로서비스 ───
|
||||
KORDOC_ENDPOINT=http://kordoc-service:3100
|
||||
|
||||
# ─── 인증 (JWT + TOTP) ───
|
||||
JWT_SECRET=
|
||||
TOTP_SECRET=
|
||||
|
||||
# ─── 국가법령정보센터 (법령 모니터링) ───
|
||||
LAW_OC=
|
||||
|
||||
# ─── Synology NAS 접속 ───
|
||||
NAS_DOMAIN=
|
||||
NAS_TAILSCALE_IP=
|
||||
NAS_PORT=15001
|
||||
|
||||
# ─── MailPlus IMAP (이메일 수집용) ───
|
||||
MAILPLUS_HOST=
|
||||
MAILPLUS_PORT=993
|
||||
MAILPLUS_USER=
|
||||
MAILPLUS_PASS=
|
||||
|
||||
# ─── Synology Chat 웹훅 (나중에 추가) ───
|
||||
#CHAT_WEBHOOK_URL=
|
||||
|
||||
# ─── TKSafety API (나중에 활성화) ───
|
||||
#TKSAFETY_HOST=
|
||||
#TKSAFETY_HOST=tksafety.technicalkorea.net
|
||||
#TKSAFETY_PORT=
|
||||
|
||||
76
docker-compose.yml
Normal file
76
docker-compose.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./migrations:/docker-entrypoint-initdb.d
|
||||
environment:
|
||||
POSTGRES_DB: pkm
|
||||
POSTGRES_USER: pkm
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pkm"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
kordoc-service:
|
||||
build: ./services/kordoc
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ${NAS_SMB_PATH:-/Volumes/Document_Server}:/documents:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
fastapi:
|
||||
build: ./app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ${NAS_SMB_PATH:-/Volumes/Document_Server}:/documents
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
kordoc-service:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- credentials.env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://pkm:${POSTGRES_PASSWORD}@postgres:5432/pkm
|
||||
- KORDOC_ENDPOINT=http://kordoc-service:3100
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- fastapi
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
depends_on:
|
||||
- fastapi
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build build/
|
||||
COPY --from=build /app/node_modules node_modules/
|
||||
COPY package.json .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
16
frontend/package.json
Normal file
16
frontend/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "hyungi-document-server-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^2.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"svelte": "^4.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyungi Document Server</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
14
frontend/src/routes/+page.svelte
Normal file
14
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
// TODO: Phase 4에서 대시보드 위젯 구현
|
||||
</script>
|
||||
|
||||
<h1>hyungi Document Server</h1>
|
||||
<p>PKM 대시보드 — Phase 4에서 구현 예정</p>
|
||||
|
||||
<section>
|
||||
<h2>시스템 상태</h2>
|
||||
<ul>
|
||||
<li>FastAPI: <a href="/api/health">헬스체크</a></li>
|
||||
<li>API 문서: <a href="/docs">OpenAPI</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
10
frontend/svelte.config.js
Normal file
10
frontend/svelte.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
33
gpu-server/docker-compose.yml
Normal file
33
gpu-server/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
ports:
|
||||
- "11434:11434"
|
||||
restart: unless-stopped
|
||||
|
||||
ai-gateway:
|
||||
build: ./services/ai-gateway
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- PRIMARY_ENDPOINT=${PRIMARY_ENDPOINT:-http://mac-mini:8800/v1/chat/completions}
|
||||
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
|
||||
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
|
||||
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
|
||||
depends_on:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
10
gpu-server/services/ai-gateway/Dockerfile
Normal file
10
gpu-server/services/ai-gateway/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
3
gpu-server/services/ai-gateway/requirements.txt
Normal file
3
gpu-server/services/ai-gateway/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
httpx>=0.27.0
|
||||
58
gpu-server/services/ai-gateway/server.py
Normal file
58
gpu-server/services/ai-gateway/server.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""AI Gateway — 모델 라우팅, 폴백, 비용 제어, 요청 로깅"""
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import httpx
|
||||
|
||||
app = FastAPI(title="AI Gateway", version="1.0.0")
|
||||
|
||||
PRIMARY = os.getenv("PRIMARY_ENDPOINT", "http://localhost:8800/v1/chat/completions")
|
||||
FALLBACK = os.getenv("FALLBACK_ENDPOINT", "http://localhost:11434/v1/chat/completions")
|
||||
CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY", "")
|
||||
DAILY_BUDGET = float(os.getenv("DAILY_BUDGET_USD", "5.00"))
|
||||
|
||||
# 일일 비용 추적 (메모리, 재시작 시 리셋)
|
||||
_daily_cost: dict[str, float] = {}
|
||||
_http = httpx.AsyncClient(timeout=120)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "ai-gateway"}
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request):
|
||||
"""OpenAI 호환 채팅 엔드포인트 — 자동 폴백"""
|
||||
body = await request.json()
|
||||
tier = request.headers.get("x-model-tier", "primary")
|
||||
|
||||
if tier == "premium":
|
||||
return await _call_premium(body)
|
||||
|
||||
# Primary → Fallback 폴백
|
||||
try:
|
||||
resp = await _http.post(PRIMARY, json=body, timeout=60)
|
||||
resp.raise_for_status()
|
||||
return JSONResponse(content=resp.json())
|
||||
except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPStatusError):
|
||||
# 폴백
|
||||
resp = await _http.post(FALLBACK, json=body, timeout=120)
|
||||
resp.raise_for_status()
|
||||
return JSONResponse(content=resp.json())
|
||||
|
||||
|
||||
async def _call_premium(body: dict):
|
||||
"""Claude API 호출 — 비용 제어"""
|
||||
today = date.today().isoformat()
|
||||
if _daily_cost.get(today, 0) >= DAILY_BUDGET:
|
||||
raise HTTPException(429, f"일일 예산 초과: ${DAILY_BUDGET}")
|
||||
|
||||
if not CLAUDE_API_KEY:
|
||||
raise HTTPException(503, "CLAUDE_API_KEY 미설정")
|
||||
|
||||
# TODO: Anthropic API 호출 + 비용 계산 (Phase 3에서 구현)
|
||||
raise HTTPException(501, "Premium 모델 호출은 Phase 3에서 구현")
|
||||
106
migrations/001_initial_schema.sql
Normal file
106
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,106 @@
|
||||
-- hyungi_Document_Server 초기 스키마
|
||||
-- PostgreSQL 16 + pgvector + pg_trgm
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ENUM 타입
|
||||
CREATE TYPE doc_type AS ENUM ('immutable', 'editable', 'note');
|
||||
CREATE TYPE source_channel AS ENUM (
|
||||
'law_monitor', 'devonagent', 'email', 'web_clip',
|
||||
'tksafety', 'inbox_route', 'manual', 'drive_sync'
|
||||
);
|
||||
CREATE TYPE data_origin AS ENUM ('work', 'external');
|
||||
CREATE TYPE process_stage AS ENUM ('extract', 'classify', 'embed');
|
||||
CREATE TYPE process_status AS ENUM ('pending', 'processing', 'completed', 'failed');
|
||||
|
||||
-- documents 테이블
|
||||
CREATE TABLE documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- 1계층: 원본 파일 참조
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
file_format VARCHAR(20) NOT NULL,
|
||||
file_size BIGINT,
|
||||
file_type doc_type NOT NULL DEFAULT 'immutable',
|
||||
import_source TEXT,
|
||||
|
||||
-- 2계층: 텍스트 추출
|
||||
extracted_text TEXT,
|
||||
extracted_at TIMESTAMPTZ,
|
||||
extractor_version VARCHAR(50),
|
||||
|
||||
-- 2계층: AI 가공
|
||||
ai_summary TEXT,
|
||||
ai_tags JSONB DEFAULT '[]',
|
||||
ai_domain VARCHAR(100),
|
||||
ai_sub_group VARCHAR(100),
|
||||
ai_model_version VARCHAR(50),
|
||||
ai_processed_at TIMESTAMPTZ,
|
||||
|
||||
-- 3계층: 벡터 임베딩
|
||||
embedding vector(768),
|
||||
embed_model_version VARCHAR(50),
|
||||
embedded_at TIMESTAMPTZ,
|
||||
|
||||
-- 메타데이터
|
||||
source_channel source_channel,
|
||||
data_origin data_origin,
|
||||
title TEXT,
|
||||
|
||||
-- 타임스탬프
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 전문검색 인덱스
|
||||
CREATE INDEX idx_documents_fts ON documents
|
||||
USING GIN (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, '')));
|
||||
|
||||
-- 트리그램 인덱스 (한국어 부분 매칭)
|
||||
CREATE INDEX idx_documents_trgm ON documents
|
||||
USING GIN ((coalesce(title, '') || ' ' || coalesce(extracted_text, '')) gin_trgm_ops);
|
||||
|
||||
-- 해시 기반 중복 검색
|
||||
CREATE INDEX idx_documents_hash ON documents (file_hash);
|
||||
|
||||
-- 재가공 대상 필터링
|
||||
CREATE INDEX idx_documents_ai_version ON documents (ai_model_version);
|
||||
CREATE INDEX idx_documents_extractor_version ON documents (extractor_version);
|
||||
CREATE INDEX idx_documents_embed_version ON documents (embed_model_version);
|
||||
|
||||
-- tasks 테이블 (CalDAV 캐시)
|
||||
CREATE TABLE tasks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
caldav_uid TEXT UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
due_date TIMESTAMPTZ,
|
||||
priority SMALLINT DEFAULT 0,
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
completed_at TIMESTAMPTZ,
|
||||
document_id BIGINT REFERENCES documents(id),
|
||||
source VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- processing_queue 테이블 (비동기 가공 큐)
|
||||
CREATE TABLE processing_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
document_id BIGINT REFERENCES documents(id) NOT NULL,
|
||||
stage process_stage NOT NULL,
|
||||
status process_status DEFAULT 'pending',
|
||||
attempts SMALLINT DEFAULT 0,
|
||||
max_attempts SMALLINT DEFAULT 3,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
|
||||
UNIQUE (document_id, stage, status)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_queue_pending ON processing_queue (stage, status)
|
||||
WHERE status = 'pending';
|
||||
12
services/kordoc/Dockerfile
Normal file
12
services/kordoc/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js .
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
13
services/kordoc/package.json
Normal file
13
services/kordoc/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "kordoc-service",
|
||||
"version": "1.0.0",
|
||||
"description": "HWP/HWPX/PDF 문서 파싱 마이크로서비스",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"kordoc": "^1.7.0"
|
||||
}
|
||||
}
|
||||
57
services/kordoc/server.js
Normal file
57
services/kordoc/server.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* kordoc 마이크로서비스 — HWP/HWPX/PDF → Markdown 변환 API
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const PORT = 3100;
|
||||
|
||||
app.use(express.json({ limit: '500mb' }));
|
||||
|
||||
// 헬스체크
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'kordoc' });
|
||||
});
|
||||
|
||||
// 문서 파싱
|
||||
app.post('/parse', async (req, res) => {
|
||||
try {
|
||||
const { filePath } = req.body;
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'filePath is required' });
|
||||
}
|
||||
|
||||
// TODO: kordoc 라이브러리 연동 (Phase 1에서 구현)
|
||||
// const kordoc = require('kordoc');
|
||||
// const result = await kordoc.parse(filePath);
|
||||
// return res.json(result);
|
||||
|
||||
return res.json({
|
||||
markdown: '',
|
||||
metadata: {},
|
||||
format: 'unknown',
|
||||
message: 'kordoc 파싱은 Phase 1에서 구현 예정'
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 문서 비교
|
||||
app.post('/compare', async (req, res) => {
|
||||
try {
|
||||
const { filePathA, filePathB } = req.body;
|
||||
if (!filePathA || !filePathB) {
|
||||
return res.status(400).json({ error: 'filePathA and filePathB are required' });
|
||||
}
|
||||
|
||||
// TODO: kordoc compare 구현 (Phase 2)
|
||||
return res.json({ diffs: [], message: 'compare는 Phase 2에서 구현 예정' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`kordoc-service listening on port ${PORT}`);
|
||||
});
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""pytest 기본 fixture 설정"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
# TODO: Phase 0 완료 후 활성화
|
||||
# @pytest_asyncio.fixture
|
||||
# async def client():
|
||||
# """FastAPI 테스트 클라이언트"""
|
||||
# from app.main import app
|
||||
# async with AsyncClient(
|
||||
# transport=ASGITransport(app=app),
|
||||
# base_url="http://test"
|
||||
# ) as ac:
|
||||
# yield ac
|
||||
Reference in New Issue
Block a user