Compare commits
13 Commits
v1-archive
...
0a01e17ea1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a01e17ea1 | ||
|
|
131dbd7b7c | ||
|
|
b338e6e424 | ||
|
|
e48b6a2bb4 | ||
|
|
dd0d7833f6 | ||
|
|
a4f8e56633 | ||
|
|
c79e26e822 | ||
|
|
4b7ddf39c1 | ||
|
|
dc3f03b421 | ||
|
|
f21f950c04 | ||
|
|
5db2f4f6fa | ||
|
|
5fc23e0dbd | ||
|
|
45cabc9aea |
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/
|
||||
|
||||
203
CLAUDE.md
203
CLAUDE.md
@@ -1,137 +1,154 @@
|
||||
# DEVONThink PKM 시스템 — Claude Code 작업 가이드
|
||||
# hyungi_Document_Server — Claude Code 작업 가이드
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
Mac mini M4 Pro(64GB, 4TB) 기반 개인 지식관리(PKM) 시스템.
|
||||
DEVONthink 4를 중앙 허브로, Ollama AI 자동 분류 + 법령 모니터링 + 일일 다이제스트를 자동화한다.
|
||||
Self-hosted PKM(Personal Knowledge Management) 웹 애플리케이션.
|
||||
FastAPI + PostgreSQL(pgvector) + SvelteKit + Docker Compose 기반.
|
||||
Mac mini M4 Pro를 애플리케이션 서버, Synology NAS를 파일 저장소, GPU 서버를 AI 추론에 사용한다.
|
||||
|
||||
## 핵심 문서 (반드시 먼저 읽을 것)
|
||||
## 핵심 문서
|
||||
|
||||
1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 구조, 태그, AI, 자동화 전체)
|
||||
2. `docs/industrial-safety-blueprint.md` — 04_Industrial Safety DB 상세 설계
|
||||
3. `docs/claude-code-commands.md` — 단계별 작업 지시서
|
||||
1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 스키마, AI 전략, 인프라, UI 설계)
|
||||
2. `docs/deploy.md` — Docker Compose 배포 가이드
|
||||
3. `docs/development-stages.md` — Phase 0~5 개발 단계별 가이드
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| 백엔드 | FastAPI (Python 3.11+) |
|
||||
| 데이터베이스 | PostgreSQL 16 + pgvector + pg_trgm |
|
||||
| 프론트엔드 | SvelteKit |
|
||||
| 문서 파싱 | kordoc (Node.js, HWP/HWPX/PDF → Markdown) |
|
||||
| 리버스 프록시 | Caddy (자동 HTTPS) |
|
||||
| 인증 | JWT + TOTP 2FA |
|
||||
| 컨테이너 | Docker Compose |
|
||||
|
||||
## 네트워크 환경
|
||||
|
||||
```
|
||||
Mac mini (운영 서버):
|
||||
- Ollama: http://localhost:11434
|
||||
- DEVONthink: 로컬 실행 중
|
||||
- OmniFocus: 로컬 실행 중
|
||||
Mac mini M4 Pro (애플리케이션 서버):
|
||||
- Docker Compose: FastAPI(:8000), PostgreSQL(:5432), kordoc(:3100), Caddy(:80,:443)
|
||||
- MLX Server: http://localhost:8800/v1/chat/completions (Qwen3.5-35B-A3B)
|
||||
- 외부 접근: pkm.hyungi.net (Caddy 프록시)
|
||||
|
||||
Synology NAS (DS1525+):
|
||||
- 도메인: ds1525.hyungi.net
|
||||
- Tailscale IP: 100.101.79.37
|
||||
- 포트: 15001
|
||||
- WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/
|
||||
- MailPlus IMAP: mailplus.hyungi.net:993 (SSL)
|
||||
- 파일 원본: /volume4/Document_Server/PKM/
|
||||
- Synology Office: 문서 편집/미리보기
|
||||
- Synology Calendar: CalDAV 태스크 관리 (OmniFocus 대체)
|
||||
- MailPlus: IMAP(993) + SMTP(465)
|
||||
|
||||
GPU 서버 (RTX 4070 Ti Super):
|
||||
- 역할: 임베딩(nomic-embed-text), 비전(Qwen2.5-VL-7B), 리랭킹(bge-reranker)
|
||||
- Tailscale IP: 별도 확인 필요
|
||||
|
||||
TKSafety: tksafety.technicalkorea.net (설정만, 나중에 활성화)
|
||||
- AI Gateway: http://gpu-server:8080 (모델 라우팅, 폴백, 비용 제어)
|
||||
- nomic-embed-text: 벡터 임베딩
|
||||
- Qwen2.5-VL-7B: 이미지/도면 OCR
|
||||
- bge-reranker-v2-m3: RAG 리랭킹
|
||||
```
|
||||
|
||||
## 인증 정보
|
||||
|
||||
- 위치: `~/.config/pkm/credentials.env`
|
||||
- 템플릿: `./credentials.env.example`
|
||||
- 스크립트에서 python-dotenv로 로딩
|
||||
|
||||
## DEVONthink DB 구조 (13개)
|
||||
|
||||
```
|
||||
운영 DB (신규 생성 완료):
|
||||
Inbox — 모든 자료 최초 진입점
|
||||
Archive — 이메일, 채팅 로그
|
||||
Projects — 진행 중 프로젝트
|
||||
|
||||
도메인 DB (기존, 유지):
|
||||
00_Note_BOX, 01_Philosophie, 02_Language, 03_Engineering,
|
||||
04_Industrial safety, 05_Programming, 07_General Book,
|
||||
97_Production drawing, 99_Reference Data, 99_Technicalkorea
|
||||
```
|
||||
|
||||
## 커스텀 메타데이터 필드 (DEVONthink에 등록 완료)
|
||||
|
||||
```
|
||||
omnifocusTaskID — Single-Line Text — OmniFocus 역링크
|
||||
sourceURL — URL — 원본 출처
|
||||
synologyPath — Single-Line Text — NAS 원본 경로
|
||||
lastAIProcess — Date — 마지막 AI 처리 일시
|
||||
sourceChannel — Single-Line Text — 유입 경로 (아래 값 중 하나)
|
||||
dataOrigin — Single-Line Text — work 또는 external
|
||||
```
|
||||
|
||||
## sourceChannel 값 (유입 경로 추적)
|
||||
|
||||
```
|
||||
tksafety — TKSafety API (업무 실적) → dataOrigin = work
|
||||
devonagent — DEVONagent 자동 수집 (뉴스) → dataOrigin = external
|
||||
law_monitor — 법령 API (법령 변경) → dataOrigin = external
|
||||
inbox_route — Inbox → AI 분류 → AI 판별
|
||||
email — MailPlus 이메일 → AI 판별
|
||||
web_clip — Web Clipper 스크랩 → dataOrigin = external
|
||||
manual — 직접 추가 → dataOrigin = work (기본)
|
||||
```
|
||||
- 위치: `credentials.env` (프로젝트 루트, .gitignore에 포함)
|
||||
- 템플릿: `credentials.env.example`
|
||||
- 스크립트에서 python-dotenv 또는 Docker env_file로 로딩
|
||||
|
||||
## AI 모델 구성
|
||||
|
||||
```
|
||||
Tier 1 (Mac mini, 상시):
|
||||
mlx-community/Qwen3.5-35B-A3B-4bit — 태그 생성, 문서 분류, 요약
|
||||
→ http://localhost:8800/v1/chat/completions (OpenAI 호환 API)
|
||||
→ MLX 서버로 실행 중 (Ollama 아님)
|
||||
Primary (Mac mini MLX, 상시, 무료):
|
||||
mlx-community/Qwen3.5-35B-A3B-4bit — 분류, 태그, 요약
|
||||
→ http://localhost:8800/v1/chat/completions
|
||||
|
||||
Tier 2 (Claude API, 필요시):
|
||||
Fallback (GPU Ollama, MLX 장애 시):
|
||||
qwen3.5:35b-a3b
|
||||
→ http://gpu-server:11434/v1/chat/completions
|
||||
|
||||
Premium (Claude API, 종량제, 수동 트리거만):
|
||||
claude-sonnet — 복잡한 분석, 장문 처리
|
||||
→ CLAUDE_API_KEY 사용
|
||||
→ 일일 한도 $5, require_explicit_trigger: true
|
||||
|
||||
Tier 3 (GPU 서버, 특수):
|
||||
nomic-embed-text — 벡터 임베딩
|
||||
Qwen2.5-VL-7B — 이미지/도면 OCR
|
||||
bge-reranker-v2-m3 — RAG 리랭킹
|
||||
Embedding (GPU 서버 전용):
|
||||
nomic-embed-text → 벡터 임베딩
|
||||
Qwen2.5-VL-7B → 이미지/도면 OCR
|
||||
bge-reranker-v2-m3 → RAG 리랭킹
|
||||
```
|
||||
|
||||
## 작업 순서
|
||||
## 프로젝트 구조
|
||||
|
||||
docs/claude-code-commands.md의 단계를 순서대로 진행:
|
||||
```
|
||||
hyungi_Document_Server/
|
||||
├── docker-compose.yml ← Mac mini용
|
||||
├── Caddyfile
|
||||
├── config.yaml ← AI 엔드포인트, NAS 경로, 스케줄
|
||||
├── credentials.env.example
|
||||
├── app/ ← FastAPI 백엔드
|
||||
│ ├── main.py
|
||||
│ ├── core/ (config, database, auth, utils)
|
||||
│ ├── models/ (document, task, queue)
|
||||
│ ├── api/ (documents, search, tasks, dashboard, export)
|
||||
│ ├── workers/(file_watcher, extract, classify, embed, law_monitor, mailplus, digest)
|
||||
│ ├── prompts/classify.txt
|
||||
│ └── ai/client.py
|
||||
├── services/kordoc/ ← Node.js 마이크로서비스
|
||||
├── gpu-server/ ← GPU 서버용 (별도 배포)
|
||||
│ ├── docker-compose.yml
|
||||
│ └── services/ai-gateway/
|
||||
├── frontend/ ← SvelteKit
|
||||
├── migrations/ ← PostgreSQL 스키마
|
||||
├── scripts/migrate_from_devonthink.py
|
||||
├── docs/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
1. **프로젝트 구조** — README.md, deploy.md 작성 (구조는 이미 생성됨)
|
||||
2. **Ollama 테스트** — 분류 프롬프트 최적화 → scripts/prompts/에 저장
|
||||
3. **AppleScript** — auto_classify.scpt, omnifocus_sync.scpt
|
||||
4. **법령 모니터링** — scripts/law_monitor.py + launchd plist
|
||||
5. **이메일 수집** — scripts/mailplus_archive.py + launchd plist
|
||||
6. **Daily Digest** — scripts/pkm_daily_digest.py + launchd plist
|
||||
7. **DEVONagent 가이드** — docs/devonagent-setup.md (수동 설정 가이드)
|
||||
8. **테스트** — tests/ + docs/test-report.md
|
||||
## 데이터 3계층
|
||||
|
||||
1. **원본 파일** (NAS `/volume4/Document_Server/PKM/`) — 유일한 진짜 원본
|
||||
2. **가공 데이터** (PostgreSQL) — 텍스트 추출, AI 메타데이터, 검색 인덱스
|
||||
3. **파생물** (pgvector + 캐시) — 벡터 임베딩, 썸네일
|
||||
|
||||
## 코딩 규칙
|
||||
|
||||
- Python 3.11+ (Mac mini 기본)
|
||||
- 인증 정보는 반드시 credentials.env에서 로딩 (하드코딩 금지)
|
||||
- AppleScript는 DEVONthink/OmniFocus와 연동 (osascript로 호출)
|
||||
- 로그는 ~/Documents/code/DEVONThink_my\ server/logs/에 저장
|
||||
- launchd plist는 launchd/ 디렉토리에 생성, Mac mini에서 심볼릭 링크로 등록
|
||||
- Python 3.11+, asyncio, type hints
|
||||
- SQLAlchemy 2.0+ async 세션
|
||||
- 인증 정보는 credentials.env에서 로딩 (하드코딩 금지)
|
||||
- 로그는 `logs/`에 저장 (Docker 볼륨)
|
||||
- AI 호출은 반드시 `app/ai/client.py`의 `AIClient`를 통해 (직접 HTTP 호출 금지)
|
||||
- 한글 주석 사용
|
||||
|
||||
## 배포 방법
|
||||
## 개발/배포 워크플로우
|
||||
|
||||
```
|
||||
MacBook Pro (개발) → Gitea push → Mac mini에서 git pull
|
||||
Mac mini에서:
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
MacBook Pro (개발) → Gitea push → 서버에서 pull
|
||||
|
||||
개발:
|
||||
cd ~/Documents/code/hyungi_Document_Server/
|
||||
# 코드 작성 → git commit & push
|
||||
|
||||
Mac mini 배포:
|
||||
cd ~/Documents/code/hyungi_Document_Server/
|
||||
git pull
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# launchd 등록은 deploy.md 참조
|
||||
docker compose up -d
|
||||
|
||||
GPU 서버 배포:
|
||||
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
|
||||
git pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## v1 코드 참조
|
||||
|
||||
v1(DEVONthink 기반) 코드는 `v1-final` 태그로 보존:
|
||||
```bash
|
||||
git show v1-final:scripts/law_monitor.py
|
||||
git show v1-final:scripts/pkm_utils.py
|
||||
git show v1-final:scripts/prompts/classify_document.txt
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
- credentials.env는 git에 올리지 않음 (.gitignore에 포함)
|
||||
- DEVONthink, OmniFocus는 Mac mini에서 GUI로 실행 중이어야 AppleScript 작동
|
||||
- credentials.env는 git에 올리지 않음 (.gitignore)
|
||||
- NAS SMB 마운트 경로: Docker 컨테이너 내 `/documents`
|
||||
- 법령 API (LAW_OC)는 승인 대기 중 — 스크립트만 만들고 실제 호출은 승인 후
|
||||
- TKSafety 연동은 설계만 완료, 구현은 나중에
|
||||
- GPU 서버 Tailscale IP는 별도 확인 후 credentials.env에 추가
|
||||
- GPU 서버 Tailscale IP는 credentials.env에서 관리
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
74
README.md
74
README.md
@@ -1,40 +1,64 @@
|
||||
# DEVONThink PKM System
|
||||
# hyungi_Document_Server
|
||||
|
||||
Mac mini M4 Pro 기반 개인 지식관리 자동화 시스템
|
||||
Self-hosted 개인 지식관리(PKM) 웹 애플리케이션
|
||||
|
||||
## 구성 요소
|
||||
## 기술 스택
|
||||
|
||||
- **DEVONthink 4** — 중앙 지식 허브 (13개 DB)
|
||||
- **Ollama** — AI 자동 분류/태깅 (Qwen3.5-35B-A3B)
|
||||
- **법령 모니터링** — 산업안전보건법 등 변경 추적
|
||||
- **일일 다이제스트** — PKM 전체 변화 요약
|
||||
- **OmniFocus 연동** — 액션 아이템 자동 생성
|
||||
- **백엔드**: FastAPI + SQLAlchemy (async)
|
||||
- **데이터베이스**: PostgreSQL 16 + pgvector + pg_trgm
|
||||
- **프론트엔드**: SvelteKit
|
||||
- **문서 파싱**: kordoc (HWP/HWPX/PDF → Markdown)
|
||||
- **AI**: Qwen3.5-35B-A3B (MLX), nomic-embed-text, Claude API (폴백)
|
||||
- **인프라**: Docker Compose, Caddy, Synology NAS
|
||||
|
||||
## 설치
|
||||
## 주요 기능
|
||||
|
||||
- 문서 자동 분류/태그/요약 (AI 기반)
|
||||
- 전문검색 + 벡터 유사도 검색
|
||||
- HWP/PDF/Markdown 문서 뷰어
|
||||
- 법령 변경 모니터링 (산업안전보건법 등)
|
||||
- 이메일 자동 수집 (MailPlus IMAP)
|
||||
- 일일 다이제스트
|
||||
- CalDAV 태스크 연동 (Synology Calendar)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Mac mini에서
|
||||
git clone [gitea-repo-url]
|
||||
cd DEVONThink_my\ server
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git
|
||||
cd hyungi_Document_Server
|
||||
|
||||
# 인증 정보 설정
|
||||
mkdir -p ~/.config/pkm
|
||||
cp credentials.env.example ~/.config/pkm/credentials.env
|
||||
nano ~/.config/pkm/credentials.env # 실제 값 입력
|
||||
chmod 600 ~/.config/pkm/credentials.env
|
||||
cp credentials.env.example credentials.env
|
||||
nano credentials.env # 실제 값 입력
|
||||
|
||||
# 실행
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
자세한 배포 방법은 `docs/deploy.md` 참조
|
||||
`http://localhost:8000/docs` 에서 API 문서 확인
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
scripts/ Python 스크립트 (법령모니터, 메일수집, 다이제스트)
|
||||
applescript/ DEVONthink/OmniFocus 연동 AppleScript
|
||||
launchd/ macOS 스케줄 실행 plist
|
||||
docs/ 설계 문서, 가이드
|
||||
tests/ 테스트 코드
|
||||
├── app/ FastAPI 백엔드 (API, 워커, AI 클라이언트)
|
||||
├── frontend/ SvelteKit 프론트엔드
|
||||
├── services/kordoc/ 문서 파싱 마이크로서비스 (Node.js)
|
||||
├── gpu-server/ GPU 서버 배포 (AI Gateway)
|
||||
├── migrations/ PostgreSQL 스키마
|
||||
├── docs/ 설계 문서, 배포 가이드
|
||||
└── tests/ 테스트 코드
|
||||
```
|
||||
|
||||
## 인프라 구성
|
||||
|
||||
| 서버 | 역할 |
|
||||
|------|------|
|
||||
| Mac mini M4 Pro | Docker Compose (FastAPI, PostgreSQL, kordoc, Caddy) + MLX AI |
|
||||
| Synology NAS | 파일 원본 저장, Synology Office/Drive/Calendar/MailPlus |
|
||||
| GPU 서버 | AI Gateway, 벡터 임베딩, OCR, 리랭킹 |
|
||||
|
||||
## 문서
|
||||
|
||||
- [아키텍처](docs/architecture.md) — 전체 시스템 설계
|
||||
- [배포 가이드](docs/deploy.md) — Docker Compose 배포 방법
|
||||
- [개발 단계](docs/development-stages.md) — Phase 0~5 개발 계획
|
||||
|
||||
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
@@ -1,102 +0,0 @@
|
||||
-- DEVONthink 4 Smart Rule: AI 자동 분류
|
||||
-- Inbox DB 새 문서 → Ollama 분류 → 태그 + 메타데이터 + 도메인 DB 이동
|
||||
-- Smart Rule 설정: Event = On Import, 조건 = Tags is empty
|
||||
|
||||
on performSmartRule(theRecords)
|
||||
tell application id "DNtp"
|
||||
repeat with theRecord in theRecords
|
||||
try
|
||||
-- 1. 문서 텍스트 추출 (최대 4000자)
|
||||
set docText to plain text of theRecord
|
||||
set docUUID to uuid of theRecord
|
||||
|
||||
if length of docText > 4000 then
|
||||
set docText to text 1 thru 4000 of docText
|
||||
end if
|
||||
|
||||
if length of docText < 10 then
|
||||
-- 텍스트가 너무 짧으면 건너뜀
|
||||
set tags of theRecord to {"@상태/검토필요"}
|
||||
continue repeat
|
||||
end if
|
||||
|
||||
-- 2. 분류 프롬프트 로딩
|
||||
set promptPath to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/prompts/classify_document.txt"
|
||||
set promptTemplate to do shell script "cat " & quoted form of promptPath
|
||||
|
||||
-- 문서 텍스트를 프롬프트에 삽입 (특수문자 이스케이프)
|
||||
set escapedText to do shell script "echo " & quoted form of docText & " | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g; s/\\n/\\\\n/g' | head -c 4000"
|
||||
|
||||
-- 3. MLX 서버 API 호출 (OpenAI 호환)
|
||||
set curlCmd to "curl -s --max-time 120 http://localhost:8800/v1/chat/completions -H 'Content-Type: application/json' -d '{\"model\": \"mlx-community/Qwen3.5-35B-A3B-4bit\", \"messages\": [{\"role\": \"user\", \"content\": " & quoted form of escapedText & "}], \"temperature\": 0.3, \"max_tokens\": 1024}'"
|
||||
set jsonResult to do shell script curlCmd
|
||||
|
||||
-- 4. JSON 파싱 (Python 사용)
|
||||
set parseCmd to "echo " & quoted form of jsonResult & " | python3 -c \"
|
||||
import sys, json
|
||||
try:
|
||||
r = json.loads(sys.stdin.read())
|
||||
content = r['choices'][0]['message']['content']
|
||||
d = json.loads(content)
|
||||
tags = ','.join(d.get('tags', []))
|
||||
db = d.get('domain_db', '00_Note_BOX')
|
||||
grp = d.get('sub_group', '00_Inbox')
|
||||
ch = d.get('sourceChannel', 'inbox_route')
|
||||
origin = d.get('dataOrigin', 'external')
|
||||
print(f'{db}|{grp}|{tags}|{ch}|{origin}')
|
||||
except:
|
||||
print('00_Note_BOX|00_Inbox||inbox_route|external')
|
||||
\""
|
||||
|
||||
set classResult to do shell script parseCmd
|
||||
set AppleScript's text item delimiters to "|"
|
||||
set resultParts to text items of classResult
|
||||
set targetDB to item 1 of resultParts
|
||||
set targetGroup to item 2 of resultParts
|
||||
set tagString to item 3 of resultParts
|
||||
set sourceChannel to item 4 of resultParts
|
||||
set dataOrigin to item 5 of resultParts
|
||||
set AppleScript's text item delimiters to ""
|
||||
|
||||
-- 5. 태그 설정
|
||||
if tagString is not "" then
|
||||
set AppleScript's text item delimiters to ","
|
||||
set tagList to text items of tagString
|
||||
set AppleScript's text item delimiters to ""
|
||||
set tags of theRecord to tagList
|
||||
end if
|
||||
|
||||
-- 6. 커스텀 메타데이터 설정
|
||||
add custom meta data sourceChannel for "sourceChannel" to theRecord
|
||||
add custom meta data dataOrigin for "dataOrigin" to theRecord
|
||||
add custom meta data (current date) for "lastAIProcess" to theRecord
|
||||
add custom meta data "inbox_route" for "sourceChannel" to theRecord
|
||||
|
||||
-- 7. 대상 도메인 DB로 이동
|
||||
set targetDatabase to missing value
|
||||
repeat with db in databases
|
||||
if name of db is targetDB then
|
||||
set targetDatabase to db
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
|
||||
if targetDatabase is not missing value then
|
||||
set groupPath to "/" & targetGroup
|
||||
set targetLocation to create location groupPath in targetDatabase
|
||||
move record theRecord to targetLocation
|
||||
end if
|
||||
|
||||
-- 8. GPU 서버 벡터 임베딩 비동기 전송
|
||||
set embedScript to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/venv/bin/python3"
|
||||
set embedPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/embed_to_chroma.py"
|
||||
do shell script embedScript & " " & quoted form of embedPy & " " & quoted form of docUUID & " &> /dev/null &"
|
||||
|
||||
on error errMsg
|
||||
-- 에러 시 로그 기록 + 검토필요 태그
|
||||
set tags of theRecord to {"@상태/검토필요", "AI분류실패"}
|
||||
do shell script "echo '[" & (current date) & "] [auto_classify] [ERROR] " & errMsg & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/auto_classify.log"
|
||||
end try
|
||||
end repeat
|
||||
end tell
|
||||
end performSmartRule
|
||||
@@ -1,71 +0,0 @@
|
||||
-- DEVONthink 4 Smart Rule: OmniFocus 연동
|
||||
-- Projects DB 새 문서에서 TODO 패턴 감지 → OmniFocus 작업 생성
|
||||
-- Smart Rule 설정: Event = On Import, DB = Projects
|
||||
|
||||
on performSmartRule(theRecords)
|
||||
tell application id "DNtp"
|
||||
repeat with theRecord in theRecords
|
||||
try
|
||||
set docText to plain text of theRecord
|
||||
set docTitle to name of theRecord
|
||||
set docUUID to uuid of theRecord
|
||||
set docLink to reference URL of theRecord -- x-devonthink-item://UUID
|
||||
|
||||
-- TODO 패턴 감지: "TODO", "할일", "□", "[ ]", "FIXME"
|
||||
set hasAction to false
|
||||
if docText contains "TODO" or docText contains "할일" or docText contains "□" or docText contains "[ ]" or docText contains "FIXME" then
|
||||
set hasAction to true
|
||||
end if
|
||||
|
||||
if not hasAction then continue repeat
|
||||
|
||||
-- 액션 아이템 추출 (Python으로 파싱)
|
||||
set extractCmd to "echo " & quoted form of docText & " | python3 -c \"
|
||||
import sys, re
|
||||
text = sys.stdin.read()
|
||||
patterns = [
|
||||
r'(?:TODO|FIXME|할일)[:\\s]*(.+?)(?:\\n|$)',
|
||||
r'(?:□|\\[ \\])\\s*(.+?)(?:\\n|$)',
|
||||
]
|
||||
items = []
|
||||
for p in patterns:
|
||||
items.extend(re.findall(p, text, re.MULTILINE))
|
||||
# 최대 5개, 중복 제거
|
||||
seen = set()
|
||||
for item in items[:10]:
|
||||
item = item.strip()
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
print(item)
|
||||
if len(seen) >= 5:
|
||||
break
|
||||
\""
|
||||
|
||||
set actionItems to paragraphs of (do shell script extractCmd)
|
||||
|
||||
if (count of actionItems) = 0 then continue repeat
|
||||
|
||||
-- OmniFocus에 작업 생성
|
||||
tell application "OmniFocus"
|
||||
tell default document
|
||||
set taskIDs to {}
|
||||
repeat with actionItem in actionItems
|
||||
set taskName to docTitle & " — " & (contents of actionItem)
|
||||
set newTask to make new inbox task with properties {name:taskName, note:"DEVONthink 문서: " & docLink}
|
||||
set end of taskIDs to id of newTask
|
||||
end repeat
|
||||
end tell
|
||||
end tell
|
||||
|
||||
-- DEVONthink 메타데이터에 OmniFocus Task ID 저장
|
||||
set AppleScript's text item delimiters to ","
|
||||
set taskIDString to taskIDs as text
|
||||
set AppleScript's text item delimiters to ""
|
||||
add custom meta data taskIDString for "omnifocusTaskID" to theRecord
|
||||
|
||||
on error errMsg
|
||||
do shell script "echo '[" & (current date) & "] [omnifocus_sync] [ERROR] " & errMsg & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/omnifocus_sync.log"
|
||||
end try
|
||||
end repeat
|
||||
end tell
|
||||
end performSmartRule
|
||||
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:
|
||||
File diff suppressed because it is too large
Load Diff
2375
docs/architecture.md
2375
docs/architecture.md
File diff suppressed because it is too large
Load Diff
@@ -1,302 +0,0 @@
|
||||
# Claude Code 실행 명령어 — PKM 시스템 구축
|
||||
|
||||
> 작업 위치: MacBook Pro ~/Documents/code/DEVONThink_my server/
|
||||
> Claude Code를 이 디렉토리에서 실행
|
||||
> 완성 후 Gitea에 push → Mac mini에서 pull
|
||||
|
||||
```
|
||||
개발/배포 흐름:
|
||||
MacBook Pro (Claude Code)
|
||||
~/Documents/code/DEVONThink_my server/
|
||||
→ 스크립트/설정 파일 작성
|
||||
→ git commit & push
|
||||
│
|
||||
▼
|
||||
Gitea (Synology NAS)
|
||||
│
|
||||
▼
|
||||
Mac mini (git pull → 실행)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0단계: 프로젝트 구조 생성 + credentials.env 복사
|
||||
|
||||
Claude Code 실행 전에 먼저:
|
||||
|
||||
```bash
|
||||
# MacBook Pro에서
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
|
||||
# credentials.env를 프로젝트에 복사 (gitignore 필수!)
|
||||
cp ~/.config/pkm/credentials.env ./credentials.env.example
|
||||
# example은 값을 비운 템플릿용, 실제 파일은 Mac mini에서 직접 생성
|
||||
|
||||
# Mac mini에서 (SSH 접속 후)
|
||||
mkdir -p ~/.config/pkm
|
||||
nano ~/.config/pkm/credentials.env
|
||||
# → 실제 인증 정보 입력
|
||||
chmod 600 ~/.config/pkm/credentials.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1단계: 프로젝트 구조 + requirements.txt
|
||||
|
||||
```
|
||||
이 프로젝트의 디렉토리 구조를 만들고 기본 설정 파일들을 생성해줘.
|
||||
작업 디렉토리: 현재 디렉토리 (~/Documents/code/DEVONThink_my server/)
|
||||
|
||||
프로젝트 구조:
|
||||
./
|
||||
├── README.md ← 프로젝트 설명
|
||||
├── requirements.txt ← Python 패키지 목록
|
||||
├── .gitignore ← credentials.env, venv, logs, __pycache__ 등 제외
|
||||
├── credentials.env.example ← 인증 정보 템플릿 (값은 비움)
|
||||
├── scripts/
|
||||
│ ├── law_monitor.py
|
||||
│ ├── mailplus_archive.py
|
||||
│ ├── pkm_daily_digest.py
|
||||
│ ├── embed_to_chroma.py
|
||||
│ └── prompts/
|
||||
│ └── classify_document.txt
|
||||
├── applescript/
|
||||
│ ├── auto_classify.scpt
|
||||
│ └── omnifocus_sync.scpt
|
||||
├── launchd/
|
||||
│ ├── net.hyungi.pkm.law-monitor.plist
|
||||
│ ├── net.hyungi.pkm.mailplus.plist
|
||||
│ └── net.hyungi.pkm.daily-digest.plist
|
||||
├── docs/
|
||||
│ ├── devonagent-setup.md
|
||||
│ └── deploy.md ← Mac mini 배포 방법
|
||||
└── tests/
|
||||
└── test_classify.py
|
||||
|
||||
requirements.txt에 넣을 패키지:
|
||||
- chromadb
|
||||
- requests
|
||||
- python-dotenv
|
||||
- schedule
|
||||
- markdown
|
||||
|
||||
.gitignore에 반드시 포함:
|
||||
- credentials.env
|
||||
- venv/
|
||||
- logs/
|
||||
- __pycache__/
|
||||
- *.pyc
|
||||
- .DS_Store
|
||||
|
||||
deploy.md에는 Mac mini에서의 설치 절차 작성:
|
||||
1. git pull
|
||||
2. python3 -m venv venv && source venv/bin/activate
|
||||
3. pip install -r requirements.txt
|
||||
4. credentials.env는 ~/.config/pkm/credentials.env에 별도 관리
|
||||
5. launchd plist 심볼릭 링크 등록 방법
|
||||
|
||||
네트워크 환경:
|
||||
- NAS 도메인: ds1525.hyungi.net (Tailscale: 100.101.79.37, 포트: 15001)
|
||||
- MailPlus: mailplus.hyungi.net:993 (IMAP SSL)
|
||||
- WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/
|
||||
- TKSafety: tksafety.technicalkorea.net (나중에 활성화)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2단계: Ollama 모델 확인 + 분류 프롬프트 테스트
|
||||
|
||||
```
|
||||
Ollama가 정상 동작하는지 확인하고, PKM 문서 분류용 프롬프트를 테스트해줘.
|
||||
|
||||
1. ollama list로 현재 모델 확인
|
||||
2. qwen3.5:35b-a3b 계열 모델이 있는지 확인 (없으면 알려줘)
|
||||
3. 테스트 프롬프트 실행 — 아래 내용으로 분류 테스트:
|
||||
|
||||
테스트 문서: "산업안전보건법 시행규칙 일부개정령안 입법예고 - 고용노동부는 위험성평가에 관한 지침을 개정하여..."
|
||||
|
||||
기대 응답 JSON:
|
||||
{
|
||||
"tags": ["위험성평가", "법령개정", "고용노동부"],
|
||||
"domain_db": "04_Industrial safety",
|
||||
"sub_group": "10_Legislation/Notice",
|
||||
"sourceChannel": "inbox_route",
|
||||
"dataOrigin": "external"
|
||||
}
|
||||
|
||||
도메인 DB 선택지:
|
||||
00_Note_BOX, 01_Philosophie, 02_Language, 03_Engineering,
|
||||
04_Industrial safety, 05_Programming, 07_General Book,
|
||||
97_Production drawing, 99_Reference Data, 99_Technicalkorea
|
||||
|
||||
sourceChannel 값: tksafety, devonagent, law_monitor, inbox_route, email, web_clip, manual
|
||||
dataOrigin 값: work (자사 업무), external (외부 참고)
|
||||
|
||||
프롬프트를 최적화해서 ~/Documents/code/DEVONThink_my server/scripts/prompts/ 디렉토리에 저장해줘.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3단계: DEVONthink Smart Rule + AppleScript 배포
|
||||
|
||||
```
|
||||
DEVONthink 4 Smart Rule용 AppleScript 모듈들을 만들어줘.
|
||||
Mac mini에서 DEVONthink가 실행 중이야.
|
||||
|
||||
모듈 A: Ollama 연동 자동 분류 (~/Documents/code/DEVONThink_my server/applescript/auto_classify.scpt)
|
||||
- DEVONthink Inbox DB에 새 문서가 들어오면 실행
|
||||
- Ollama qwen3.5 35B에 문서 텍스트 전송
|
||||
- 응답에서 tags, domain_db, sub_group, sourceChannel, dataOrigin 파싱
|
||||
- DEVONthink 태그 설정 + 커스텀 메타데이터(sourceChannel, dataOrigin, lastAIProcess) 설정
|
||||
- 해당 도메인 DB의 하위 그룹으로 문서 이동
|
||||
- GPU 서버(Tailscale IP)로 벡터 임베딩 비동기 전송
|
||||
|
||||
모듈 B: OmniFocus 연동 (~/Documents/code/DEVONThink_my server/applescript/omnifocus_sync.scpt)
|
||||
- Projects DB에 새 문서 추가 시 TODO 패턴 감지
|
||||
- OmniFocus에 작업 생성 (DEVONthink 링크 포함)
|
||||
- 커스텀 메타데이터에 omnifocusTaskID 저장
|
||||
|
||||
프롬프트 파일 위치: ~/Documents/code/DEVONThink_my server/scripts/prompts/
|
||||
인증 정보: ~/.config/pkm/credentials.env
|
||||
GPU 서버 Tailscale IP는 별도 확인 필요 (나중에 추가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4단계: 법령 모니터링 스크립트
|
||||
|
||||
```
|
||||
한국 법령 변경 모니터링 스크립트를 만들어줘.
|
||||
|
||||
스크립트: ~/Documents/code/DEVONThink_my server/scripts/law_monitor.py
|
||||
인증: ~/.config/pkm/credentials.env의 LAW_OC 값 사용
|
||||
API: open.law.go.kr OpenAPI
|
||||
|
||||
기능:
|
||||
1. 산업안전보건법, 중대재해처벌법, 관련 시행령/시행규칙/고시 변경 추적
|
||||
2. 변경 감지 시:
|
||||
- 법령 본문(XML) 다운로드 → ~/Documents/code/DEVONThink_my server/data/laws/에 저장
|
||||
- DEVONthink 04_Industrial Safety/10_Legislation/ 하위에 자동 임포트 (AppleScript 호출)
|
||||
- 커스텀 메타데이터: sourceChannel=law_monitor, dataOrigin=external
|
||||
- 로그: ~/Documents/code/DEVONThink_my server/logs/law_monitor.log
|
||||
3. launchd plist 생성: 매일 07:00 실행
|
||||
~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.law-monitor.plist
|
||||
→ ~/Library/LaunchAgents/에 심볼릭 링크
|
||||
|
||||
※ 법령 API 승인 대기중이라 스크립트만 만들고 실제 테스트는 승인 후에
|
||||
※ 해외 법령(US OSHA, JP, EU)은 나중에 추가 예정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5단계: MailPlus → DEVONthink 이메일 수집
|
||||
|
||||
```
|
||||
MailPlus 이메일을 DEVONthink Archive DB로 자동 수집하는 스크립트를 만들어줘.
|
||||
|
||||
스크립트: ~/Documents/code/DEVONThink_my server/scripts/mailplus_archive.py
|
||||
인증: ~/.config/pkm/credentials.env
|
||||
|
||||
접속 정보:
|
||||
- IMAP 서버: mailplus.hyungi.net:993 (SSL)
|
||||
- 계정: hyungi
|
||||
|
||||
기능:
|
||||
1. IMAP으로 MailPlus 접속
|
||||
2. 마지막 동기화 이후 새 메일 가져오기
|
||||
3. DEVONthink Archive DB에 임포트 (AppleScript 호출)
|
||||
4. 커스텀 메타데이터: sourceChannel=email
|
||||
5. 안전 관련 키워드 감지 시 dataOrigin 자동 판별
|
||||
|
||||
launchd: 매일 07:00 + 18:00 실행
|
||||
~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.mailplus.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6단계: Daily Digest 시스템
|
||||
|
||||
```
|
||||
PKM 일일 다이제스트를 자동 생성하는 스크립트를 만들어줘.
|
||||
|
||||
스크립트: ~/Documents/code/DEVONThink_my server/scripts/pkm_daily_digest.py
|
||||
인증: ~/.config/pkm/credentials.env
|
||||
|
||||
기능:
|
||||
1. DEVONthink에서 오늘 추가/수정된 문서 집계 (AppleScript로 쿼리)
|
||||
- DB별 신규 건수
|
||||
- sourceChannel별 구분
|
||||
2. law_monitor 로그에서 법령 변경 건 파싱
|
||||
3. OmniFocus 오늘 완료/추가/기한초과 집계 (AppleScript)
|
||||
4. 상위 뉴스 3건 요약 (Ollama 35B 호출)
|
||||
5. MD 파일 생성 → DEVONthink 00_Note_BOX/Daily_Digest/에 저장
|
||||
파일명: YYYY-MM-DD_digest.md
|
||||
6. OmniFocus 액션 자동 생성 (법령변경, overdue, Inbox 미처리 등)
|
||||
7. 90일 지난 다이제스트 → 90_Archive 이동 (Smart Rule 대체)
|
||||
|
||||
launchd: 매일 20:00 실행
|
||||
~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.daily-digest.plist
|
||||
|
||||
※ Synology Chat 웹훅 알림은 나중에 추가 (CHAT_WEBHOOK_URL 설정 후)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7단계: DEVONagent 검색 세트 (수동 가이드)
|
||||
|
||||
```
|
||||
DEVONagent Pro에서 안전 분야 자동 검색 세트를 설정하는 가이드를 만들어줘.
|
||||
|
||||
총 9개 검색 세트 (7 안전 + 2 기술):
|
||||
1. 국내 산업안전 뉴스 (kosha, moel, safetynews 등)
|
||||
2. 국내 중대재해 뉴스
|
||||
3. KOSHA 가이드/지침
|
||||
4. 국내 산업안전 학술/논문
|
||||
5. US OSHA / Safety+Health Magazine
|
||||
6. JP 厚生労働省 / 安全衛生
|
||||
7. EU-OSHA
|
||||
8. 기술 뉴스 (AI/서버/네트워크)
|
||||
9. 프로그래밍 기술 동향
|
||||
|
||||
각 세트별로:
|
||||
- 검색 키워드/연산자
|
||||
- 사이트 제한
|
||||
- 스케줄 (매일/주간)
|
||||
- 수량 제한 (주간 합계 50~85건 수준)
|
||||
- 결과 → DEVONthink Inbox로 전송 설정 방법
|
||||
|
||||
이건 DEVONagent GUI에서 수동 설정해야 하니까,
|
||||
단계별 가이드 문서를 ~/Documents/code/DEVONThink_my server/docs/devonagent-setup.md로 만들어줘.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8단계: 전체 테스트
|
||||
|
||||
```
|
||||
PKM 시스템 전체 End-to-End 테스트를 진행해줘.
|
||||
|
||||
테스트 항목:
|
||||
1. Ollama 분류 테스트 — 5종 문서(법령, 뉴스, 논문, 메모, 이메일) 분류 정확도
|
||||
2. DEVONthink Smart Rule — Inbox에 테스트 문서 추가 → 자동 분류 확인
|
||||
3. sourceChannel/dataOrigin 메타데이터가 정상 설정되는지
|
||||
4. OmniFocus 연동 — TODO 패턴 문서 → 작업 자동 생성
|
||||
5. MailPlus IMAP 접속 테스트
|
||||
6. launchd 스케줄 등록 확인 (launchctl list | grep pkm)
|
||||
7. Daily Digest 수동 실행 테스트
|
||||
|
||||
각 항목 pass/fail 리포트를 ~/Documents/code/DEVONThink_my server/docs/test-report.md로 저장해줘.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고: 네트워크 환경
|
||||
|
||||
```
|
||||
Mac mini 접속: SSH (MacBook Pro → Mac mini)
|
||||
NAS 도메인: ds1525.hyungi.net (Tailscale: 100.101.79.37, 포트: 15001)
|
||||
MailPlus: mailplus.hyungi.net:993 (IMAP SSL)
|
||||
WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/
|
||||
TKSafety: tksafety.technicalkorea.net (나중에 활성화)
|
||||
내부 네트워크: Tailscale VPN 연결됨
|
||||
```
|
||||
184
docs/deploy.md
184
docs/deploy.md
@@ -1,96 +1,154 @@
|
||||
# Mac mini 배포 가이드
|
||||
# 배포 가이드
|
||||
|
||||
## 1. 초기 설치
|
||||
## 1. 사전 요구사항
|
||||
|
||||
- Docker & Docker Compose (Mac mini)
|
||||
- NAS SMB 마운트 (`/Volumes/Document_Server`)
|
||||
- Tailscale VPN 연결 (Mac mini ↔ GPU 서버 ↔ NAS)
|
||||
|
||||
## 2. Mac mini 배포
|
||||
|
||||
### 2-1. 코드 가져오기
|
||||
|
||||
```bash
|
||||
# Mac mini에서
|
||||
cd ~/Documents/code/
|
||||
git clone https://git.hyungi.net/hyungi/devonthink_home.git "DEVONThink_my server"
|
||||
cd "DEVONThink_my server"
|
||||
|
||||
# Python 가상환경
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git hyungi_Document_Server
|
||||
cd hyungi_Document_Server
|
||||
```
|
||||
|
||||
## 2. 인증 정보 설정
|
||||
### 2-2. 인증 정보 설정
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/pkm
|
||||
nano ~/.config/pkm/credentials.env
|
||||
chmod 600 ~/.config/pkm/credentials.env
|
||||
cp credentials.env.example credentials.env
|
||||
nano credentials.env # 실제 값 입력
|
||||
chmod 600 credentials.env
|
||||
```
|
||||
|
||||
credentials.env.example을 참고하여 실제 값 입력.
|
||||
필수 값: `POSTGRES_PASSWORD`, `JWT_SECRET`, `TOTP_SECRET`, `MLX_ENDPOINT`
|
||||
선택 값: `CLAUDE_API_KEY`, `LAW_OC` (법령 API 승인 후)
|
||||
|
||||
## 3. launchd 스케줄 등록
|
||||
### 2-3. NAS SMB 마운트 확인
|
||||
|
||||
```bash
|
||||
# 심볼릭 링크 생성
|
||||
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.law-monitor.plist ~/Library/LaunchAgents/
|
||||
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.mailplus.plist ~/Library/LaunchAgents/
|
||||
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.daily-digest.plist ~/Library/LaunchAgents/
|
||||
|
||||
# 등록
|
||||
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist
|
||||
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.mailplus.plist
|
||||
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.daily-digest.plist
|
||||
# macOS에서 SMB 마운트 (Finder 또는 CLI)
|
||||
mount -t smbfs //hyungi@ds1525.hyungi.net/Document_Server /Volumes/Document_Server
|
||||
|
||||
# 확인
|
||||
launchctl list | grep pkm
|
||||
ls /Volumes/Document_Server/PKM/
|
||||
```
|
||||
|
||||
## 4. 수동 테스트
|
||||
Docker 컨테이너에서 이 경로를 `/documents`로 바인드 마운트한다.
|
||||
|
||||
### 2-4. 서비스 시작
|
||||
|
||||
```bash
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
source venv/bin/activate
|
||||
docker compose up -d
|
||||
|
||||
# 각 스크립트 수동 실행
|
||||
python3 scripts/law_monitor.py
|
||||
python3 scripts/mailplus_archive.py
|
||||
python3 scripts/pkm_daily_digest.py
|
||||
# 상태 확인
|
||||
docker compose ps
|
||||
docker compose logs -f fastapi
|
||||
```
|
||||
|
||||
## 5. DEVONthink Smart Rule 설정
|
||||
|
||||
1. DEVONthink → Preferences → Smart Rules
|
||||
2. 새 Rule: "AI Auto Classify"
|
||||
- Event: On Import
|
||||
- Database: Inbox
|
||||
- Condition: Tags is empty
|
||||
- Action: Execute Script → External → `applescript/auto_classify.scpt`
|
||||
3. 새 Rule: "OmniFocus Sync"
|
||||
- Event: On Import
|
||||
- Database: Projects
|
||||
- Action: Execute Script → External → `applescript/omnifocus_sync.scpt`
|
||||
|
||||
## 6. 업데이트
|
||||
### 2-5. 확인
|
||||
|
||||
```bash
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
# FastAPI OpenAPI 문서
|
||||
curl http://localhost:8000/docs
|
||||
|
||||
# PostgreSQL 테이블 확인
|
||||
docker compose exec postgres psql -U pkm -d pkm -c '\dt'
|
||||
|
||||
# kordoc 헬스체크
|
||||
curl http://localhost:3100/health
|
||||
```
|
||||
|
||||
### 2-6. 외부 접근 (Caddy)
|
||||
|
||||
Caddy가 자동으로 HTTPS 인증서를 발급한다.
|
||||
- `pkm.hyungi.net` → FastAPI (:8000)
|
||||
- `office.hyungi.net` → Synology Office (NAS 프록시)
|
||||
|
||||
DNS 레코드가 Mac mini의 공인 IP를 가리켜야 한다.
|
||||
|
||||
## 3. GPU 서버 배포
|
||||
|
||||
### 3-1. AI Gateway + Ollama
|
||||
|
||||
```bash
|
||||
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
|
||||
cp ../credentials.env .env # 필요한 값만 복사
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3-2. 모델 확인
|
||||
|
||||
```bash
|
||||
# Ollama 모델 목록
|
||||
docker compose exec ollama ollama list
|
||||
|
||||
# 필요 모델 pull
|
||||
docker compose exec ollama ollama pull nomic-embed-text
|
||||
docker compose exec ollama ollama pull qwen2.5-vl:7b
|
||||
docker compose exec ollama ollama pull bge-reranker-v2-m3
|
||||
```
|
||||
|
||||
### 3-3. AI Gateway 확인
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## 4. 업데이트
|
||||
|
||||
```bash
|
||||
# Mac mini
|
||||
cd ~/Documents/code/hyungi_Document_Server/
|
||||
git pull
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
docker compose up -d --build
|
||||
|
||||
# GPU 서버
|
||||
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 7. 로그 확인
|
||||
## 5. 로그 확인
|
||||
|
||||
```bash
|
||||
# 스크립트 로그
|
||||
tail -f logs/law_monitor.log
|
||||
tail -f logs/mailplus.log
|
||||
tail -f logs/digest.log
|
||||
# FastAPI 로그
|
||||
docker compose logs -f fastapi
|
||||
|
||||
# launchd 로그
|
||||
tail -f logs/law_monitor_launchd.log
|
||||
# 특정 워커 로그
|
||||
docker compose logs -f fastapi | grep law_monitor
|
||||
docker compose logs -f fastapi | grep mailplus
|
||||
docker compose logs -f fastapi | grep digest
|
||||
|
||||
# PostgreSQL 로그
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
## 실행 스케줄
|
||||
## 6. 자동화 스케줄 (APScheduler)
|
||||
|
||||
| 스크립트 | 시간 | 용도 |
|
||||
|---------|------|------|
|
||||
| law_monitor.py | 매일 07:00 | 법령 변경 모니터링 |
|
||||
| mailplus_archive.py | 매일 07:00, 18:00 | 이메일 수집 |
|
||||
| pkm_daily_digest.py | 매일 20:00 | 일일 다이제스트 |
|
||||
Docker 내부에서 APScheduler로 관리 (launchd 대체):
|
||||
|
||||
| 시간 | 작업 | 주기 |
|
||||
|------|------|------|
|
||||
| 07:00 | law_monitor | 매일 |
|
||||
| 07:00, 18:00 | mailplus_archive | 매일 2회 |
|
||||
| 20:00 | daily_digest | 매일 |
|
||||
| */5분 | file_watcher | 상시 |
|
||||
| */10분 | processing_queue consumer | 상시 |
|
||||
|
||||
## 7. 백업
|
||||
|
||||
### 우선순위
|
||||
|
||||
1. **NAS 원본 파일** — Synology Drive 버전 이력 + Hyper Backup
|
||||
2. **PostgreSQL** — `pg_dump` 정기 백업
|
||||
3. **Docker volumes** — pgdata, caddy_data
|
||||
|
||||
### PostgreSQL 백업
|
||||
|
||||
```bash
|
||||
docker compose exec postgres pg_dump -U pkm pkm > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
142
docs/development-stages.md
Normal file
142
docs/development-stages.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 개발 단계 가이드
|
||||
|
||||
> 작업 위치: MacBook Pro `~/Documents/code/hyungi_Document_Server/`
|
||||
> 개발/배포: MacBook Pro (Claude Code) → Gitea push → 서버에서 pull
|
||||
> 설계 원본: `docs/architecture.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: 기반 구축 (1~2주)
|
||||
|
||||
### 산출물
|
||||
- `docker compose up -d` → postgres, fastapi, kordoc, caddy 구동
|
||||
- DB 스키마 자동 생성 (`migrations/001_initial_schema.sql`)
|
||||
- JWT + TOTP 인증 작동 (로그인, 토큰 갱신)
|
||||
- NAS SMB 마운트 검증 (Docker 컨테이너에서 `/documents` 읽기/쓰기)
|
||||
- `config.yaml` 로딩 검증
|
||||
|
||||
### 핵심 파일
|
||||
- `app/main.py` — FastAPI 앱 엔트리포인트 + lifespan + APScheduler
|
||||
- `app/core/config.py` — Pydantic settings (config.yaml + credentials.env 로딩)
|
||||
- `app/core/database.py` — SQLAlchemy async engine + session factory
|
||||
- `app/core/auth.py` — JWT 발급/검증 + TOTP 2FA
|
||||
- `migrations/001_initial_schema.sql` — documents, tasks, processing_queue 테이블
|
||||
|
||||
### 완료 기준
|
||||
- [ ] `curl localhost:8000/docs` → OpenAPI 문서 반환
|
||||
- [ ] 로그인 플로우 성공 (JWT 발급 + TOTP 검증)
|
||||
- [ ] `psql`로 DB 테이블 3개 존재 확인 (documents, tasks, processing_queue)
|
||||
- [ ] Docker 컨테이너에서 NAS 파일 읽기/쓰기 정상
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 데이터 마이그레이션 (1~2주)
|
||||
|
||||
### 산출물
|
||||
- `scripts/migrate_from_devonthink.py` — DEVONthink → NAS 폴더 구조 생성 + 파일 이동 + DB 등록
|
||||
- kordoc-service 컨테이너 구동, 텍스트 추출 작동
|
||||
- 배치: 전 문서 텍스트 추출 → AI 분류 → 벡터 임베딩
|
||||
|
||||
### 핵심 파일
|
||||
- `scripts/migrate_from_devonthink.py` — 마이그레이션 스크립트
|
||||
- `services/kordoc/server.js` — HWP/PDF 파싱 HTTP API
|
||||
- `app/workers/extract_worker.py` — kordoc 호출, DB에 extracted_text 저장
|
||||
- `app/workers/classify_worker.py` — MLX로 AI 분류/태그/요약
|
||||
- `app/workers/embed_worker.py` — GPU 서버로 벡터 임베딩
|
||||
|
||||
### 완료 기준
|
||||
- [ ] PostgreSQL 문서 수 = DEVONthink 문서 수
|
||||
- [ ] 텍스트 추출 성공률 >95%
|
||||
- [ ] 20건 분류 spot-check 통과 (도메인, 태그 정확도)
|
||||
- [ ] 벡터 임베딩 정상 생성 (embedding 컬럼 NOT NULL 비율)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 핵심 기능 (2~3주)
|
||||
|
||||
### 산출물
|
||||
- 문서 CRUD API (`/api/documents/`)
|
||||
- 전문검색 + 벡터검색 API (`/api/search/`)
|
||||
- 문서 뷰어: PDF(pdf.js), Markdown, Synology Office iframe, HWP(kordoc Markdown)
|
||||
- Inbox 자동분류 파이프라인 (감지→추출→분류→임베딩→폴더 이동)
|
||||
- 파일 변경 감지 (해시 비교 → 재가공)
|
||||
|
||||
### 핵심 파일
|
||||
- `app/api/documents.py` — 문서 CRUD
|
||||
- `app/api/search.py` — GIN/pg_trgm + pgvector 검색
|
||||
- `app/workers/file_watcher.py` — NAS 파일 변경 감지
|
||||
- `frontend/src/routes/documents/+page.svelte` — 문서 탐색
|
||||
- `frontend/src/lib/components/DocumentViewer.svelte` — 포맷별 뷰어
|
||||
|
||||
### 완료 기준
|
||||
- [ ] 검색 API가 ranked 결과 반환
|
||||
- [ ] Inbox에 파일 업로드 → 자동 분류 + Knowledge 폴더 이동 확인
|
||||
- [ ] PDF, Markdown, HWP 뷰어 정상 렌더링
|
||||
- [ ] 파일 수정 후 해시 변경 감지 → 재가공 큐 등록
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 자동화 이전 (1~2주)
|
||||
|
||||
### 산출물
|
||||
- `app/workers/law_monitor.py` — 법령 변경 → NAS 저장 + DB 등록 + CalDAV 태스크
|
||||
- `app/workers/mailplus_archive.py` — IMAP 수집 → NAS 저장 + DB 등록 + SMTP 알림
|
||||
- `app/workers/daily_digest.py` — PostgreSQL/CalDAV 쿼리 → Markdown 생성 + SMTP 발송
|
||||
- APScheduler 스케줄 등록 (07:00, 07:00+18:00, 20:00)
|
||||
- CalDAV 태스크 연동 (Synology Calendar)
|
||||
|
||||
### v1→v2 코드 재활용
|
||||
v1 코드 참조: `git show v1-final:scripts/<파일명>`
|
||||
|
||||
| v1 | v2 | 변경 |
|
||||
|-----|-----|------|
|
||||
| `scripts/law_monitor.py` | `app/workers/law_monitor.py` | `import_to_devonthink()` → `save_to_nas()` + `register_in_db()` + `create_caldav_task()` |
|
||||
| `scripts/mailplus_archive.py` | `app/workers/mailplus_archive.py` | `import_to_devonthink()` → `save_to_nas()` + `register_in_db()` + `send_smtp_notification()` |
|
||||
| `scripts/pkm_daily_digest.py` | `app/workers/daily_digest.py` | DEVONthink/OmniFocus 쿼리 → PostgreSQL/CalDAV 쿼리 |
|
||||
| `scripts/pkm_utils.py` | `app/core/utils.py` | `run_applescript*()` 제거, 나머지 유지 |
|
||||
|
||||
### 완료 기준
|
||||
- [ ] 법령 모니터 실행 → NAS 파일 + DB 레코드 + CalDAV VTODO 생성
|
||||
- [ ] 이메일 수집 → NAS 저장 + DB 등록 정상
|
||||
- [ ] 다이제스트 → Markdown 생성 + SMTP 발송 확인
|
||||
- [ ] APScheduler 스케줄 3개 정상 등록 확인
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: UI 완성 (2~3주)
|
||||
|
||||
### 산출물
|
||||
- 대시보드 위젯: 오늘 할일, Inbox 미분류, 법령 알림, 최근 문서, 시스템 상태
|
||||
- 태그/폴더 탐색 사이드바
|
||||
- 메타데이터 패널 (AI 요약, pgvector 관련 문서 5건, 가공 이력)
|
||||
- Inbox 분류 UI (수동 오버라이드 + 배치 승인)
|
||||
- 반응형 모바일 대응
|
||||
- 내보내기 API (Markdown → DOCX/HWPX via kordoc)
|
||||
|
||||
### 핵심 파일
|
||||
- `app/api/dashboard.py`, `tasks.py`, `export.py`
|
||||
- `frontend/src/lib/components/Sidebar.svelte`, `MetadataPanel.svelte`, `TaskWidget.svelte`
|
||||
- `frontend/src/routes/inbox/+page.svelte`
|
||||
- `frontend/src/routes/settings/+page.svelte`
|
||||
|
||||
### 완료 기준
|
||||
- [ ] 전체 워크플로우: 로그인 → 대시보드 → 검색 → 문서 조회 → 태스크 → Inbox 분류
|
||||
- [ ] 모바일 브라우저에서 정상 표시
|
||||
- [ ] 내보내기 API로 DOCX 생성 확인
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: DEVONthink 퇴역 (2주)
|
||||
|
||||
### 산출물
|
||||
- 2주간 v1+v2 병행 운영
|
||||
- 비교 리포트: 문서 수, 검색 품질, 자동화 안정성
|
||||
- Mac mini: main 브랜치 전환 + `docker compose up -d`
|
||||
- 기존 launchd plist 해제 (`launchctl unload`)
|
||||
- DEVONthink DB 종료
|
||||
- NAS `Document_Server/DEVONThink/` 아카이브
|
||||
|
||||
### 완료 기준
|
||||
- [ ] Mac mini `docker compose up -d` 후 전체 기능 정상
|
||||
- [ ] DEVONthink 없이 1주 운영 안정
|
||||
- [ ] 모든 자동화(법령, 이메일, 다이제스트) 정상 실행
|
||||
@@ -1,103 +0,0 @@
|
||||
# DEVONagent Pro — 검색 세트 설정 가이드
|
||||
|
||||
DEVONagent Pro에서 안전 분야 + 기술 분야 자동 검색 세트를 설정합니다.
|
||||
주간 합계 50~85건 수준으로 양을 조절합니다.
|
||||
|
||||
## 공통 설정
|
||||
|
||||
- **Schedule**: 각 세트별 지정 (매일/주간)
|
||||
- **Action**: Import to DEVONthink → Inbox DB
|
||||
- **Max Results per Run**: 각 세트별 지정
|
||||
- **Language**: 해당 언어
|
||||
|
||||
---
|
||||
|
||||
## 검색 세트 1: 국내 산업안전 뉴스 (매일)
|
||||
|
||||
- **키워드**: `산업안전 OR 중대재해 OR 위험성평가 OR 안전사고`
|
||||
- **사이트**: kosha.or.kr, moel.go.kr, safetynews.co.kr, dailysafety.com
|
||||
- **Max Results**: 5/일
|
||||
- **Schedule**: 매일 08:00
|
||||
|
||||
## 검색 세트 2: 국내 중대재해 뉴스 (매일)
|
||||
|
||||
- **키워드**: `중대재해 OR 산업재해 OR 작업장사고 -주식 -부동산`
|
||||
- **사이트**: 뉴스 전체
|
||||
- **Max Results**: 3/일
|
||||
- **Schedule**: 매일 08:30
|
||||
|
||||
## 검색 세트 3: KOSHA 가이드/지침 (주간)
|
||||
|
||||
- **키워드**: `site:kosha.or.kr 가이드 OR 지침 OR 기술자료`
|
||||
- **Max Results**: 5/주
|
||||
- **Schedule**: 매주 월요일 09:00
|
||||
|
||||
## 검색 세트 4: 국내 산업안전 학술/논문 (주간)
|
||||
|
||||
- **키워드**: `산업안전 OR 위험성평가 OR occupational safety site:kci.go.kr OR site:dbpia.co.kr`
|
||||
- **Max Results**: 3/주
|
||||
- **Schedule**: 매주 수요일 09:00
|
||||
|
||||
## 검색 세트 5: US OSHA / Safety+Health Magazine (주간)
|
||||
|
||||
- **키워드**: `occupational safety OR workplace hazard OR OSHA regulation`
|
||||
- **사이트**: osha.gov, safetyandhealthmagazine.com, ehstoday.com
|
||||
- **Max Results**: 5/주
|
||||
- **Language**: English
|
||||
- **Schedule**: 매주 화요일 09:00
|
||||
|
||||
## 검색 세트 6: JP 厚生労働省 / 安全衛生 (주간)
|
||||
|
||||
- **키워드**: `労働安全 OR 安全衛生 OR 労災`
|
||||
- **사이트**: mhlw.go.jp, jisha.or.jp
|
||||
- **Max Results**: 3/주
|
||||
- **Language**: Japanese
|
||||
- **Schedule**: 매주 목요일 09:00
|
||||
|
||||
## 검색 세트 7: EU-OSHA (월간)
|
||||
|
||||
- **키워드**: `occupational safety health EU regulation`
|
||||
- **사이트**: osha.europa.eu
|
||||
- **Max Results**: 5/월
|
||||
- **Language**: English
|
||||
- **Schedule**: 매월 1일 09:00
|
||||
|
||||
## 검색 세트 8: 기술 뉴스 — AI/서버/네트워크 (매일)
|
||||
|
||||
- **키워드**: `AI model release OR server infrastructure OR homelab OR self-hosted`
|
||||
- **사이트**: news.ycombinator.com, arstechnica.com, theregister.com
|
||||
- **Max Results**: 5/일
|
||||
- **Schedule**: 매일 12:00
|
||||
|
||||
## 검색 세트 9: 프로그래밍 기술 동향 (주간)
|
||||
|
||||
- **키워드**: `Python release OR Node.js update OR Docker best practice OR FastAPI`
|
||||
- **사이트**: dev.to, blog.python.org, nodejs.org
|
||||
- **Max Results**: 5/주
|
||||
- **Schedule**: 매주 금요일 12:00
|
||||
|
||||
---
|
||||
|
||||
## 주간 예상 건수
|
||||
|
||||
| 세트 | 빈도 | 건/주 |
|
||||
|------|------|-------|
|
||||
| 1. 국내 안전 뉴스 | 매일 5 | ~35 |
|
||||
| 2. 중대재해 뉴스 | 매일 3 | ~21 |
|
||||
| 3. KOSHA 가이드 | 주간 5 | 5 |
|
||||
| 4. 학술/논문 | 주간 3 | 3 |
|
||||
| 5. US OSHA | 주간 5 | 5 |
|
||||
| 6. JP 안전위생 | 주간 3 | 3 |
|
||||
| 7. EU-OSHA | 월간 5 | ~1 |
|
||||
| 8. 기술 뉴스 | 매일 5 | ~35 |
|
||||
| 9. 프로그래밍 | 주간 5 | 5 |
|
||||
| **합계** | | **~113** |
|
||||
|
||||
> 양이 너무 많으면 세트 1, 2, 8의 Max Results를 3으로 줄이면 주간 ~65건 수준으로 조절 가능.
|
||||
|
||||
## DEVONthink 전송 설정
|
||||
|
||||
1. DEVONagent → Preferences → DEVONthink
|
||||
2. Target Database: **Inbox**
|
||||
3. Auto-Tag: 검색 세트 이름으로 자동 태그 (`devonagent-검색세트명`)
|
||||
4. DEVONthink Smart Rule이 Inbox에서 자동 분류 처리
|
||||
@@ -1,684 +0,0 @@
|
||||
# 04_Industrial Safety — DEVONthink DB 상세 설계서
|
||||
|
||||
> 메인 아키텍처: [mac-mini-pkm-architecture.md](computer:///sessions/amazing-vigilant-hypatia/mnt/outputs/mac-mini-pkm-architecture.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## 1. DB 그룹 구조
|
||||
|
||||
```
|
||||
04_Industrial Safety/
|
||||
├── 00_Inbox ← 2차 분류 대기
|
||||
├── 10_Legislation ← 법령, 고시, 행정규칙
|
||||
│ ├── Act ← 산업안전보건법 등 법률 원문
|
||||
│ ├── Decree ← 시행령, 시행규칙
|
||||
│ ├── Notice ← 고시, 지침, 예규, 가이드라인
|
||||
│ ├── SAPA ← 중대재해처벌법 (별도 법 체계)
|
||||
│ ├── KR_Archive ← 개정 이력 자동 수집 (법령 API)
|
||||
│ └── Foreign ← 해외 법령 (참고용)
|
||||
│ ├── US ← OSHA Standards, CFR Title 29
|
||||
│ ├── JP ← 労働安全衛生法
|
||||
│ └── EU ← EU-OSHA Directives, REACH
|
||||
├── 20_Theory ← 이론서, 교과서, 학습 자료
|
||||
├── 30_Papers ← 학술 논문, 연구 보고서
|
||||
├── 40_Cases ← 사고 사례, 재해 분석
|
||||
│ ├── Domestic ← 국내 사례
|
||||
│ └── International ← 해외 사례
|
||||
├── 50_Practice ← 실무 문서 (현장 업무)
|
||||
│ ├── Risk_Assessment ← 위험성평가
|
||||
│ ├── Patrol_Inspection ← 순회점검
|
||||
│ ├── Safety_Plan ← 안전관리계획서
|
||||
│ ├── Education ← 안전교육 자료
|
||||
│ ├── Checklist ← 점검표, 체크리스트
|
||||
│ ├── Contractor_Management ← 도급/수급업체 안전관리
|
||||
│ ├── Permit_to_Work ← 작업허가서 (화기, 밀폐, 고소 등)
|
||||
│ ├── Emergency_Plan ← 비상조치계획, 대피/소방훈련
|
||||
│ └── PPE ← 보호구 관리, 선정 기준, 지급 대장
|
||||
├── 60_Compliance ← 신고, 보고, 감독 (실제 행정 문서)
|
||||
│ ├── Report ← 산재 신고, 중대재해 보고
|
||||
│ ├── Audit ← 감독 결과, 시정명령
|
||||
│ └── Certification ← 자격증, 인증 관련
|
||||
├── 70_Safety_Manager ← 안전관리자 직무 전용
|
||||
│ ├── Appointment ← 선임 서류, 자격 관련
|
||||
│ ├── Duty_Record ← 직무수행 기록, 월간/분기 보고
|
||||
│ ├── Meeting ← 산업안전보건위원회, 회의록
|
||||
│ ├── Inspection ← 안전관리자 점검 기록
|
||||
│ └── Improvement ← 개선 요청, 시정 조치 이력
|
||||
├── 75_Health_Manager ← 보건관리자 직무 전용
|
||||
│ ├── Appointment ← 선임 서류, 자격 관련
|
||||
│ ├── Duty_Record ← 직무수행 기록, 월간/분기 보고
|
||||
│ ├── Work_Environment ← 작업환경측정, 유해인자 관리
|
||||
│ ├── Health_Checkup ← 건강검진 관리, 사후관리
|
||||
│ ├── MSDS ← 물질안전보건자료 관리
|
||||
│ ├── Ergonomics ← 근골격계 유해요인조사, 직업병 예방
|
||||
│ └── Mental_Health ← 직무스트레스 평가, 감정노동, 심리상담
|
||||
├── 80_Reference ← 규격, 기준, 매뉴얼
|
||||
│ ├── Standards ← KS, ISO, KOSHA Guide
|
||||
│ └── Manual ← 장비 매뉴얼, 작업 지침서
|
||||
└── 90_Archive ← 폐기 법령, 구버전 자료
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. AI 2차 분류 라우팅 (태그 → 그룹 매핑)
|
||||
|
||||
Inbox에서 1차 분류로 이 DB에 도착한 문서를, AI가 태그와 본문 키워드를 보고 하위 그룹까지 자동 이동시킵니다.
|
||||
|
||||
```
|
||||
태그 조합 → 이동 대상 그룹
|
||||
──────────────────────────────────────────────────────────
|
||||
$유형/법령 → 10_Legislation/
|
||||
$유형/법령 + #주제/산업안전/법령 → 10_Legislation/
|
||||
├── 텍스트에 "법률" "법" 포함 → Act/
|
||||
├── 텍스트에 "시행령" "시행규칙" 포함 → Decree/
|
||||
├── 텍스트에 "고시" "지침" "예규" 포함 → Notice/
|
||||
└── 텍스트에 "중대재해처벌" 포함 → SAPA/
|
||||
|
||||
$유형/논문 → 30_Papers/
|
||||
|
||||
#주제/산업안전/사고사례 → 40_Cases/
|
||||
├── sourceURL에 kosha.or.kr 포함 → Domestic/
|
||||
└── sourceURL에 osha.gov 등 포함 → International/
|
||||
|
||||
#주제/산업안전/위험성평가 → 50_Practice/Risk_Assessment/
|
||||
#주제/산업안전/순회점검 → 50_Practice/Patrol_Inspection/
|
||||
#주제/산업안전/안전교육 → 50_Practice/Education/
|
||||
$유형/체크리스트 → 50_Practice/Checklist/
|
||||
키워드: "도급" "수급" "협력업체" → 50_Practice/Contractor_Management/
|
||||
키워드: "작업허가" "화기" "밀폐" → 50_Practice/Permit_to_Work/
|
||||
키워드: "비상" "대피" "소방" → 50_Practice/Emergency_Plan/
|
||||
키워드: "보호구" "안전화" "안전모" → 50_Practice/PPE/
|
||||
|
||||
#주제/산업안전/신고보고 → 60_Compliance/Report/
|
||||
키워드: "감독" "시정명령" → 60_Compliance/Audit/
|
||||
키워드: "자격증" "인증" "면허" → 60_Compliance/Certification/
|
||||
|
||||
#주제/산업안전/안전관리자 → 70_Safety_Manager/
|
||||
├── "선임" "자격" → Appointment/
|
||||
├── "직무수행" "월간보고" → Duty_Record/
|
||||
├── "위원회" "회의록" → Meeting/
|
||||
├── "점검" "순회" → Inspection/
|
||||
└── "개선" "시정" → Improvement/
|
||||
|
||||
#주제/산업안전/보건관리자 → 75_Health_Manager/
|
||||
├── "선임" "자격" → Appointment/
|
||||
├── "작업환경측정" "유해인자" → Work_Environment/
|
||||
├── "건강검진" "사후관리" → Health_Checkup/
|
||||
├── "MSDS" "물질안전" → MSDS/
|
||||
├── "근골격계" "직업병" → Ergonomics/
|
||||
└── "스트레스" "감정노동" → Mental_Health/
|
||||
|
||||
#주제/산업안전/규격기준 → 80_Reference/Standards/
|
||||
|
||||
분류 불가 → 00_Inbox/ (수동 리뷰 대기)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 법령 자동 수집 및 변경 알림 시스템
|
||||
|
||||
### 3.1 모니터링 대상 법령
|
||||
|
||||
```
|
||||
🇰🇷 한국 (필수) — 국가법령정보센터 Open API (open.law.go.kr)
|
||||
─────────────────────────────────────────
|
||||
· 산업안전보건법 (법률/시행령/시행규칙)
|
||||
· 중대재해 처벌 등에 관한 법률 (법률/시행령)
|
||||
· 건설기술 진흥법
|
||||
· 화학물질관리법 / 화학물질의 등록 및 평가 등에 관한 법률
|
||||
· 위험물안전관리법
|
||||
· KOSHA Guide (한국산업안전보건공단 기술지침)
|
||||
· 고용노동부 고시/지침 (관련 행정규칙)
|
||||
|
||||
🇺🇸 미국 (참고) — Federal Register API + OSHA
|
||||
─────────────────────────────────────────
|
||||
· OSHA Standards (29 CFR 1910 일반산업, 1926 건설)
|
||||
· Federal Register: OSHA 관련 규칙 제정/개정 공지
|
||||
· NIOSH 권고사항 (새 출판물)
|
||||
|
||||
🇯🇵 일본 (참고) — e-Gov 法令API (laws.e-gov.go.jp)
|
||||
─────────────────────────────────────────
|
||||
· 労働安全衛生法 (노동안전위생법)
|
||||
· 労働安全衛生法施行令
|
||||
· 労働安全衛生規則
|
||||
|
||||
🇪🇺 EU (참고) — EUR-Lex SPARQL / REST
|
||||
─────────────────────────────────────────
|
||||
· Framework Directive 89/391/EEC (산업안전 기본지침)
|
||||
· REACH Regulation (화학물질 규정)
|
||||
· CLP Regulation (분류/표시 규정)
|
||||
· Machinery Directive 2006/42/EC
|
||||
```
|
||||
|
||||
### 3.2 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 법령 모니터링 시스템 (Mac mini, launchd) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ law_monitor.py │ │ 수집 스케줄 │ │
|
||||
│ │ · KR: law.go.kr │ │ · 한국: 매일 06:00 │ │
|
||||
│ │ · US: FedReg │ │ · 미국: 주 1회 (월) │ │
|
||||
│ │ · JP: e-Gov │ │ · 일본: 주 1회 (수) │ │
|
||||
│ │ · EU: EUR-Lex │ │ · EU: 월 1회 (1일) │ │
|
||||
│ └───────┬─────────┘ └──────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ 변경 감지: API → SQLite 비교 → diff 생성 │ │
|
||||
│ └───────┬──────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ 알림 + 저장 │ │
|
||||
│ │ · DEVONthink: 10_Legislation/ 자동 분류 │ │
|
||||
│ │ · Synology Chat 웹훅 즉시 알림 │ │
|
||||
│ │ · OmniFocus 작업 생성 ("법령 변경 검토 필요") │ │
|
||||
│ │ · Ollama 35B: 변경 요약 + 실무 영향 브리핑 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 법적 근거 — 수집에 문제 없음
|
||||
|
||||
```
|
||||
한국: 저작권법 제7조 — 법령, 조약, 판결 등은 저작권 보호 대상 아님
|
||||
+ 국가법령정보센터 Open API 공공데이터 자유이용 허용
|
||||
미국: 연방법/규정은 Public Domain (17 U.S.C. §105)
|
||||
일본: 著作権法 第13条 — 법령은 저작권 대상 제외
|
||||
EU: EUR-Lex 자유 재사용 정책 (Decision 2011/833/EU)
|
||||
```
|
||||
|
||||
### 3.4 저장 구조 예시
|
||||
|
||||
```
|
||||
04_Industrial Safety/10_Legislation/
|
||||
├── Act/
|
||||
│ └── 산업안전보건법_2026-03-01_시행.pdf
|
||||
├── Decree/
|
||||
│ └── 산업안전보건법_시행령_2026-01-01.pdf
|
||||
├── SAPA/
|
||||
│ └── 중대재해처벌법_2026-01-01_시행.pdf
|
||||
├── KR_Archive/
|
||||
│ ├── 2026-03-24_산업안전보건법_개정_diff.md ← 변경점 요약
|
||||
│ └── 2026-03-24_산업안전보건법_개정_원문.pdf
|
||||
└── Foreign/
|
||||
├── US/
|
||||
│ └── 29CFR1910_General_Industry.pdf
|
||||
├── JP/
|
||||
│ └── 労働安全衛生法_2026.pdf
|
||||
└── EU/
|
||||
└── Directive_89_391_EEC_Framework.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. DEVONagent 검색 세트 — 안전 분야
|
||||
|
||||
### 4.1 전체 구성
|
||||
|
||||
```
|
||||
Mac mini 자동 스케줄
|
||||
├── [SS-01] 🇰🇷 한국 산업안전 뉴스 매일 06:00 15~25건/주
|
||||
├── [SS-02] 🇰🇷 중대재해·판례 매일 06:15 5~10건/주
|
||||
├── [SS-04] 🇺🇸 미국 안전 동향 주 1회 (월) 10~15건/주
|
||||
├── [SS-05] 🇯🇵 일본 안전 동향 주 1회 (수) 5~10건/주
|
||||
├── [SS-06] 🇪🇺 유럽 안전 동향 월 2회 2~4건/주
|
||||
├── [SS-07] 🌐 국제 안전 전문지 주 1회 (금) 5~10건/주
|
||||
└── [SS-08] 📚 학술 논문 (안전공학) 주 1회 (토) 5~10건/주
|
||||
|
||||
안전 분야 주간 합계: ~50~85건 (하루 평균 ~8~12건)
|
||||
```
|
||||
|
||||
### 4.2 [SS-01] 한국 산업안전 뉴스 (매일)
|
||||
|
||||
```
|
||||
검색 세트: KR_Safety_News
|
||||
스케줄: 매일 06:00 / 새 결과만 수집
|
||||
|
||||
소스:
|
||||
· kosha.or.kr — 공단 공지, 가이드, 재해사례
|
||||
· portal.kosha.or.kr — 산재예방 포털
|
||||
· moel.go.kr — 고용노동부 보도자료, 정책
|
||||
· labor.moel.go.kr — 중대재해 알림e
|
||||
· safety.or.kr — 대한산업안전협회
|
||||
· safetyin.co.kr — 안전저널
|
||||
· Google News — "산업안전" OR "산재" OR "안전보건" -채용 -구인
|
||||
|
||||
→ Inbox → AI 태깅 → 04_Industrial Safety 하위 그룹 자동 분류
|
||||
```
|
||||
|
||||
### 4.3 [SS-02] 중대재해·판례 (매일)
|
||||
|
||||
```
|
||||
검색 세트: KR_SAPA_Cases
|
||||
스케줄: 매일 06:15 / 새 결과만 수집
|
||||
|
||||
소스:
|
||||
· labor.moel.go.kr — 중대재해 알림e 공시
|
||||
· nosanjae.kr — 중대재해 기업 검색
|
||||
· law.go.kr — 판례 검색 (산업안전 관련)
|
||||
· Google News — "중대재해" OR "중대재해처벌" OR "산재 사망" -채용
|
||||
|
||||
→ Inbox → AI 태깅 → 40_Cases/Domestic/ 또는 60_Compliance/
|
||||
```
|
||||
|
||||
### 4.4 [SS-04] 🇺🇸 미국 안전 동향 (주 1회)
|
||||
|
||||
```
|
||||
검색 세트: US_Safety
|
||||
스케줄: 월요일 07:00 / 최대 15건
|
||||
|
||||
소스:
|
||||
· osha.gov/rss — OSHA 보도자료, 벌금 부과, 규칙 (주 3~5건)
|
||||
· osha.gov/quicktakes — OSHA QuickTakes 뉴스레터 (격주 1건)
|
||||
· federalregister.gov — OSHA final rule / proposed (주 1~3건)
|
||||
· ehstoday.com — EHS Today 산업안전 전문지 (주 3~5건)
|
||||
|
||||
쿼리: ("OSHA" OR "workplace fatality" OR "safety violation") -job -hiring
|
||||
|
||||
→ Inbox → 10_Legislation/Foreign/US/ 또는 40_Cases/International/
|
||||
```
|
||||
|
||||
### 4.5 [SS-05] 🇯🇵 일본 안전 동향 (주 1회)
|
||||
|
||||
```
|
||||
검색 세트: JP_Safety
|
||||
스케줄: 수요일 07:00 / 최대 10건
|
||||
|
||||
소스:
|
||||
· mhlw.go.jp/rss — 厚生労働省 보도자료 (주 2~4건)
|
||||
· anzeninfo.mhlw.go.jp — 職場のあんぜんサイト (재해사례) (주 2~3건)
|
||||
· jaish.gr.jp — 安全衛生情報センター (통달/지침) (주 1~2건)
|
||||
|
||||
쿼리: ("労働安全" OR "労働災害" OR "安全衛生" OR "重大災害")
|
||||
|
||||
→ Inbox → 10_Legislation/Foreign/JP/
|
||||
→ AI 자동 처리: Ollama로 일본어 → 한국어 1줄 요약 생성
|
||||
```
|
||||
|
||||
### 4.6 [SS-06] 🇪🇺 유럽 안전 동향 (월 2회)
|
||||
|
||||
```
|
||||
검색 세트: EU_Safety
|
||||
스케줄: 1일·15일 07:00 / 최대 10건
|
||||
|
||||
소스:
|
||||
· osha.europa.eu RSS — EU-OSHA 발간물, 뉴스, 지침 (월 3~5건)
|
||||
· eur-lex.europa.eu — 산업안전 관련 신규 지침/규정 (월 1~3건)
|
||||
· hse.gov.uk — UK Health & Safety Executive (월 2~3건)
|
||||
|
||||
쿼리: ("EU-OSHA" OR "workplace safety directive" OR "REACH" OR "safety at work")
|
||||
-vacancy -recruitment
|
||||
|
||||
→ Inbox → 10_Legislation/Foreign/EU/
|
||||
```
|
||||
|
||||
### 4.7 [SS-07] 🌐 국제 안전 전문지 (주 1회)
|
||||
|
||||
```
|
||||
검색 세트: Global_Safety_Magazines
|
||||
스케줄: 금요일 07:00 / 최대 10건
|
||||
|
||||
소스:
|
||||
· ishn.com/rss — Industrial Safety & Hygiene News (주 3~5건)
|
||||
· ohsonline.com — Occupational Health & Safety (주 2~3건)
|
||||
· safetyandhealthmagazine.com — NSC Safety+Health Magazine (주 1~2건)
|
||||
|
||||
쿼리: ("industrial safety" OR "process safety" OR "workplace accident"
|
||||
OR "safety management" OR "risk assessment")
|
||||
|
||||
→ Inbox → AI 태깅 후 주제별 자동 분류
|
||||
```
|
||||
|
||||
### 4.8 [SS-08] 학술 논문 — 안전공학 (주 1회)
|
||||
|
||||
```
|
||||
검색 세트: Safety_Academic
|
||||
스케줄: 토요일 08:00 / 최대 10건
|
||||
|
||||
소스:
|
||||
· Google Scholar — 한국어: "산업안전" "위험성평가" "안전공학"
|
||||
· Google Scholar — 영어: "occupational safety" "risk assessment"
|
||||
· oshri.kosha.or.kr — 산업안전보건연구원 발간물
|
||||
· dbpia.co.kr — 한국 학술논문
|
||||
· sciencedirect.com — Safety Science 저널
|
||||
|
||||
→ Inbox → 30_Papers/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 양 조절 전략
|
||||
|
||||
### 5.1 주간 예상 유입량
|
||||
|
||||
```
|
||||
검색 세트 주간 예상 빈도
|
||||
──────────────────────────────────────────
|
||||
SS-01 한국 안전뉴스 15~25건 매일
|
||||
SS-02 중대재해/판례 5~10건 매일
|
||||
SS-04 🇺🇸 미국 10~15건 주 1회
|
||||
SS-05 🇯🇵 일본 5~10건 주 1회
|
||||
SS-06 🇪🇺 유럽 2~4건 월 2회
|
||||
SS-07 🌐 전문지 5~10건 주 1회
|
||||
SS-08 학술 논문 5~10건 주 1회
|
||||
──────────────────────────────────────────
|
||||
안전 분야 합계 ~50~85건 /주
|
||||
하루 평균 ~8~12건
|
||||
```
|
||||
|
||||
### 5.2 과다 유입 방지 장치
|
||||
|
||||
```
|
||||
1단계: DEVONagent "새 결과만" — 이전 수집분 자동 제외
|
||||
2단계: 검색 세트별 최대 수집량 캡 (Max Results)
|
||||
3단계: AI 관련도 필터 — Ollama가 관련도 판단
|
||||
→ 낮으면 @상태/아카이브 → 90_Archive 이동
|
||||
→ 높으면 @상태/검토필요 → 해당 그룹에 유지
|
||||
4단계: 주간 다이제스트 — 금요일 Claude API가 주간 요약
|
||||
→ "이번 주 꼭 봐야 할 5건" 브리핑 자동 생성
|
||||
5단계: 30일 이상 미열람 → Smart Rule로 자동 90_Archive 이동
|
||||
```
|
||||
|
||||
### 5.3 일본어 자료 자동 처리
|
||||
|
||||
```
|
||||
수집 → Smart Rule: 일본 태그 감지
|
||||
→ Ollama 35B: 일본어 → 한국어 1줄 요약
|
||||
→ DEVONthink 커스텀 메타데이터 "summaryKR" 필드에 저장
|
||||
→ 원문은 그대로 보존
|
||||
|
||||
※ 일본 산업안전 용어는 한자어 공통으로 번역 정확도 높음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 기존 자료 마이그레이션
|
||||
|
||||
```
|
||||
현재 → 이동 대상
|
||||
───────────────────────────────────────────────
|
||||
0_Theory/ (72건) → 20_Theory/
|
||||
8_Reference/ (1건) → 80_Reference/
|
||||
9_일반자료_산업안전/ (33건) → 내용별 분산:
|
||||
사고사례 → 40_Cases/Domestic/
|
||||
실무서식 → 50_Practice/
|
||||
신고관련 → 60_Compliance/
|
||||
지게차 관련규칙 개정... (PDF) → 10_Legislation/Notice/
|
||||
Industrial Safety... (HTML) → 20_Theory/ 또는 80_Reference/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 태그 체계 (산업안전 영역)
|
||||
|
||||
```
|
||||
#주제/산업안전/
|
||||
├── 법령 ← 10_Legislation
|
||||
├── 위험성평가 ← 50_Practice/Risk_Assessment
|
||||
├── 순회점검 ← 50_Practice/Patrol_Inspection
|
||||
├── 안전교육 ← 50_Practice/Education
|
||||
├── 사고사례 ← 40_Cases
|
||||
├── 신고보고 ← 60_Compliance
|
||||
├── 안전관리자 ← 70_Safety_Manager
|
||||
├── 보건관리자 ← 75_Health_Manager
|
||||
└── 규격기준 ← 80_Reference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 유입 경로 추적 체계 (Source Tracking)
|
||||
|
||||
모든 문서에 유입 경로를 기록하여 "이 자료가 어디서 왔는지"를 즉시 파악할 수 있게 합니다.
|
||||
실제 업무 데이터와 외부 참고자료를 명확히 구분하는 것이 핵심입니다.
|
||||
|
||||
### 8.1 유입 경로 분류
|
||||
|
||||
```
|
||||
커스텀 메타데이터: sourceChannel (텍스트, 필수)
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 자동 유입 (시스템) │
|
||||
├────────────────┬─────────────────────────────────────────────────┤
|
||||
│ tksafety │ TKSafety API 연동 — 업무 실적 (위험성평가, 점검 등) │
|
||||
│ devonagent │ DEVONagent 검색 세트 — 뉴스/업계 동향 자동 수집 │
|
||||
│ law_monitor │ 법령 모니터링 API — 법령 제·개정 추적 │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ 수동/반자동 유입 │
|
||||
├────────────────┬─────────────────────────────────────────────────┤
|
||||
│ inbox_route │ Inbox DB → AI 분류 → 이 DB로 라우팅된 문서 │
|
||||
│ email │ MailPlus → Archive DB → 안전 관련 메일 전달 │
|
||||
│ web_clip │ DEVONthink Web Clipper로 직접 스크랩 │
|
||||
│ manual │ 드래그&드롭, 스캔, 파일 직접 추가 │
|
||||
└────────────────┴─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 메타데이터 자동 설정 규칙
|
||||
|
||||
```
|
||||
유입 경로별 자동 태그:
|
||||
|
||||
tksafety → @출처/TKSafety + sourceURL = tksafety.technicalkorea.net/...
|
||||
devonagent → @출처/뉴스수집 + sourceURL = 원본 기사 URL
|
||||
law_monitor → @출처/법령API + sourceURL = law.go.kr/... 또는 해외 법령 URL
|
||||
inbox_route → @출처/자동분류 + (원본 sourceURL 유지)
|
||||
email → @출처/이메일 + sourceURL = mailplus 메시지 링크
|
||||
web_clip → @출처/웹스크랩 + sourceURL = 스크랩 원본 URL
|
||||
manual → @출처/수동입력 + sourceURL = 없음 (직접 기입 가능)
|
||||
```
|
||||
|
||||
### 8.3 업무 데이터 vs 참고자료 구분
|
||||
|
||||
```
|
||||
커스텀 메타데이터: dataOrigin (드롭다운, 필수)
|
||||
|
||||
work — 우리 회사 실제 업무에서 발생한 데이터
|
||||
(TKSafety 연동, 직접 작성한 보고서, 내부 회의록 등)
|
||||
external — 외부에서 수집한 참고/학습 자료
|
||||
(뉴스, 법령 원문, 타사 사례, 학술 논문 등)
|
||||
|
||||
자동 판별 규칙:
|
||||
· sourceChannel = tksafety → dataOrigin = work (항상)
|
||||
· sourceChannel = law_monitor → dataOrigin = external (항상)
|
||||
· sourceChannel = devonagent → dataOrigin = external (항상)
|
||||
· sourceChannel = manual → dataOrigin = work (기본값, 수동 변경 가능)
|
||||
· sourceChannel = inbox_route → AI가 내용 기반으로 판별
|
||||
· sourceChannel = email → AI가 발신자/내용 기반으로 판별
|
||||
· sourceChannel = web_clip → dataOrigin = external (기본값)
|
||||
```
|
||||
|
||||
### 8.4 Smart Rule 적용
|
||||
|
||||
```
|
||||
DEVONthink Smart Rule: "소스 채널 누락 검출"
|
||||
|
||||
조건: custom metadata "sourceChannel" is empty
|
||||
AND database is "04_Industrial Safety"
|
||||
AND NOT in group "00_Inbox"
|
||||
동작:
|
||||
1. @상태/미분류출처 태그 추가
|
||||
2. 00_Inbox으로 이동 (출처 확인 후 재분류)
|
||||
|
||||
→ 어떤 경로로든 출처 없이 들어온 문서는 자동 포착
|
||||
→ 주간 리뷰에서 정리 (수동 입력 자료 대부분 여기 해당)
|
||||
```
|
||||
|
||||
### 8.5 활용 시나리오
|
||||
|
||||
```
|
||||
검색/필터 예시:
|
||||
|
||||
"올해 우리 회사가 실시한 위험성평가만 보기"
|
||||
→ 50_Practice/Risk_Assessment/ + dataOrigin = work
|
||||
|
||||
"외부 위험성평가 사례/참고자료"
|
||||
→ 50_Practice/Risk_Assessment/ + dataOrigin = external
|
||||
|
||||
"TKSafety에서 자동 수집된 문서 전체"
|
||||
→ sourceChannel = tksafety
|
||||
|
||||
"직접 스크랩한 자료 중 미정리 건"
|
||||
→ sourceChannel = web_clip + @상태/미분류출처
|
||||
|
||||
Smart Group으로 상시 모니터링:
|
||||
· "출처 미기입 문서" → sourceChannel is empty
|
||||
· "이번 주 업무 문서" → dataOrigin = work + 최근 7일
|
||||
· "외부 수집 미읽음" → dataOrigin = external + unread
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. TKSafety 시스템 연동 (설정 대기)
|
||||
|
||||
> **현재 상태: 설계 완료, 구현 대기**
|
||||
> API 엔드포인트 명세와 연동 구조만 확정해두고, 실제 활성화는 PKM 기본 체계가 안정된 후 진행합니다.
|
||||
> TKSafety는 자체 개발 시스템이므로 필요 시점에 API를 추가하면 됩니다.
|
||||
|
||||
### 9.1 시스템 정보
|
||||
|
||||
```
|
||||
· URL: tksafety.technicalkorea.net (Cloudflare Tunnel)
|
||||
· 호스팅: Synology DS1525+ Docker
|
||||
· 내부 접근: Tailscale VPN
|
||||
· 개발/수정: 직접 가능
|
||||
· sourceChannel 값: tksafety
|
||||
· dataOrigin 값: work (항상)
|
||||
```
|
||||
|
||||
### 9.2 연동 아키텍처 (예정)
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌────────────────────────────┐
|
||||
│ TKSafety │ │ Mac mini (PKM 허브) │
|
||||
│ (Synology Docker) │ │ │
|
||||
│ │ API │ tksafety_sync.py │
|
||||
│ /api/v1/ │◄──────►│ (launchd 스케줄) │
|
||||
│ risk-assessments │ Tailscale│ │
|
||||
│ patrol-inspections │ │ ┌─────────────────────┐ │
|
||||
│ corrective-actions │ │ │ 데이터 가공 │ │
|
||||
│ incidents │ │ │ · JSON → PDF/MD 변환 │ │
|
||||
│ education-records │ │ │ · sourceChannel 설정 │ │
|
||||
│ meeting-minutes │ │ │ · dataOrigin = work │ │
|
||||
│ │ │ └──────────┬──────────┘ │
|
||||
└──────────────────────┘ │ ▼ │
|
||||
│ DEVONthink 자동 임포트 │
|
||||
│ → 04_Industrial Safety/ │
|
||||
│ 하위 그룹 자동 라우팅 │
|
||||
│ │
|
||||
│ ChromaDB 벡터 인덱싱 │
|
||||
│ → RAG 검색 가능 │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.3 API 엔드포인트 명세 (TKSafety에 추가 예정)
|
||||
|
||||
```
|
||||
GET /api/v1/risk-assessments
|
||||
?since=2026-03-01&status=completed → 위험성평가 결과 목록
|
||||
GET /api/v1/risk-assessments/{id}/report → 상세 (PDF/JSON)
|
||||
|
||||
GET /api/v1/patrol-inspections
|
||||
?since=2026-03-01 → 순회점검 결과 목록
|
||||
GET /api/v1/patrol-inspections/{id}/report → 상세 + 사진
|
||||
|
||||
GET /api/v1/corrective-actions
|
||||
?since=2026-03-01&status=open|completed|overdue → 시정조치 내역
|
||||
|
||||
GET /api/v1/incidents?since=2026-03-01 → 사고/아차사고 보고서
|
||||
|
||||
GET /api/v1/education-records?since=2026-03-01 → 안전교육 기록
|
||||
|
||||
GET /api/v1/meetings?type=safety-committee&since=2026-03-01 → 회의록
|
||||
|
||||
GET /api/v1/sync-status → 마지막 동기화 시점, 대기 건수
|
||||
```
|
||||
|
||||
### 9.4 라우팅 매핑 (활성화 시 적용)
|
||||
|
||||
```
|
||||
TKSafety 데이터 → DEVONthink 그룹 → 파일 형식
|
||||
────────────────────────────────────────────────────────────────
|
||||
risk-assessments → 50_Practice/Risk_Assessment/ → PDF
|
||||
patrol-inspections → 50_Practice/Patrol_Inspection/ → MD + 사진
|
||||
corrective-actions → 70_Safety_Manager/Improvement/ → MD
|
||||
incidents → 40_Cases/Domestic/ → PDF
|
||||
education-records → 50_Practice/Education/ → MD
|
||||
meetings (safety-comm) → 70_Safety_Manager/Meeting/ → MD
|
||||
|
||||
파일명 규칙:
|
||||
RA_2026-03-24_[작업명]_[위험등급].pdf
|
||||
PI_2026-03-24_[구역명].md
|
||||
CA_2026-03-24_[조치내용]_[상태].md
|
||||
INC_2026-03-24_[사고유형]_[심각도].pdf
|
||||
```
|
||||
|
||||
### 9.5 동기화 스케줄 (활성화 시 적용)
|
||||
|
||||
```
|
||||
· 위험성평가, 순회점검 → 매일 07:00
|
||||
· 시정조치 → 매일 07:00 + 18:00
|
||||
· 사고/아차사고 → 1시간마다 (긴급성)
|
||||
· 교육기록, 회의록 → 주 1회 (월요일 07:00)
|
||||
· overdue 시정조치 → OmniFocus 작업 자동 생성
|
||||
```
|
||||
|
||||
### 9.6 활성화 단계
|
||||
|
||||
```
|
||||
지금 할 것:
|
||||
✓ API 명세 확정 (이 문서)
|
||||
✓ sourceChannel/dataOrigin 체계 설계
|
||||
○ TKSafety에 /api/v1/ 엔드포인트 뼈대만 추가 (빈 응답 OK)
|
||||
|
||||
PKM 안정화 후:
|
||||
Phase 1: API 실제 데이터 응답 구현
|
||||
Phase 2: tksafety_sync.py 개발 + DEVONthink 임포트
|
||||
Phase 3: 시정조치 → OmniFocus 연동
|
||||
Phase 4: 양방향 확장 (DEVONthink → TKSafety 상태 업데이트)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 산업안전 Daily Digest 기여 항목
|
||||
|
||||
Daily Digest는 전체 PKM 차원에서 운영되지만 (메인 아키텍처 참조),
|
||||
이 DB는 특히 다음 항목을 다이제스트에 공급합니다.
|
||||
|
||||
```
|
||||
04_Industrial Safety → Daily Digest 공급 항목:
|
||||
|
||||
■ 문서 변동
|
||||
· 오늘 추가된 문서 수 (sourceChannel별 구분)
|
||||
예: "산업안전 +5 (뉴스3, 법령1, 업무1)"
|
||||
· 분류 실패 → 00_Inbox 잔류 건수
|
||||
|
||||
■ 법령 변경 (law_monitor 연동)
|
||||
· 한국 법령 제·개정 감지 → ⚠ 마크로 강조
|
||||
· 해외 법령 변경 → 참고 표시
|
||||
· OmniFocus 액션: "법령 변경 검토: [법령명]" 자동 생성
|
||||
|
||||
■ 뉴스/동향 (DEVONagent 연동)
|
||||
· 오늘 수집된 안전 뉴스 건수 (국내/해외 구분)
|
||||
· 상위 3건 자동 요약 (Ollama 35B)
|
||||
|
||||
■ 업무 데이터 (TKSafety 연동, 활성화 시)
|
||||
· 위험성평가/순회점검 신규 건수
|
||||
· 시정조치 overdue → ⚠ OmniFocus 긴급 액션
|
||||
|
||||
■ OmniFocus 액션 자동 생성 조건 (이 DB 관련):
|
||||
· 법령 변경 감지 → "법령 변경 검토: [법령명]"
|
||||
· 시정조치 기한초과 → "시정조치 기한초과: [내용]" (긴급)
|
||||
· 안전 뉴스 중대 키워드 → "뉴스 확인: [제목]"
|
||||
(키워드: 중대재해, 사망, 작업중지, 과태료)
|
||||
· Inbox 미처리 5건 이상 → "산업안전 Inbox 정리 필요"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 확장 계획
|
||||
|
||||
- 나머지 도메인 DB(03_Engineering, 05_Programming 등)도 동일한 넘버링 패턴으로 그룹 구조 설계 예정
|
||||
- 각 DB별 DEVONagent 검색 세트 추가
|
||||
- DB 간 크로스 레퍼런스 (예: 산업안전 + 공학 문서 연결)
|
||||
- TKSafety 양방향 연동 확장 (Section 9.6 참조)
|
||||
- sourceChannel/dataOrigin 체계를 다른 도메인 DB에도 확장 적용
|
||||
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에서 구현")
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>net.hyungi.pkm.daily-digest</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/pkm_daily_digest.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>20</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/digest_launchd.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/digest_launchd_err.log</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>net.hyungi.pkm.law-monitor</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/law_monitor.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>7</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/law_monitor_launchd.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/law_monitor_launchd_err.log</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>net.hyungi.pkm.mailplus</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/mailplus_archive.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
|
||||
<key>StartCalendarInterval</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>7</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>18</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/mailplus_launchd.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/mailplus_launchd_err.log</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
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';
|
||||
@@ -1,6 +0,0 @@
|
||||
chromadb>=0.4.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
schedule>=1.2.0
|
||||
markdown>=3.5.0
|
||||
anthropic>=0.40.0
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
벡터 임베딩 스크립트
|
||||
- DEVONthink 문서 UUID로 텍스트 추출
|
||||
- GPU 서버(nomic-embed-text)로 임베딩 생성
|
||||
- ChromaDB에 저장
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from pkm_utils import setup_logger, load_credentials, run_applescript_inline
|
||||
|
||||
logger = setup_logger("embed")
|
||||
|
||||
# ChromaDB 저장 경로
|
||||
CHROMA_DIR = Path.home() / ".local" / "share" / "pkm" / "chromadb"
|
||||
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_document_text(uuid: str) -> tuple[str, str]:
|
||||
"""DEVONthink에서 UUID로 문서 텍스트 + 제목 추출"""
|
||||
script = f'''
|
||||
tell application id "DNtp"
|
||||
set theRecord to get record with uuid "{uuid}"
|
||||
set docText to plain text of theRecord
|
||||
set docTitle to name of theRecord
|
||||
return docTitle & "|||" & docText
|
||||
end tell
|
||||
'''
|
||||
result = run_applescript_inline(script)
|
||||
parts = result.split("|||", 1)
|
||||
title = parts[0] if len(parts) > 0 else ""
|
||||
text = parts[1] if len(parts) > 1 else ""
|
||||
return title, text
|
||||
|
||||
|
||||
def get_embedding(text: str, gpu_server_ip: str) -> list[float] | None:
|
||||
"""GPU 서버의 nomic-embed-text로 임베딩 생성"""
|
||||
url = f"http://{gpu_server_ip}:11434/api/embeddings"
|
||||
try:
|
||||
resp = requests.post(url, json={
|
||||
"model": "nomic-embed-text",
|
||||
"prompt": text[:8000] # 토큰 제한
|
||||
}, timeout=60)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("embedding")
|
||||
except Exception as e:
|
||||
logger.error(f"임베딩 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def store_in_chromadb(doc_id: str, title: str, text: str, embedding: list[float]):
|
||||
"""ChromaDB에 저장"""
|
||||
import chromadb
|
||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
collection = client.get_or_create_collection(
|
||||
name="pkm_documents",
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
collection.upsert(
|
||||
ids=[doc_id],
|
||||
embeddings=[embedding],
|
||||
documents=[text[:2000]],
|
||||
metadatas=[{"title": title, "source": "devonthink"}]
|
||||
)
|
||||
logger.info(f"ChromaDB 저장: {doc_id} ({title[:30]})")
|
||||
|
||||
|
||||
def run(uuid: str):
|
||||
"""단일 문서 임베딩 처리"""
|
||||
logger.info(f"임베딩 처리 시작: {uuid}")
|
||||
|
||||
creds = load_credentials()
|
||||
gpu_ip = creds.get("GPU_SERVER_IP")
|
||||
if not gpu_ip:
|
||||
logger.warning("GPU_SERVER_IP 미설정 — 임베딩 건너뜀")
|
||||
return
|
||||
|
||||
try:
|
||||
title, text = get_document_text(uuid)
|
||||
if not text or len(text) < 10:
|
||||
logger.warning(f"텍스트 부족 [{uuid}]: {len(text)}자")
|
||||
return
|
||||
|
||||
embedding = get_embedding(text, gpu_ip)
|
||||
if embedding:
|
||||
store_in_chromadb(uuid, title, text, embedding)
|
||||
logger.info(f"임베딩 완료: {uuid}")
|
||||
else:
|
||||
logger.error(f"임베딩 실패: {uuid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"임베딩 처리 에러 [{uuid}]: {e}", exc_info=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("사용법: python3 embed_to_chroma.py <DEVONthink_UUID>")
|
||||
sys.exit(1)
|
||||
run(sys.argv[1])
|
||||
@@ -1,400 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
법령 모니터링 스크립트
|
||||
- 국가법령정보센터 OpenAPI (open.law.go.kr) 폴링
|
||||
- 산업안전보건법, 중대재해처벌법 등 변경 추적
|
||||
- 변경 감지 시 DEVONthink 04_Industrial Safety 자동 임포트
|
||||
※ API 승인 대기중 — 스크립트만 작성, 실제 호출은 승인 후
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from pkm_utils import setup_logger, load_credentials, run_applescript_inline, llm_generate, PROJECT_ROOT, DATA_DIR
|
||||
|
||||
logger = setup_logger("law_monitor")
|
||||
|
||||
# 모니터링 대상 법령
|
||||
MONITORED_LAWS = [
|
||||
{"name": "산업안전보건법", "law_id": "001789", "category": "법률"},
|
||||
{"name": "산업안전보건법 시행령", "law_id": "001790", "category": "대통령령"},
|
||||
{"name": "산업안전보건법 시행규칙", "law_id": "001791", "category": "부령"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률", "law_id": "019005", "category": "법률"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률 시행령", "law_id": "019006", "category": "대통령령"},
|
||||
{"name": "화학물질관리법", "law_id": "012354", "category": "법률"},
|
||||
{"name": "위험물안전관리법", "law_id": "001478", "category": "법률"},
|
||||
]
|
||||
|
||||
# 마지막 확인 일자 저장 파일
|
||||
LAST_CHECK_FILE = DATA_DIR / "law_last_check.json"
|
||||
LAWS_DIR = DATA_DIR / "laws"
|
||||
LAWS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def load_last_check() -> dict:
|
||||
"""마지막 확인 일자 로딩"""
|
||||
if LAST_CHECK_FILE.exists():
|
||||
with open(LAST_CHECK_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_last_check(data: dict):
|
||||
"""마지막 확인 일자 저장"""
|
||||
with open(LAST_CHECK_FILE, "w") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def fetch_law_info(law_oc: str, law_id: str) -> dict | None:
|
||||
"""법령 정보 조회 (법령 API)"""
|
||||
url = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
params = {
|
||||
"OC": law_oc,
|
||||
"target": "law",
|
||||
"type": "JSON",
|
||||
"MST": law_id,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# API 에러 응답 감지
|
||||
if "result" in data and "실패" in str(data.get("result", "")):
|
||||
logger.error(f"법령 API 에러 [{law_id}]: {data.get('result')} — {data.get('msg')}")
|
||||
return None
|
||||
if "LawSearch" in data and "law" in data["LawSearch"]:
|
||||
laws = data["LawSearch"]["law"]
|
||||
if isinstance(laws, list):
|
||||
return laws[0] if laws else None
|
||||
return laws
|
||||
logger.warning(f"법령 응답에 데이터 없음 [{law_id}]: {list(data.keys())}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"법령 조회 실패 [{law_id}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_law_text(law_oc: str, law_mst: str) -> str | None:
|
||||
"""법령 본문 XML 다운로드"""
|
||||
url = "https://www.law.go.kr/DRF/lawService.do"
|
||||
params = {
|
||||
"OC": law_oc,
|
||||
"target": "law",
|
||||
"type": "XML",
|
||||
"MST": law_mst,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=60)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
logger.error(f"법령 본문 다운로드 실패 [{law_mst}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_law_file(law_name: str, content: str) -> Path:
|
||||
"""법령 XML 저장"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
safe_name = law_name.replace(" ", "_").replace("/", "_")
|
||||
filepath = LAWS_DIR / f"{safe_name}_{today}.xml"
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
logger.info(f"법령 저장: {filepath}")
|
||||
return filepath
|
||||
|
||||
|
||||
def import_to_devonthink(filepath: Path, law_name: str, category: str):
|
||||
"""DEVONthink 04_Industrial Safety로 임포트 — 변수 방식"""
|
||||
fp = str(filepath)
|
||||
script = f'set fp to "{fp}"\n'
|
||||
script += 'tell application id "DNtp"\n'
|
||||
script += ' repeat with db in databases\n'
|
||||
script += ' if name of db is "04_Industrial safety" then\n'
|
||||
script += ' set targetGroup to create location "/10_Legislation/Law" in db\n'
|
||||
script += ' set theRecord to import fp to targetGroup\n'
|
||||
script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{category}"}}\n'
|
||||
script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n'
|
||||
script += ' add custom meta data "external" for "dataOrigin" to theRecord\n'
|
||||
script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n'
|
||||
script += ' exit repeat\n'
|
||||
script += ' end if\n'
|
||||
script += ' end repeat\n'
|
||||
script += 'end tell'
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
logger.info(f"DEVONthink 임포트 완료: {law_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 임포트 실패 [{law_name}]: {e}")
|
||||
|
||||
|
||||
def run():
|
||||
"""메인 실행"""
|
||||
logger.info("=== 법령 모니터링 시작 ===")
|
||||
|
||||
creds = load_credentials()
|
||||
law_oc = creds.get("LAW_OC")
|
||||
if not law_oc:
|
||||
logger.error("LAW_OC 인증키가 설정되지 않았습니다. credentials.env를 확인하세요.")
|
||||
sys.exit(1)
|
||||
|
||||
last_check = load_last_check()
|
||||
changes_found = 0
|
||||
|
||||
for law in MONITORED_LAWS:
|
||||
law_name = law["name"]
|
||||
law_id = law["law_id"]
|
||||
category = law["category"]
|
||||
|
||||
logger.info(f"확인 중: {law_name} ({law_id})")
|
||||
|
||||
info = fetch_law_info(law_oc, law_id)
|
||||
if not info:
|
||||
continue
|
||||
|
||||
# 시행일자 또는 공포일자로 변경 감지
|
||||
announce_date = info.get("공포일자", info.get("시행일자", ""))
|
||||
prev_date = last_check.get(law_id, "")
|
||||
|
||||
if announce_date and announce_date != prev_date:
|
||||
logger.info(f"변경 감지: {law_name} — 공포일자 {announce_date} (이전: {prev_date or '없음'})")
|
||||
|
||||
# 법령 본문 다운로드
|
||||
law_mst = info.get("법령MST", law_id)
|
||||
text = fetch_law_text(law_oc, law_mst)
|
||||
if text:
|
||||
filepath = save_law_file(law_name, text)
|
||||
import_to_devonthink(filepath, law_name, category)
|
||||
changes_found += 1
|
||||
|
||||
last_check[law_id] = announce_date
|
||||
else:
|
||||
logger.debug(f"변경 없음: {law_name}")
|
||||
|
||||
save_last_check(last_check)
|
||||
|
||||
# ─── 외국 법령 (빈도 체크 후 실행) ───
|
||||
us_count = fetch_us_osha(last_check)
|
||||
jp_count = fetch_jp_mhlw(last_check)
|
||||
eu_count = fetch_eu_osha(last_check)
|
||||
changes_found += us_count + jp_count + eu_count
|
||||
|
||||
save_last_check(last_check)
|
||||
logger.info(f"=== 법령 모니터링 완료 — {changes_found}건 변경 감지 (한국+외국) ===")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 외국 법령 모니터링
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _should_run(last_check: dict, key: str, interval_days: int) -> bool:
|
||||
"""빈도 체크: 마지막 실행일로부터 interval_days 경과 여부"""
|
||||
last_run = last_check.get(key, "")
|
||||
if not last_run:
|
||||
return True
|
||||
try:
|
||||
last_date = datetime.strptime(last_run, "%Y-%m-%d")
|
||||
return (datetime.now() - last_date).days >= interval_days
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def _import_foreign_to_devonthink(filepath: Path, title: str, country: str):
|
||||
"""외국 법령 DEVONthink 임포트 — 변수 방식 (POSIX path 따옴표 문제 회피)"""
|
||||
folder = {"US": "US", "JP": "JP", "EU": "EU"}.get(country, country)
|
||||
fp = str(filepath)
|
||||
script = f'set fp to "{fp}"\n'
|
||||
script += 'tell application id "DNtp"\n'
|
||||
script += ' repeat with db in databases\n'
|
||||
script += ' if name of db is "04_Industrial safety" then\n'
|
||||
script += f' set targetGroup to create location "/10_Legislation/Foreign/{folder}" in db\n'
|
||||
script += ' set theRecord to import fp to targetGroup\n'
|
||||
script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{country}"}}\n'
|
||||
script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n'
|
||||
script += ' add custom meta data "external" for "dataOrigin" to theRecord\n'
|
||||
script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n'
|
||||
script += ' exit repeat\n'
|
||||
script += ' end if\n'
|
||||
script += ' end repeat\n'
|
||||
script += 'end tell'
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
safe_title = title[:40].replace('\n', ' ')
|
||||
logger.info(f"DEVONthink 임포트 [{country}]: {safe_title}")
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 임포트 실패 [{country}]: {e}")
|
||||
|
||||
|
||||
def fetch_us_osha(last_check: dict) -> int:
|
||||
"""US OSHA — Federal Register API (주 1회)"""
|
||||
if not _should_run(last_check, "_us_osha_last", 7):
|
||||
logger.debug("US OSHA: 이번 주 이미 실행됨, 건너뜀")
|
||||
return 0
|
||||
|
||||
logger.info("=== US OSHA 확인 ===")
|
||||
try:
|
||||
from_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
resp = requests.get("https://www.federalregister.gov/api/v1/documents.json", params={
|
||||
"conditions[agencies][]": "occupational-safety-and-health-administration",
|
||||
"conditions[publication_date][gte]": from_date,
|
||||
"per_page": 10,
|
||||
"order": "newest",
|
||||
}, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results = data.get("results", [])
|
||||
count = 0
|
||||
|
||||
for doc in results:
|
||||
doc_id = doc.get("document_number", "")
|
||||
title = doc.get("title", "")
|
||||
pub_date = doc.get("publication_date", "")
|
||||
abstract = doc.get("abstract", "")
|
||||
doc_url = doc.get("html_url", "")
|
||||
|
||||
# 마크다운으로 저장
|
||||
content = f"# {title}\n\n"
|
||||
content += f"- **Document**: {doc_id}\n"
|
||||
content += f"- **Date**: {pub_date}\n"
|
||||
content += f"- **URL**: {doc_url}\n\n"
|
||||
if abstract:
|
||||
content += f"## Abstract\n\n{abstract}\n"
|
||||
|
||||
safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in title)[:50]
|
||||
filepath = LAWS_DIR / f"US_OSHA_{pub_date}_{safe_title}.md"
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
_import_foreign_to_devonthink(filepath, title, "US")
|
||||
count += 1
|
||||
|
||||
last_check["_us_osha_last"] = datetime.now().strftime("%Y-%m-%d")
|
||||
logger.info(f"US OSHA: {count}건")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"US OSHA 에러: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_jp_mhlw(last_check: dict) -> int:
|
||||
"""JP 厚生労働省 — RSS 파싱 + MLX 번역 (주 1회)"""
|
||||
if not _should_run(last_check, "_jp_mhlw_last", 7):
|
||||
logger.debug("JP 厚労省: 이번 주 이미 실행됨, 건너뜀")
|
||||
return 0
|
||||
|
||||
logger.info("=== JP 厚生労働省 확인 ===")
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
resp = requests.get("https://www.mhlw.go.jp/stf/news.rdf", timeout=30)
|
||||
resp.raise_for_status()
|
||||
root = ET.fromstring(resp.content)
|
||||
|
||||
safety_keywords = ["労働安全", "安全衛生", "労災", "化学物質", "石綿", "安全管理", "労働", "安全", "衛生"]
|
||||
rss_ns = "http://purl.org/rss/1.0/"
|
||||
count = 0
|
||||
|
||||
# RDF 1.0 형식: {http://purl.org/rss/1.0/}item
|
||||
items = root.findall(f"{{{rss_ns}}}item")
|
||||
logger.info(f"JP RSS 항목: {len(items)}건")
|
||||
for item in items:
|
||||
title = item.findtext(f"{{{rss_ns}}}title", "")
|
||||
link = item.findtext(f"{{{rss_ns}}}link", "")
|
||||
pub_date = item.findtext("pubDate", "")
|
||||
|
||||
# 안전위생 키워드 필터
|
||||
if not any(kw in title for kw in safety_keywords):
|
||||
continue
|
||||
|
||||
# MLX 35B로 한국어 번역
|
||||
translated = ""
|
||||
try:
|
||||
translated = llm_generate(
|
||||
f"다음 일본어 제목을 한국어로 번역해줘. 번역만 출력하고 다른 말은 하지 마.\n\n{title}"
|
||||
)
|
||||
# thinking 출력 제거 — 마지막 줄만 사용
|
||||
lines = [l.strip() for l in translated.strip().split("\n") if l.strip()]
|
||||
translated = lines[-1] if lines else title
|
||||
except Exception:
|
||||
translated = title
|
||||
|
||||
content = f"# {title}\n\n"
|
||||
content += f"**한국어**: {translated}\n\n"
|
||||
content += f"- **URL**: {link}\n"
|
||||
content += f"- **Date**: {pub_date}\n"
|
||||
|
||||
safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in title)[:40]
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
filepath = LAWS_DIR / f"JP_{today}_{safe_title}.md"
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
_import_foreign_to_devonthink(filepath, f"{translated} ({title})", "JP")
|
||||
count += 1
|
||||
|
||||
if count >= 10:
|
||||
break
|
||||
|
||||
last_check["_jp_mhlw_last"] = datetime.now().strftime("%Y-%m-%d")
|
||||
logger.info(f"JP 厚労省: {count}건")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"JP 厚労省 에러: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_eu_osha(last_check: dict) -> int:
|
||||
"""EU-OSHA — RSS 파싱 (월 1회)"""
|
||||
if not _should_run(last_check, "_eu_osha_last", 30):
|
||||
logger.debug("EU-OSHA: 이번 달 이미 실행됨, 건너뜀")
|
||||
return 0
|
||||
|
||||
logger.info("=== EU-OSHA 확인 ===")
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
resp = requests.get("https://osha.europa.eu/en/rss.xml", timeout=30)
|
||||
resp.raise_for_status()
|
||||
root = ET.fromstring(resp.content)
|
||||
|
||||
count = 0
|
||||
for item in root.iter("item"):
|
||||
title = item.findtext("title", "")
|
||||
link = item.findtext("link", "")
|
||||
description = item.findtext("description", "")
|
||||
pub_date = item.findtext("pubDate", "")
|
||||
|
||||
content = f"# {title}\n\n"
|
||||
content += f"- **URL**: {link}\n"
|
||||
content += f"- **Date**: {pub_date}\n\n"
|
||||
if description:
|
||||
content += f"## Summary\n\n{description}\n"
|
||||
|
||||
safe_title = "".join(c if c.isalnum() or c in " _-" else "" for c in title)[:50].strip() or f"item{count+1}"
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
filepath = LAWS_DIR / f"EU_{today}_{count+1:02d}_{safe_title}.md"
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
_import_foreign_to_devonthink(filepath, title, "EU")
|
||||
count += 1
|
||||
|
||||
if count >= 5:
|
||||
break
|
||||
|
||||
last_check["_eu_osha_last"] = datetime.now().strftime("%Y-%m-%d")
|
||||
logger.info(f"EU-OSHA: {count}건")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"EU-OSHA 에러: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MailPlus → DEVONthink Archive DB 이메일 수집
|
||||
- Synology MailPlus IMAP 접속
|
||||
- 마지막 동기화 이후 새 메일 가져오기
|
||||
- DEVONthink Archive DB 임포트
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import imaplib
|
||||
import email
|
||||
from email.header import decode_header
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from pkm_utils import setup_logger, load_credentials, run_applescript_inline, DATA_DIR
|
||||
|
||||
logger = setup_logger("mailplus")
|
||||
|
||||
LAST_UID_FILE = DATA_DIR / "mailplus_last_uid.txt"
|
||||
MAIL_TMP_DIR = DATA_DIR / "mail_tmp"
|
||||
MAIL_TMP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# 안전 관련 키워드 (dataOrigin 판별용)
|
||||
SAFETY_KEYWORDS = [
|
||||
"안전", "위험", "사고", "재해", "점검", "보건", "화학물질",
|
||||
"OSHA", "safety", "hazard", "incident", "KOSHA"
|
||||
]
|
||||
|
||||
|
||||
def decode_mime_header(value: str) -> str:
|
||||
"""MIME 헤더 디코딩"""
|
||||
if not value:
|
||||
return ""
|
||||
decoded_parts = decode_header(value)
|
||||
result = []
|
||||
for part, charset in decoded_parts:
|
||||
if isinstance(part, bytes):
|
||||
result.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
result.append(part)
|
||||
return " ".join(result)
|
||||
|
||||
|
||||
def load_last_uid() -> int:
|
||||
"""마지막 처리 UID 로딩"""
|
||||
if LAST_UID_FILE.exists():
|
||||
return int(LAST_UID_FILE.read_text().strip())
|
||||
return 0
|
||||
|
||||
|
||||
def save_last_uid(uid: int):
|
||||
"""마지막 처리 UID 저장"""
|
||||
LAST_UID_FILE.write_text(str(uid))
|
||||
|
||||
|
||||
def detect_data_origin(subject: str, body: str) -> str:
|
||||
"""안전 키워드 감지로 dataOrigin 판별"""
|
||||
text = (subject + " " + body).lower()
|
||||
for kw in SAFETY_KEYWORDS:
|
||||
if kw.lower() in text:
|
||||
return "work"
|
||||
return "external"
|
||||
|
||||
|
||||
def save_email_file(msg: email.message.Message, uid: int) -> Path:
|
||||
"""이메일을 .eml 파일로 저장"""
|
||||
subject = decode_mime_header(msg.get("Subject", ""))
|
||||
safe_subject = "".join(c if c.isalnum() or c in " _-" else "_" for c in subject)[:50]
|
||||
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{date_str}_{uid}_{safe_subject}.eml"
|
||||
filepath = MAIL_TMP_DIR / filename
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(msg.as_bytes())
|
||||
return filepath
|
||||
|
||||
|
||||
def get_email_body(msg: email.message.Message) -> str:
|
||||
"""이메일 본문 추출"""
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
body += payload.decode(charset, errors="replace")
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
body = payload.decode(charset, errors="replace")
|
||||
return body[:2000]
|
||||
|
||||
|
||||
def import_to_devonthink(filepath: Path, subject: str, data_origin: str):
|
||||
"""DEVONthink Archive DB로 임포트"""
|
||||
escaped_path = str(filepath).replace('"', '\\"')
|
||||
escaped_subject = subject.replace('"', '\\"').replace("'", "\\'")
|
||||
script = f'''
|
||||
tell application id "DNtp"
|
||||
set targetDB to missing value
|
||||
repeat with db in databases
|
||||
if name of db is "Archive" then
|
||||
set targetDB to db
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
|
||||
if targetDB is not missing value then
|
||||
set targetGroup to create location "/Email" in targetDB
|
||||
set theRecord to import POSIX path "{escaped_path}" to targetGroup
|
||||
add custom meta data "email" for "sourceChannel" to theRecord
|
||||
add custom meta data "{data_origin}" for "dataOrigin" to theRecord
|
||||
add custom meta data (current date) for "lastAIProcess" to theRecord
|
||||
end if
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
logger.info(f"DEVONthink 임포트: {subject[:40]}")
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 임포트 실패: {e}")
|
||||
|
||||
|
||||
def run():
|
||||
"""메인 실행"""
|
||||
logger.info("=== MailPlus 이메일 수집 시작 ===")
|
||||
|
||||
creds = load_credentials()
|
||||
host = creds.get("MAILPLUS_HOST")
|
||||
port = int(creds.get("MAILPLUS_PORT", "993"))
|
||||
user = creds.get("MAILPLUS_USER")
|
||||
password = creds.get("MAILPLUS_PASS")
|
||||
|
||||
if not all([host, user, password]):
|
||||
logger.error("MAILPLUS 접속 정보가 불완전합니다. credentials.env를 확인하세요.")
|
||||
sys.exit(1)
|
||||
|
||||
last_uid = load_last_uid()
|
||||
logger.info(f"마지막 처리 UID: {last_uid}")
|
||||
|
||||
try:
|
||||
# IMAP SSL 접속
|
||||
mail = imaplib.IMAP4_SSL(host, port)
|
||||
mail.login(user, password)
|
||||
mail.select("INBOX")
|
||||
logger.info("IMAP 접속 성공")
|
||||
|
||||
# 마지막 UID 이후 메일 검색
|
||||
if last_uid > 0:
|
||||
status, data = mail.uid("search", None, f"UID {last_uid + 1}:*")
|
||||
else:
|
||||
# 최초 실행: 최근 7일치만
|
||||
from datetime import timedelta
|
||||
since = (datetime.now() - timedelta(days=7)).strftime("%d-%b-%Y")
|
||||
status, data = mail.uid("search", None, f"SINCE {since}")
|
||||
|
||||
if status != "OK":
|
||||
logger.error(f"메일 검색 실패: {status}")
|
||||
mail.logout()
|
||||
sys.exit(1)
|
||||
|
||||
uids = data[0].split()
|
||||
logger.info(f"새 메일: {len(uids)}건")
|
||||
|
||||
max_uid = last_uid
|
||||
imported = 0
|
||||
|
||||
for uid_bytes in uids:
|
||||
uid = int(uid_bytes)
|
||||
if uid <= last_uid:
|
||||
continue
|
||||
|
||||
status, msg_data = mail.uid("fetch", uid_bytes, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
|
||||
raw_email = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw_email)
|
||||
|
||||
subject = decode_mime_header(msg.get("Subject", "(제목 없음)"))
|
||||
body = get_email_body(msg)
|
||||
data_origin = detect_data_origin(subject, body)
|
||||
|
||||
filepath = save_email_file(msg, uid)
|
||||
import_to_devonthink(filepath, subject, data_origin)
|
||||
|
||||
max_uid = max(max_uid, uid)
|
||||
imported += 1
|
||||
|
||||
if max_uid > last_uid:
|
||||
save_last_uid(max_uid)
|
||||
|
||||
mail.logout()
|
||||
logger.info(f"=== MailPlus 수집 완료 — {imported}건 임포트 ===")
|
||||
|
||||
except imaplib.IMAP4.error as e:
|
||||
logger.error(f"IMAP 에러: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"예상치 못한 에러: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PKM 일일 다이제스트
|
||||
- DEVONthink 오늘 추가/수정 집계
|
||||
- law_monitor 법령 변경 건 파싱
|
||||
- OmniFocus 완료/추가/기한초과 집계
|
||||
- 상위 뉴스 Ollama 요약
|
||||
- OmniFocus 액션 자동 생성
|
||||
- 90일 지난 다이제스트 아카이브
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from pkm_utils import (
|
||||
setup_logger, load_credentials, run_applescript_inline,
|
||||
ollama_generate, count_log_errors, PROJECT_ROOT, LOGS_DIR, DATA_DIR
|
||||
)
|
||||
|
||||
logger = setup_logger("digest")
|
||||
|
||||
DIGEST_DIR = DATA_DIR / "digests"
|
||||
DIGEST_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def get_devonthink_stats() -> dict:
|
||||
"""DEVONthink 오늘 추가/수정 문서 집계"""
|
||||
script = '''
|
||||
tell application id "DNtp"
|
||||
set today to current date
|
||||
set time of today to 0
|
||||
set stats to {}
|
||||
|
||||
repeat with db in databases
|
||||
set dbName to name of db
|
||||
set addedCount to count of (search "date:today" in db)
|
||||
set modifiedCount to count of (search "modified:today" in db)
|
||||
|
||||
if addedCount > 0 or modifiedCount > 0 then
|
||||
set end of stats to dbName & ":" & addedCount & ":" & modifiedCount
|
||||
end if
|
||||
end repeat
|
||||
|
||||
set AppleScript's text item delimiters to "|"
|
||||
return stats as text
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
result = run_applescript_inline(script)
|
||||
stats = {}
|
||||
if result:
|
||||
for item in result.split("|"):
|
||||
parts = item.split(":")
|
||||
if len(parts) == 3:
|
||||
stats[parts[0]] = {"added": int(parts[1]), "modified": int(parts[2])}
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 집계 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_omnifocus_stats() -> dict:
|
||||
"""OmniFocus 오늘 완료/추가/기한초과 집계"""
|
||||
script = '''
|
||||
tell application "OmniFocus"
|
||||
tell default document
|
||||
set today to current date
|
||||
set time of today to 0
|
||||
set tomorrow to today + 1 * days
|
||||
|
||||
set completedCount to count of (every flattened task whose completed is true and completion date ≥ today)
|
||||
set addedCount to count of (every flattened task whose creation date ≥ today)
|
||||
set overdueCount to count of (every flattened task whose completed is false and due date < today and due date is not missing value)
|
||||
|
||||
return (completedCount as text) & "|" & (addedCount as text) & "|" & (overdueCount as text)
|
||||
end tell
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
result = run_applescript_inline(script)
|
||||
parts = result.split("|")
|
||||
return {
|
||||
"completed": int(parts[0]) if len(parts) > 0 else 0,
|
||||
"added": int(parts[1]) if len(parts) > 1 else 0,
|
||||
"overdue": int(parts[2]) if len(parts) > 2 else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"OmniFocus 집계 실패: {e}")
|
||||
return {"completed": 0, "added": 0, "overdue": 0}
|
||||
|
||||
|
||||
def parse_law_changes() -> list:
|
||||
"""law_monitor 로그에서 오늘 법령 변경 건 파싱"""
|
||||
log_file = LOGS_DIR / "law_monitor.log"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
changes = []
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if today in line and "변경 감지" in line:
|
||||
# "[2026-03-26 07:00:15] [law_monitor] [INFO] 변경 감지: 산업안전보건법 — 공포일자 ..."
|
||||
match = re.search(r"변경 감지: (.+?)$", line)
|
||||
if match:
|
||||
changes.append(match.group(1).strip())
|
||||
return changes
|
||||
|
||||
|
||||
def get_inbox_count() -> int:
|
||||
"""DEVONthink Inbox 미처리 문서 수"""
|
||||
script = '''
|
||||
tell application id "DNtp"
|
||||
repeat with db in databases
|
||||
if name of db is "Inbox" then
|
||||
return count of children of root group of db
|
||||
end if
|
||||
end repeat
|
||||
return 0
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
return int(run_applescript_inline(script))
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def create_omnifocus_task(task_name: str, note: str = "", flagged: bool = False):
|
||||
"""OmniFocus 작업 생성"""
|
||||
flag_str = "true" if flagged else "false"
|
||||
escaped_name = task_name.replace('"', '\\"')
|
||||
escaped_note = note.replace('"', '\\"')
|
||||
script = f'''
|
||||
tell application "OmniFocus"
|
||||
tell default document
|
||||
make new inbox task with properties {{name:"{escaped_name}", note:"{escaped_note}", flagged:{flag_str}}}
|
||||
end tell
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
logger.info(f"OmniFocus 작업 생성: {task_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"OmniFocus 작업 생성 실패: {e}")
|
||||
|
||||
|
||||
def get_system_health() -> dict:
|
||||
"""각 모듈 로그의 최근 24시간 ERROR 카운트"""
|
||||
modules = ["law_monitor", "mailplus", "digest", "embed", "auto_classify"]
|
||||
health = {}
|
||||
for mod in modules:
|
||||
log_file = LOGS_DIR / f"{mod}.log"
|
||||
health[mod] = count_log_errors(log_file, since_hours=24)
|
||||
return health
|
||||
|
||||
|
||||
def generate_digest():
|
||||
"""다이제스트 생성"""
|
||||
logger.info("=== Daily Digest 생성 시작 ===")
|
||||
today = datetime.now()
|
||||
date_str = today.strftime("%Y-%m-%d")
|
||||
|
||||
# 데이터 수집
|
||||
dt_stats = get_devonthink_stats()
|
||||
of_stats = get_omnifocus_stats()
|
||||
law_changes = parse_law_changes()
|
||||
inbox_count = get_inbox_count()
|
||||
system_health = get_system_health()
|
||||
|
||||
# 마크다운 생성
|
||||
md = f"# PKM Daily Digest — {date_str}\n\n"
|
||||
|
||||
# DEVONthink 현황
|
||||
md += "## DEVONthink 변화\n\n"
|
||||
if dt_stats:
|
||||
md += "| DB | 신규 | 수정 |\n|---|---|---|\n"
|
||||
total_added = 0
|
||||
total_modified = 0
|
||||
for db_name, counts in dt_stats.items():
|
||||
md += f"| {db_name} | {counts['added']} | {counts['modified']} |\n"
|
||||
total_added += counts["added"]
|
||||
total_modified += counts["modified"]
|
||||
md += f"| **합계** | **{total_added}** | **{total_modified}** |\n\n"
|
||||
else:
|
||||
md += "변화 없음\n\n"
|
||||
|
||||
# 법령 변경
|
||||
md += "## 법령 변경\n\n"
|
||||
if law_changes:
|
||||
for change in law_changes:
|
||||
md += f"- {change}\n"
|
||||
md += "\n"
|
||||
else:
|
||||
md += "변경 없음\n\n"
|
||||
|
||||
# OmniFocus 현황
|
||||
md += "## OmniFocus 현황\n\n"
|
||||
md += f"- 완료: {of_stats['completed']}건\n"
|
||||
md += f"- 신규: {of_stats['added']}건\n"
|
||||
md += f"- 기한초과: {of_stats['overdue']}건\n\n"
|
||||
|
||||
# Inbox 상태
|
||||
md += f"## Inbox 미처리: {inbox_count}건\n\n"
|
||||
|
||||
# 시스템 상태
|
||||
md += "## 시스템 상태\n\n"
|
||||
total_errors = sum(system_health.values())
|
||||
if total_errors == 0:
|
||||
md += "모든 모듈 정상\n\n"
|
||||
else:
|
||||
md += "| 모듈 | 에러 수 |\n|---|---|\n"
|
||||
for mod, cnt in system_health.items():
|
||||
status = f"**{cnt}**" if cnt > 0 else "0"
|
||||
md += f"| {mod} | {status} |\n"
|
||||
md += "\n"
|
||||
|
||||
# 파일 저장
|
||||
digest_file = DIGEST_DIR / f"{date_str}_digest.md"
|
||||
with open(digest_file, "w", encoding="utf-8") as f:
|
||||
f.write(md)
|
||||
logger.info(f"다이제스트 저장: {digest_file}")
|
||||
|
||||
# DEVONthink 저장
|
||||
import_digest_to_devonthink(digest_file, date_str)
|
||||
|
||||
# OmniFocus 액션 자동 생성
|
||||
if law_changes:
|
||||
for change in law_changes:
|
||||
create_omnifocus_task(f"법령 변경 검토: {change[:30]}", note=change)
|
||||
|
||||
if inbox_count >= 3:
|
||||
create_omnifocus_task(f"Inbox 정리 ({inbox_count}건 미처리)", note="DEVONthink Inbox에 미분류 문서가 쌓여있습니다.")
|
||||
|
||||
if of_stats["overdue"] > 0:
|
||||
create_omnifocus_task(f"기한초과 작업 처리 ({of_stats['overdue']}건)", flagged=True)
|
||||
|
||||
# 90일 지난 다이제스트 아카이브
|
||||
archive_old_digests()
|
||||
|
||||
logger.info("=== Daily Digest 완료 ===")
|
||||
|
||||
|
||||
def import_digest_to_devonthink(filepath: Path, date_str: str):
|
||||
"""다이제스트를 DEVONthink에 저장"""
|
||||
escaped_path = str(filepath).replace('"', '\\"')
|
||||
script = f'''
|
||||
tell application id "DNtp"
|
||||
repeat with db in databases
|
||||
if name of db is "00_Note_BOX" then
|
||||
set targetGroup to create location "/Daily_Digest" in db
|
||||
import POSIX path "{escaped_path}" to targetGroup
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 다이제스트 임포트 실패: {e}")
|
||||
|
||||
|
||||
def archive_old_digests():
|
||||
"""90일 지난 다이제스트 이동"""
|
||||
cutoff = datetime.now() - timedelta(days=90)
|
||||
for f in DIGEST_DIR.glob("*_digest.md"):
|
||||
try:
|
||||
date_part = f.stem.split("_digest")[0]
|
||||
file_date = datetime.strptime(date_part, "%Y-%m-%d")
|
||||
if file_date < cutoff:
|
||||
archive_dir = DIGEST_DIR / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
f.rename(archive_dir / f.name)
|
||||
logger.info(f"아카이브: {f.name}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_digest()
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
PKM 시스템 공통 유틸리티
|
||||
- 로거 설정 (파일 + 콘솔)
|
||||
- credentials.env 로딩
|
||||
- osascript 호출 래퍼
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 프로젝트 루트 디렉토리
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
LOGS_DIR = PROJECT_ROOT / "logs"
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
|
||||
APPLESCRIPT_DIR = PROJECT_ROOT / "applescript"
|
||||
|
||||
# 디렉토리 생성
|
||||
LOGS_DIR.mkdir(exist_ok=True)
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def setup_logger(name: str) -> logging.Logger:
|
||||
"""모듈별 로거 설정 — 파일 + 콘솔 핸들러"""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger # 중복 핸들러 방지
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 파일 핸들러
|
||||
fh = logging.FileHandler(LOGS_DIR / f"{name}.log", encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
|
||||
# 콘솔 핸들러
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(logging.INFO)
|
||||
ch.setFormatter(fmt)
|
||||
logger.addHandler(ch)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def load_credentials() -> dict:
|
||||
"""~/.config/pkm/credentials.env 로딩 + 누락 키 경고"""
|
||||
cred_path = Path.home() / ".config" / "pkm" / "credentials.env"
|
||||
if not cred_path.exists():
|
||||
# 폴백: 프로젝트 내 credentials.env (개발용)
|
||||
cred_path = PROJECT_ROOT / "credentials.env"
|
||||
|
||||
if cred_path.exists():
|
||||
load_dotenv(cred_path)
|
||||
else:
|
||||
print(f"[경고] credentials.env를 찾을 수 없습니다: {cred_path}")
|
||||
|
||||
keys = {
|
||||
"CLAUDE_API_KEY": os.getenv("CLAUDE_API_KEY"),
|
||||
"LAW_OC": os.getenv("LAW_OC"),
|
||||
"NAS_DOMAIN": os.getenv("NAS_DOMAIN"),
|
||||
"NAS_TAILSCALE_IP": os.getenv("NAS_TAILSCALE_IP"),
|
||||
"NAS_PORT": os.getenv("NAS_PORT", "15001"),
|
||||
"MAILPLUS_HOST": os.getenv("MAILPLUS_HOST"),
|
||||
"MAILPLUS_PORT": os.getenv("MAILPLUS_PORT", "993"),
|
||||
"MAILPLUS_USER": os.getenv("MAILPLUS_USER"),
|
||||
"MAILPLUS_PASS": os.getenv("MAILPLUS_PASS"),
|
||||
"GPU_SERVER_IP": os.getenv("GPU_SERVER_IP"),
|
||||
}
|
||||
|
||||
missing = [k for k, v in keys.items() if not v and k not in ("GPU_SERVER_IP", "CLAUDE_API_KEY")]
|
||||
if missing:
|
||||
print(f"[경고] 누락된 인증 키: {', '.join(missing)}")
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def run_applescript(script_path: str, *args) -> str:
|
||||
"""osascript 호출 래퍼 + 에러 캡처"""
|
||||
cmd = ["osascript", str(script_path)] + [str(a) for a in args]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"AppleScript 에러: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"AppleScript 타임아웃: {script_path}")
|
||||
|
||||
|
||||
def run_applescript_inline(script: str) -> str:
|
||||
"""인라인 AppleScript 실행 — 단일 -e 방식"""
|
||||
cmd = ["osascript", "-e", script]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"AppleScript 에러: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("AppleScript 타임아웃 (인라인)")
|
||||
|
||||
|
||||
def llm_generate(prompt: str, model: str = "mlx-community/Qwen3.5-35B-A3B-4bit",
|
||||
host: str = "http://localhost:8800", json_mode: bool = False) -> str:
|
||||
"""MLX 서버 API 호출 (OpenAI 호환)"""
|
||||
import requests
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
resp = requests.post(f"{host}/v1/chat/completions", json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
}, timeout=300)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
if not json_mode:
|
||||
return content
|
||||
# JSON 모드: thinking 허용 → 마지막 유효 JSON 객체 추출
|
||||
import re
|
||||
import json as _json
|
||||
# 배열이 포함된 JSON 객체 매칭
|
||||
all_jsons = re.findall(r'\{[^{}]*(?:\[[^\]]*\])?[^{}]*\}', content)
|
||||
for j in reversed(all_jsons):
|
||||
try:
|
||||
parsed = _json.loads(j)
|
||||
if any(k in parsed for k in ("domain_db", "tags", "domain", "classification")):
|
||||
return j
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
# 폴백: 전체에서 가장 큰 JSON 추출
|
||||
json_match = re.search(r'\{[\s\S]*\}', content)
|
||||
return json_match.group(0) if json_match else content
|
||||
|
||||
|
||||
# 하위호환 별칭
|
||||
ollama_generate = llm_generate
|
||||
|
||||
|
||||
def count_log_errors(log_file: Path, since_hours: int = 24) -> int:
|
||||
"""로그 파일에서 최근 N시간 ERROR 카운트"""
|
||||
from datetime import datetime, timedelta
|
||||
if not log_file.exists():
|
||||
return 0
|
||||
cutoff = datetime.now() - timedelta(hours=since_hours)
|
||||
count = 0
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if "[ERROR]" in line:
|
||||
try:
|
||||
ts_str = line[1:20] # [YYYY-MM-DD HH:MM:SS]
|
||||
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
||||
if ts >= cutoff:
|
||||
count += 1
|
||||
except (ValueError, IndexError):
|
||||
count += 1
|
||||
return count
|
||||
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
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
분류 프롬프트 단위 테스트
|
||||
5종 문서(법령, 뉴스, 논문, 메모, 이메일)로 분류 정확도 확인
|
||||
※ Ollama가 실행 중인 Mac mini에서 테스트해야 함
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
from pkm_utils import llm_generate, PROJECT_ROOT
|
||||
|
||||
PROMPT_TEMPLATE = (PROJECT_ROOT / "scripts" / "prompts" / "classify_document.txt").read_text()
|
||||
|
||||
# 테스트 문서 5종
|
||||
TEST_DOCUMENTS = [
|
||||
{
|
||||
"name": "법령 — 산업안전보건법 시행규칙 개정",
|
||||
"text": "산업안전보건법 시행규칙 일부개정령안 입법예고. 고용노동부는 위험성평가에 관한 지침을 개정하여 사업장의 위험성평가 실시 주기를 연 1회에서 반기 1회로 강화하고, 위험성평가 결과의 기록 보존 기간을 3년에서 5년으로 확대하는 내용을 포함합니다.",
|
||||
"expected_db": "04_Industrial safety",
|
||||
"expected_origin": "external",
|
||||
},
|
||||
{
|
||||
"name": "뉴스 — AI 기술 동향",
|
||||
"text": "OpenAI가 GPT-5를 발표했습니다. 이번 모델은 멀티모달 기능이 대폭 강화되었으며, 코드 생성 능력이 기존 대비 40% 향상되었습니다. 특히 에이전트 기능이 추가되어 복잡한 작업을 자율적으로 수행할 수 있게 되었습니다.",
|
||||
"expected_db": "05_Programming",
|
||||
"expected_origin": "external",
|
||||
},
|
||||
{
|
||||
"name": "논문 — 위험성평가 방법론",
|
||||
"text": "A Literature Review on Risk Assessment Methodologies in Manufacturing Industry. This paper reviews the current state of risk assessment practices in Korean manufacturing facilities, comparing KOSHA guidelines with international standards including ISO 45001 and OSHA regulations.",
|
||||
"expected_db": "04_Industrial safety",
|
||||
"expected_origin": "external",
|
||||
},
|
||||
{
|
||||
"name": "메모 — 업무 노트",
|
||||
"text": "오늘 공장 순회점검에서 3층 용접 작업장의 환기 시설이 미작동 상태인 것을 확인했다. 시정조치 요청서를 작성하고 생산팀장에게 전달해야 한다. TODO: 시정조치 결과 확인 (3일 내)",
|
||||
"expected_db": "04_Industrial safety",
|
||||
"expected_origin": "work",
|
||||
},
|
||||
{
|
||||
"name": "이메일 — 일반 업무",
|
||||
"text": "안녕하세요 테크니컬코리아 안현기 과장님, 지난번 요청하신 용접기 부품 견적서를 첨부합니다. 납기는 발주 후 2주입니다. 감사합니다. - ○○산업 김철수 드림",
|
||||
"expected_db": "99_Technicalkorea",
|
||||
"expected_origin": "work",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_classify_test(doc: dict) -> dict:
|
||||
"""단일 문서 분류 테스트"""
|
||||
prompt = PROMPT_TEMPLATE.replace("{document_text}", doc["text"])
|
||||
|
||||
try:
|
||||
response = llm_generate(prompt, json_mode=True)
|
||||
result = json.loads(response)
|
||||
|
||||
db_match = result.get("domain_db") == doc["expected_db"]
|
||||
origin_match = result.get("dataOrigin") == doc["expected_origin"]
|
||||
|
||||
return {
|
||||
"name": doc["name"],
|
||||
"pass": db_match and origin_match,
|
||||
"expected_db": doc["expected_db"],
|
||||
"actual_db": result.get("domain_db"),
|
||||
"expected_origin": doc["expected_origin"],
|
||||
"actual_origin": result.get("dataOrigin"),
|
||||
"tags": result.get("tags", []),
|
||||
"sub_group": result.get("sub_group"),
|
||||
"error": None,
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"name": doc["name"], "pass": False, "error": f"JSON 파싱 실패: {e}"}
|
||||
except Exception as e:
|
||||
return {"name": doc["name"], "pass": False, "error": str(e)}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("PKM 문서 분류 테스트")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
for doc in TEST_DOCUMENTS:
|
||||
print(f"\n테스트: {doc['name']}")
|
||||
result = run_classify_test(doc)
|
||||
results.append(result)
|
||||
|
||||
status = "PASS" if result["pass"] else "FAIL"
|
||||
print(f" [{status}]")
|
||||
if result.get("error"):
|
||||
print(f" 에러: {result['error']}")
|
||||
else:
|
||||
print(f" DB: {result.get('actual_db')} (기대: {result.get('expected_db')})")
|
||||
print(f" Origin: {result.get('actual_origin')} (기대: {result.get('expected_origin')})")
|
||||
print(f" 태그: {result.get('tags')}")
|
||||
print(f" 그룹: {result.get('sub_group')}")
|
||||
|
||||
# 요약
|
||||
passed = sum(1 for r in results if r["pass"])
|
||||
total = len(results)
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"결과: {passed}/{total} 통과")
|
||||
print("=" * 60)
|
||||
|
||||
# 리포트 저장
|
||||
report_path = PROJECT_ROOT / "docs" / "test-report.md"
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"# 분류 테스트 리포트\n\n")
|
||||
f.write(f"실행일시: {__import__('datetime').datetime.now()}\n\n")
|
||||
f.write(f"## 결과: {passed}/{total}\n\n")
|
||||
for r in results:
|
||||
status = "PASS" if r["pass"] else "FAIL"
|
||||
f.write(f"### [{status}] {r['name']}\n")
|
||||
if r.get("error"):
|
||||
f.write(f"- 에러: {r['error']}\n")
|
||||
else:
|
||||
f.write(f"- DB: {r.get('actual_db')} (기대: {r.get('expected_db')})\n")
|
||||
f.write(f"- Origin: {r.get('actual_origin')} (기대: {r.get('expected_origin')})\n")
|
||||
f.write(f"- 태그: {r.get('tags')}\n\n")
|
||||
|
||||
print(f"\n리포트 저장: {report_path}")
|
||||
sys.exit(0 if passed == total else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user