diff --git a/README.md b/README.md index 70ddd74..66f968b 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,34 @@ curl -s -X POST http://localhost:26000/paperless/hook \ - `EMBEDDING_MODEL`(기본 `nomic-embed-text`) - `INDEX_PATH`(기본 `data/index.jsonl`) - `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 + }' +``` ## 이 저장소 사용 계획 diff --git a/scripts/dev_server.sh b/scripts/dev_server.sh index a8c7bf2..f380225 100755 --- a/scripts/dev_server.sh +++ b/scripts/dev_server.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash 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 BASE_MODEL=${BASE_MODEL:-qwen2.5:7b-instruct} export BOOST_MODEL=${BOOST_MODEL:-qwen2.5:14b-instruct} diff --git a/server/main.py b/server/main.py index 1948427..a14727d 100644 --- a/server/main.py +++ b/server/main.py @@ -1,15 +1,31 @@ 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 typing import List, Dict, Any from .config import settings from .ollama_client import OllamaClient 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) index = JsonlIndex(settings.index_path) @@ -137,8 +153,48 @@ class PaperlessHook(BaseModel): @app.post("/paperless/hook") -def paperless_hook(hook: PaperlessHook) -> Dict[str, Any]: - # NOTE: 확장 지점 - paperless API를 조회하여 문서 텍스트/메타데이터를 받아 - # scripts/embed_ollama.py와 동일 로직으로 인덱스를 업데이트할 수 있습니다. - return {"status": "ack", "document_id": hook.document_id} +def paperless_hook(hook: PaperlessHook, _: None = Depends(require_api_key)) -> Dict[str, Any]: + # Fetch text from Paperless and upsert into index + client = PaperlessClient(settings.paperless_base_url, settings.paperless_token) + 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"), + } + ], + } diff --git a/server/paperless_client.py b/server/paperless_client.py new file mode 100644 index 0000000..9b53369 --- /dev/null +++ b/server/paperless_client.py @@ -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 + diff --git a/server/security.py b/server/security.py new file mode 100644 index 0000000..2a43c1c --- /dev/null +++ b/server/security.py @@ -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") + diff --git a/server/utils.py b/server/utils.py new file mode 100644 index 0000000..5966b7c --- /dev/null +++ b/server/utils.py @@ -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 +