feat: security(CORS/API key), OpenAI-compatible endpoint, Paperless hook indexing; .env support

This commit is contained in:
hyungi
2025-08-13 07:36:27 +09:00
parent 325dde803d
commit d17ec57b2e
6 changed files with 164 additions and 6 deletions

View File

@@ -228,6 +228,34 @@ curl -s -X POST http://localhost:26000/paperless/hook \
- `EMBEDDING_MODEL`(기본 `nomic-embed-text`) - `EMBEDDING_MODEL`(기본 `nomic-embed-text`)
- `INDEX_PATH`(기본 `data/index.jsonl`) - `INDEX_PATH`(기본 `data/index.jsonl`)
- `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용 - `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용
- `API_KEY`(선택): 설정 시 모든 민감 엔드포인트 호출에 `X-API-Key` 헤더 필요
- `CORS_ORIGINS`(선택): CORS 허용 오리진(쉼표 구분), 미설정 시 `*`
`.env` 예시:
```bash
AI_SERVER_PORT=26000
OLLAMA_HOST=http://localhost:11434
BASE_MODEL=qwen2.5:7b-instruct
BOOST_MODEL=qwen2.5:14b-instruct
EMBEDDING_MODEL=nomic-embed-text
INDEX_PATH=data/index.jsonl
API_KEY=changeme
CORS_ORIGINS=http://localhost:5173,http://synology.local
```
OpenAI 호환 호출 예시:
```bash
curl -s -X POST http://localhost:26000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "X-API-Key: changeme" \
-d '{
"model":"qwen2.5:14b-instruct",
"messages":[{"role":"user","content":"이 서버 기능을 한 줄로 설명"}],
"temperature":0.3
}'
```
## 이 저장소 사용 계획 ## 이 저장소 사용 계획

View File

@@ -1,6 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# load .env if exists
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
export OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434} export OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
export BASE_MODEL=${BASE_MODEL:-qwen2.5:7b-instruct} export BASE_MODEL=${BASE_MODEL:-qwen2.5:7b-instruct}
export BOOST_MODEL=${BOOST_MODEL:-qwen2.5:14b-instruct} export BOOST_MODEL=${BOOST_MODEL:-qwen2.5:14b-instruct}

View File

@@ -1,15 +1,31 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Dict, Any from typing import List, Dict, Any
from .config import settings from .config import settings
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
from .index_store import JsonlIndex from .index_store import JsonlIndex
from .security import require_api_key
from .paperless_client import PaperlessClient
from .utils import chunk_text
app = FastAPI(title="Local AI Server", version="0.1.0") app = FastAPI(title="Local AI Server", version="0.2.1")
# CORS
import os
cors_origins = os.getenv("CORS_ORIGINS", "*")
origins = [o.strip() for o in cors_origins.split(",") if o.strip()] or ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
ollama = OllamaClient(settings.ollama_host) ollama = OllamaClient(settings.ollama_host)
index = JsonlIndex(settings.index_path) index = JsonlIndex(settings.index_path)
@@ -137,8 +153,48 @@ class PaperlessHook(BaseModel):
@app.post("/paperless/hook") @app.post("/paperless/hook")
def paperless_hook(hook: PaperlessHook) -> Dict[str, Any]: def paperless_hook(hook: PaperlessHook, _: None = Depends(require_api_key)) -> Dict[str, Any]:
# NOTE: 확장 지점 - paperless API를 조회하여 문서 텍스트/메타데이터를 받아 # Fetch text from Paperless and upsert into index
# scripts/embed_ollama.py와 동일 로직으로 인덱스를 업데이트할 수 있습니다. client = PaperlessClient(settings.paperless_base_url, settings.paperless_token)
return {"status": "ack", "document_id": hook.document_id} text = client.get_document_text(hook.document_id)
parts = chunk_text(text)
model = settings.embedding_model
from .index_store import IndexRow
to_append = []
for i, t in enumerate(parts):
vec = ollama.embeddings(model, t)
to_append.append(IndexRow(id=f"paperless:{hook.document_id}:{i}", text=t, vector=vec, source="paperless"))
added = index.append(to_append)
return {"status": "indexed", "document_id": hook.document_id, "chunks": added}
# OpenAI-compatible chat completions (minimal)
class ChatCompletionsRequest(BaseModel):
model: str | None = None
messages: List[Dict[str, str]]
temperature: float | None = None
max_tokens: int | None = None
@app.post("/v1/chat/completions")
def chat_completions(req: ChatCompletionsRequest, _: None = Depends(require_api_key)) -> Dict[str, Any]:
chosen = req.model or settings.base_model
opts: Dict[str, Any] = {}
if req.temperature is not None:
opts["temperature"] = req.temperature
# Note: Ollama ignores max_tokens field; left here for interface similarity
resp = ollama.chat(chosen, req.messages, stream=False, options=opts)
# Minimal OpenAI-like response shape
return {
"id": "chatcmpl-local",
"object": "chat.completion",
"model": chosen,
"choices": [
{
"index": 0,
"message": resp.get("message", {"role": "assistant", "content": resp.get("response", "")}),
"finish_reason": resp.get("done_reason", "stop"),
}
],
}

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import os
from typing import Any, Dict
import requests
class PaperlessClient:
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
self.base_url = (base_url or os.getenv("PAPERLESS_BASE_URL", "")).rstrip("/")
self.token = token or os.getenv("PAPERLESS_TOKEN", "")
def _headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Accept": "application/json"}
if self.token:
headers["Authorization"] = f"Token {self.token}"
return headers
def get_document_text(self, doc_id: int) -> str:
if not self.base_url:
raise RuntimeError("PAPERLESS_BASE_URL not configured")
# Example endpoint; adjust to real Paperless API
url = f"{self.base_url}/api/documents/{doc_id}/"
resp = requests.get(url, headers=self._headers(), timeout=60)
resp.raise_for_status()
data = resp.json()
# Prefer content field if available; else title
text = data.get("content", "") or data.get("notes", "") or data.get("title", "")
return text

17
server/security.py Normal file
View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import os
from fastapi import Header, HTTPException
API_KEY_ENV = "API_KEY"
def require_api_key(x_api_key: str | None = Header(default=None)) -> None:
expected = os.getenv(API_KEY_ENV, "")
if not expected:
# No API key configured → allow
return
if not x_api_key or x_api_key != expected:
raise HTTPException(status_code=401, detail="invalid_api_key")

19
server/utils.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import List
def chunk_text(text: str, max_chars: int = 1200, overlap: int = 200) -> List[str]:
chunks: List[str] = []
start = 0
n = len(text)
while start < n:
end = min(start + max_chars, n)
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
if end == n:
break
start = max(0, end - overlap)
return chunks