Compare commits

...

13 Commits

Author SHA1 Message Date
Hyungi Ahn
841178ed7e feat: Phase 3 보안 강화 - API 키 AES-256 암호화
- server/encryption.py: AES-256 암호화/복호화 함수 추가
- test_admin.py: API 키 암호화 저장 및 조회 로직 구현
- static/admin.js: 암호화 상태 표시 UI 추가
- static/admin.css: 암호화 배지 스타일 추가

API 키가 이제 AES-256으로 암호화되어 저장됩니다.
2025-08-19 15:29:53 +09:00
Hyungi Ahn
1e098999c1 feat: AI 서버 관리 페이지 Phase 3 보안 강화 - JWT 인증 시스템
🔐 JWT 기반 로그인 시스템:
- 로그인 페이지: 아름다운 애니메이션과 보안 정보 표시
- JWT 토큰: 24시간 또는 30일 (Remember Me) 만료 설정
- 비밀번호 암호화: bcrypt 해싱으로 안전한 저장
- 계정 잠금: 5회 실패 시 15분 자동 잠금

👥 사용자 계정 관리:
- admin/admin123 (관리자 권한)
- hyungi/hyungi123 (시스템 권한)
- 역할 기반 접근 제어 (RBAC)

🛡️ 보안 기능:
- 토큰 자동 검증 및 만료 처리
- 감사 로그: 로그인/로그아웃/관리 작업 추적
- 안전한 세션 관리 및 토큰 정리
- 클라이언트 사이드 토큰 검증

🎨 UI/UX 개선:
- 로그인 페이지: 그라디언트 배경, 플로팅 아이콘 애니메이션
- 사용자 메뉴: 헤더에 사용자명과 로그아웃 버튼 표시
- 보안 표시: SSL, 세션 타임아웃, JWT 인증 정보
- 반응형 디자인 및 다크모드 지원

🔧 기술 구현:
- FastAPI HTTPBearer 보안 스키마
- PyJWT 토큰 생성/검증
- bcrypt 비밀번호 해싱
- 클라이언트-서버 토큰 동기화

새 파일:
- templates/login.html: 로그인 페이지 HTML
- static/login.css: 로그인 페이지 스타일
- static/login.js: 로그인 JavaScript 로직
- server/auth.py: JWT 인증 시스템 (실제 서버용)

수정된 파일:
- test_admin.py: 테스트 서버에 JWT 인증 추가
- static/admin.js: JWT 토큰 기반 API 요청으로 변경
- templates/admin.html: 사용자 메뉴 및 로그아웃 버튼 추가
- static/admin.css: 사용자 메뉴 스타일 추가

보안 레벨: Phase 1 (API Key) → Phase 3 (JWT + 감사로그)
2025-08-18 15:24:01 +09:00
Hyungi Ahn
b752e56b94 feat: AI 서버 관리 페이지 Phase 2 고급 기능 구현
🤖 모델 관리 고도화:
- 모델 다운로드: 인기 모델들 원클릭 설치 (llama, qwen, gemma, codellama, mistral)
- 모델 삭제: 확인 모달과 함께 안전한 삭제 기능
- 사용 가능한 모델 목록: 태그별 분류 (chat, code, lightweight 등)
- 모델 상세 정보: 설명, 크기, 용도별 태그 표시

�� 실시간 시스템 모니터링:
- CPU/메모리/디스크/GPU 사용률 원형 프로그레스바
- 색상 코딩: 사용률에 따른 시각적 구분 (녹색/주황/빨강)
- 실시간 업데이트: 30초마다 자동 새로고침
- 시스템 리소스 상세 정보 (코어 수, 용량, 온도 등)

🎨 고급 UI/UX:
- 모달 창: 부드러운 애니메이션과 블러 효과
- 원형 프로그레스바: CSS 기반 실시간 업데이트
- 반응형 디자인: 모바일 최적화
- 태그 시스템: 모델 분류 및 시각화

🔧 새 API 엔드포인트:
- POST /admin/models/download - 모델 다운로드
- DELETE /admin/models/{model_name} - 모델 삭제
- GET /admin/models/available - 다운로드 가능한 모델 목록
- GET /admin/system/stats - 시스템 리소스 사용률

수정된 파일:
- server/main.py: Phase 2 API 엔드포인트 추가
- test_admin.py: 테스트 모드 Phase 2 기능 추가
- templates/admin.html: 시스템 모니터링 섹션, 모달 창 추가
- static/admin.css: 모니터링 차트, 모달 스타일 추가
- static/admin.js: Phase 2 기능 JavaScript 구현
2025-08-18 13:45:04 +09:00
Hyungi Ahn
e102ce6db9 feat: AI 서버 관리 페이지 Phase 1 구현
- 웹 기반 관리 대시보드 추가 (/admin)
- 시스템 상태 모니터링 (AI 서버, Ollama, 활성 모델, API 호출)
- 모델 관리 기능 (목록 조회, 테스트, 새로고침)
- API 키 관리 시스템 (생성, 조회, 삭제)
- 반응형 UI/UX 디자인 (모바일 지원)
- 테스트 모드 서버 (test_admin.py) 추가
- 보안: API 키 기반 인증, 키 마스킹
- 실시간 업데이트 (30초 자동 새로고침)

구현 파일:
- templates/admin.html: 관리 페이지 HTML
- static/admin.css: 관리 페이지 스타일
- static/admin.js: 관리 페이지 JavaScript
- server/main.py: 관리 API 엔드포인트 추가
- test_admin.py: 맥북프로 테스트용 서버
- README.md: 관리 페이지 문서 업데이트
2025-08-18 13:33:39 +09:00
hyungi
cb009f7393 feat: 완전한 웹 UI 구현 및 문서 처리 파이프라인 완성
 새로운 기능:
- FastAPI 기반 완전한 웹 UI 구현
- 반응형 디자인 (모바일/태블릿 지원)
- 드래그앤드롭 파일 업로드 인터페이스
- 실시간 AI 챗봇 인터페이스
- 문서 관리 및 검색 시스템
- 진행률 표시 및 상태 알림

🎨 UI 구성:
- 메인 대시보드: 서버 상태, 통계, 빠른 접근
- 파일 업로드: 드래그앤드롭, 처리 옵션, 진행률
- 문서 관리: 검색, 정렬, 미리보기, 다운로드
- AI 챗봇: 실시간 대화, 문서 기반 RAG, 빠른 질문

🔧 기술 스택:
- FastAPI + Jinja2 템플릿
- 모던 CSS (그라디언트, 애니메이션)
- Font Awesome 아이콘
- JavaScript (ES6+)

🚀 완성된 기능:
- 파일 업로드 → 텍스트 추출 → 임베딩 → 번역 → HTML 생성
- 벡터 검색 및 RAG 기반 질의응답
- 다중 모델 지원 (기본/부스팅/영어 전용)
- API 키 인증 및 CORS 설정
- NAS 연동 및 파일 내보내기
2025-08-14 08:09:48 +09:00
hyungi
ef64aaec84 feat: export pipeline outputs (HTML copy + upload archiving) via EXPORT_* envs 2025-08-13 08:53:05 +09:00
hyungi
8d87b1f46b feat: add summarization to pipeline (summarize + summary_sentences + summary_language) 2025-08-13 08:50:06 +09:00
hyungi
6346635ac1 feat: add /pipeline/ingest_file endpoint for .txt/.pdf upload 2025-08-13 08:48:17 +09:00
hyungi
6e7cf8eafa feat: pipeline options translate/target_language; allow HTML-only without translation 2025-08-13 08:46:08 +09:00
hyungi
a280304adc feat: document pipeline (embedding->Korean translation->HTML). Add /pipeline/ingest endpoint 2025-08-13 08:45:01 +09:00
hyungi
b430a27215 Merge commit '397efb86dc84197b74d9a3b16a11b1d0d534ad9e' as 'integrations/document-ai' 2025-08-13 08:38:41 +09:00
hyungi
397efb86dc Squashed 'integrations/document-ai/' content from commit 9093611
git-subtree-dir: integrations/document-ai
git-subtree-split: 9093611c9629c0de3db760878ec9929f50add5ed
2025-08-13 08:38:41 +09:00
hyungi
9c70d3e8a1 chore: save WIP before importing Document-AI subtree 2025-08-13 08:38:30 +09:00
57 changed files with 13162 additions and 16 deletions

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ build/
data/
*.pdf
# Local env
.env

12
HYUNGI-HOME-CA.crt Normal file
View File

@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBtzCCAV6gAwIBAgIRAJlMAYJ+9FWuLuhaeqLKuzEwCgYIKoZIzj0EAwIwOjEX
MBUGA1UEChMOSFlVTkdJLUhPTUUtQ0ExHzAdBgNVBAMTFkhZVU5HSS1IT01FLUNB
IFJvb3QgQ0EwHhcNMjUwODEwMjI1NjA0WhcNMzUwODA4MjI1NjA0WjA6MRcwFQYD
VQQKEw5IWVVOR0ktSE9NRS1DQTEfMB0GA1UEAxMWSFlVTkdJLUhPTUUtQ0EgUm9v
dCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBrpCKBTfIvPdTDXW/qXUnqO
sOMOmSR4cBsDIh5hpNqTzDmAGWv8y7iSJ3s0KBtPfOE80IsgAEMGkO8iWIQQDESj
RTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQW
BBRPNRdB/SiyYcBFf5TimQ7YI01ZcjAKBggqhkjOPQQDAgNHADBEAiBZ1VLgInhw
Ad/fdgAg7mKPeZGhAq7XZ0RIlrzbGw0JTAIgT415n4A3kLKhsHhrkrfWuJvOavgN
D4csz04qpbswPgM=
-----END CERTIFICATE-----

View File

@@ -213,6 +213,42 @@ curl -s -X POST http://localhost:26000/paperless/hook \
해당 훅은 문서 도착을 통지받는 용도로 제공됩니다. 실제 본문 텍스트는 Paperless API로 조회해 `/index/upsert`로 추가하세요.
### Paperless 배치 동기화(`/paperless/sync`)
### 문서 파이프라인(`/pipeline/ingest`)
첨부 문서(텍스트가 준비된 상태: OCR/추출 선행) → (옵션)요약 → (옵션)번역 → 임베딩 → HTML 생성까지 처리합니다.
```bash
curl -s -X POST http://localhost:26000/pipeline/ingest \
-H 'Content-Type: application/json' -H 'X-API-Key: <키>' \
-d '{
"doc_id": "doc-2025-08-13-001",
"text": "(여기에 문서 텍스트)",
"generate_html": true,
"translate": true,
"target_language": "ko",
"summarize": false,
"summary_sentences": 5,
"summary_language": null
}'
```
응답에 `html_path`가 포함됩니다.
- 요약 켜짐(`summarize=true`): 청크별 요약 후 통합 요약을 생성해 사용(기본 5문장). `summary_language`로 요약 언어 선택 가능(기본 번역 언어와 동일, 번역 off면 ko).
- 번역 켜짐(`translate=true`): 최종 텍스트를 대상 언어로 번역해 HTML+인덱스화.
- 번역 꺼짐(`translate=false`): 최종 텍스트(요약 또는 원문)로 HTML+인덱스화.
파일 업로드 버전(`/pipeline/ingest_file`): `.txt`/`.pdf` 지원
```bash
curl -s -X POST http://localhost:26000/pipeline/ingest_file \
-H 'X-API-Key: <키>' \
-F 'file=@/path/to/file.pdf' \
-F 'doc_id=doc-001' \
-F 'generate_html=true' \
-F 'translate=false' \
-F 'target_language=ko'
```
Paperless에서 다수 문서를 일괄 인덱싱합니다.
@@ -249,6 +285,11 @@ curl -s -X POST http://localhost:26000/paperless/sync \
- `EMBEDDING_MODEL`(기본 `nomic-embed-text`)
- `INDEX_PATH`(기본 `data/index.jsonl`)
- `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용
- `PAPERLESS_VERIFY_SSL`(기본 `true`): Paperless HTTPS 검증 비활성화는 `false`
- `PAPERLESS_CA_BUNDLE`(선택): 신뢰할 CA 번들 경로 지정 시 해당 번들로 검증
- `OUTPUT_DIR`(기본 `outputs`): 파이프라인 산출물(HTML) 저장 루트
- `EXPORT_HTML_DIR`(선택): HTML 산출물 사본을 내보낼 디렉터리(예: 시놀로지 공유 폴더)
- `EXPORT_UPLOAD_DIR`(선택): 업로드 원본 파일 보관 디렉터리
- `API_KEY`(선택): 설정 시 모든 민감 엔드포인트 호출에 `X-API-Key` 헤더 필요
- `CORS_ORIGINS`(선택): CORS 허용 오리진(쉼표 구분), 미설정 시 `*`
@@ -278,11 +319,55 @@ curl -s -X POST http://localhost:26000/v1/chat/completions \
}'
```
## AI 서버 관리 페이지 (Admin Dashboard)
AI 서버의 효율적인 관리를 위한 웹 기반 관리 페이지를 제공합니다.
### 관리 페이지 접근
- **URL**: `http://localhost:26000/admin`
- **인증**: API 키 기반 (환경변수 `API_KEY` 설정 필요)
### 주요 기능
#### Phase 1: 기본 관리 기능 ✅
- **시스템 상태 대시보드**: 서버/Ollama/모델 상태 실시간 모니터링
- **모델 관리**: 설치된 모델 목록, 활성 모델 현황, 모델별 사용 통계
- **API 키 관리**: 키 생성/조회/삭제, 사용량 모니터링
#### Phase 2: 고급 기능 (계획)
- **모델 다운로드/삭제**: Ollama 모델 원격 관리
- **실시간 모니터링**: CPU/메모리/GPU 사용률, API 호출 통계
- **설정 관리**: 환경변수 편집, Paperless 연동 설정
#### Phase 3: 보안 강화 (계획)
- **인증 시스템**: JWT 기반 로그인, 2FA 지원
- **접근 제어**: IP 화이트리스트, 권한 관리
- **감사 로그**: 모든 관리 작업 기록 및 추적
### 보안 고려사항
- **API 키 암호화**: AES-256 암호화 저장
- **HTTPS 강제**: SSL/TLS 인증서 필수
- **접근 로그**: 모든 관리 페이지 접근 기록
- **민감 정보 보호**: 로그에서 API 키 자동 마스킹
### 사용 예시
```bash
# API 키 설정
export API_KEY=your-secure-api-key
# 서버 실행
python -m server.main
# 관리 페이지 접근
curl -H "X-API-Key: your-secure-api-key" http://localhost:26000/admin
```
## 이 저장소 사용 계획
1) Ollama API를 감싸는 경량 서버(Express 또는 FastAPI) 추가
2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
3) 헬스체크/모델 선택/리밋/로깅 옵션 제공
1) Ollama API를 감싸는 경량 서버(FastAPI) 구현 완료
2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
3) 헬스체크/모델 선택/리밋/로깅 옵션 제공
4) 🚧 웹 기반 관리 페이지 구현 중 (Phase 1)
우선 본 문서로 설치/선택 가이드를 정리했으며, 다음 단계에서 서버 스켈레톤과 샘플 클라이언트를 추가할 예정입니다.
우선 본 문서로 설치/선택 가이드를 정리했으며, 현재 관리 페이지와 고급 기능들을 단계적으로 추가하고 있습니다.

13
ca/ca-bundle.pem Normal file
View File

@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB4DCCAYagAwIBAgIQNYeMnRkkRCMSymCTYWVHLzAKBggqhkjOPQQDAjA6MRcw
FQYDVQQKEw5IWVVOR0ktSE9NRS1DQTEfMB0GA1UEAxMWSFlVTkdJLUhPTUUtQ0Eg
Um9vdCBDQTAeFw0yNTA4MTAyMjU2MDVaFw0zNTA4MDgyMjU2MDVaMEIxFzAVBgNV
BAoTDkhZVU5HSS1IT01FLUNBMScwJQYDVQQDEx5IWVVOR0ktSE9NRS1DQSBJbnRl
cm1lZGlhdGUgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARuqjmRgxRCr7aW
VDEhP2cquiFwdL6QYEHQOsC1L0MFQRcF42oohIST3D+cA4r42KLvUyBmpd+MId1m
R7mwvt2Go2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAd
BgNVHQ4EFgQUKaSBWtPK3Fq3F4mS3i+INcb5LTQwHwYDVR0jBBgwFoAUTzUXQf0o
smHARX+U4pkO2CNNWXIwCgYIKoZIzj0EAwIDSAAwRQIgBXlUO6QZNqJMZLs5q+DB
mJX5mQOKLAX9xve1zDK5XFYCIQDHT1myj9bWHDF5ZKMdzqtQCGNsTxK9x99gxmhn
fFW+3g==
-----END CERTIFICATE-----

13
ca/intermediate_ca.crt Normal file
View File

@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB4DCCAYagAwIBAgIQNYeMnRkkRCMSymCTYWVHLzAKBggqhkjOPQQDAjA6MRcw
FQYDVQQKEw5IWVVOR0ktSE9NRS1DQTEfMB0GA1UEAxMWSFlVTkdJLUhPTUUtQ0Eg
Um9vdCBDQTAeFw0yNTA4MTAyMjU2MDVaFw0zNTA4MDgyMjU2MDVaMEIxFzAVBgNV
BAoTDkhZVU5HSS1IT01FLUNBMScwJQYDVQQDEx5IWVVOR0ktSE9NRS1DQSBJbnRl
cm1lZGlhdGUgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARuqjmRgxRCr7aW
VDEhP2cquiFwdL6QYEHQOsC1L0MFQRcF42oohIST3D+cA4r42KLvUyBmpd+MId1m
R7mwvt2Go2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAd
BgNVHQ4EFgQUKaSBWtPK3Fq3F4mS3i+INcb5LTQwHwYDVR0jBBgwFoAUTzUXQf0o
smHARX+U4pkO2CNNWXIwCgYIKoZIzj0EAwIDSAAwRQIgBXlUO6QZNqJMZLs5q+DB
mJX5mQOKLAX9xve1zDK5XFYCIQDHT1myj9bWHDF5ZKMdzqtQCGNsTxK9x99gxmhn
fFW+3g==
-----END CERTIFICATE-----

32
ca/standard-cert.crt Normal file
View File

@@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIDAjCCAqmgAwIBAgIQX0j/5HufTq45+4leMkBrDDAKBggqhkjOPQQDAjBCMRcw
FQYDVQQKEw5IWVVOR0ktSE9NRS1DQTEnMCUGA1UEAxMeSFlVTkdJLUhPTUUtQ0Eg
SW50ZXJtZWRpYXRlIENBMB4XDTI1MDgxMTAwMjkxOFoXDTI3MDgxMTAwMzAxOFow
FTETMBEGA1UEAxMKaHl1bmdpLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBAKdg4RayoCrBAyQw4Ql4ojQr6cGKO8qmLPwkk026UI1xjoPqXcYya2CF
P0yvSrlsuEGlltBFAwSyYcCiRKQzQ1E7o5PN6wFwYo1eo1BpXbBUQlrwRz3Vd1ZJ
6zWoFka3EbK6Ht4iB6Fp8/PDB7bqDiLXjuBwkQb6YeWn5Ff0kXxaiXsk0VbOjtrr
lPkq/M0COJTp33DVAKsW4CzjsTdSKns1k6xPuh19bIsXA56BpoyVks9YbFN2rx8b
J3jPSXwsipV6QxIeqvbXSwqSxrvUzhansyAQNaHOuJu3ZBpv4EOhqslXi157rVb9
jYFuqBexVd69rPutuzjmbw5X+/JX+H8CAwEAAaOB4jCB3zAOBgNVHQ8BAf8EBAMC
BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBSvyMdI
BvLKmIul2mYiR4YLqLSA7jAfBgNVHSMEGDAWgBQppIFa08rcWrcXiZLeL4g1xvkt
NDAjBgNVHREEHDAaggpoeXVuZ2kubmV0ggwqLmh5dW5naS5uZXQwSQYMKwYBBAGC
pGTGKEABBDkwNwIBAQQFYWRtaW4EKzlOUG5ZdVRYTXBGMHAzemtSdEZRbjl5OEht
T3pRUnVUWm9mRFNJcGV4M28wCgYIKoZIzj0EAwIDRwAwRAIgH3rAfdCvSsjhRuQ/
WVQre2/8bnE5Pdwj/GiQmrrgwhoCIFDntMaqd/2c820gJ+juoeRQwVZkKRPwGQOE
86Fsjnb4
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIB4DCCAYagAwIBAgIQNYeMnRkkRCMSymCTYWVHLzAKBggqhkjOPQQDAjA6MRcw
FQYDVQQKEw5IWVVOR0ktSE9NRS1DQTEfMB0GA1UEAxMWSFlVTkdJLUhPTUUtQ0Eg
Um9vdCBDQTAeFw0yNTA4MTAyMjU2MDVaFw0zNTA4MDgyMjU2MDVaMEIxFzAVBgNV
BAoTDkhZVU5HSS1IT01FLUNBMScwJQYDVQQDEx5IWVVOR0ktSE9NRS1DQSBJbnRl
cm1lZGlhdGUgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARuqjmRgxRCr7aW
VDEhP2cquiFwdL6QYEHQOsC1L0MFQRcF42oohIST3D+cA4r42KLvUyBmpd+MId1m
R7mwvt2Go2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAd
BgNVHQ4EFgQUKaSBWtPK3Fq3F4mS3i+INcb5LTQwHwYDVR0jBBgwFoAUTzUXQf0o
smHARX+U4pkO2CNNWXIwCgYIKoZIzj0EAwIDSAAwRQIgBXlUO6QZNqJMZLs5q+DB
mJX5mQOKLAX9xve1zDK5XFYCIQDHT1myj9bWHDF5ZKMdzqtQCGNsTxK9x99gxmhn
fFW+3g==
-----END CERTIFICATE-----

47
integrations/document-ai/.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# =========================
# Python 가상환경
# =========================
# 각 개발자의 로컬 환경에 따라 내용이 다르며,
# 용량이 매우 크기 때문에 Git으로 관리하지 않습니다.
# 대신 'requirements.txt' 파일을 통해 필요한 라이브러리를 관리합니다.
nllb_env/
# =========================
# AI 모델 파일
# =========================
# 수 GB에 달하는 매우 큰 파일들입니다.
# Git은 대용량 파일을 관리하기에 적합하지 않으므로 제외합니다.
# 모델은 별도의 방법(예: 다운로드 스크립트)으로 관리해야 합니다.
models/
# =========================
# 데이터 파일
# =========================
# 원본 데이터나 학습 데이터는 용량이 클 수 있으므로 제외합니다.
data/
# =========================
# 실행 결과물
# =========================
# 코드를 실행하면 자동으로 생성되는 출력 파일들입니다.
# 소스 코드가 아니므로 버전 관리 대상에서 제외합니다.
output/
# =========================
# Python 캐시 파일
# =========================
# Python 인터프리터가 실행 속도 향상을 위해 자동으로 생성하는 파일들입니다.
__pycache__/
*.pyc
# =========================
# macOS 시스템 파일
# =========================
# macOS의 Finder가 자동으로 생성하는 시스템 파일입니다.
.DS_Store
# =========================
# IDE 및 에디터 설정
# =========================
.idea/
.vscode/

View File

@@ -0,0 +1,102 @@
# NLLB 번역 시스템 코딩 규칙
이 문서는 NLLB 번역 시스템 프로젝트의 코드 일관성과 품질을 유지하기 위한 코딩 규칙을 정의합니다.
## 1. 일반 원칙
- **언어**: 모든 코드와 주석은 **한국어**로 작성하는 것을 원칙으로 합니다. 단, 외부 라이브러리 이름이나 기술 용어 등은 예외로 합니다.
- **인코딩**: 모든 파일은 **UTF-8**로 인코딩합니다.
- **명령형**: 커밋 메시지, 함수/메서드 설명 등은 명령형(`~하기`, `~함`)으로 작성하여 명확성을 높입니다.
## 2. 코드 스타일
- **들여쓰기**: 공백 4칸을 사용합니다.
- **최대 줄 길이**: 한 줄은 120자를 넘지 않도록 합니다.
- **명명 규칙**:
- **변수**: `snake_case` (예: `translated_text`)
- **함수/메서드**: `snake_case` (예: `load_models`)
- **클래스**: `PascalCase` (예: `IntegratedTranslationSystem`)
- **상수**: `UPPER_SNAKE_CASE` (예: `NAS_MOUNT_POINT`)
- **주석**:
- 복잡한 로직이나 중요한 결정 사항에 대해서는 `#`을 사용하여 간결한 주석을 작성합니다.
- 파일의 목적과 전반적인 설명을 위해 파일 최상단에 `"""독스트링"""`을 작성합니다.
- **타입 힌팅**: 함수의 인자와 반환 값에는 타입 힌트를 명시하여 코드의 명확성을 높입니다. (예: `def process_document(input_path: str) -> TranslationResult:`)
## 3. Python 관련 규칙
- **임포트**:
1. 표준 라이브러리
2. 서드파티 라이브러리
3. 로컬 애플리케이션/라이브러리 순으로 그룹화하고, 각 그룹 사이에는 한 줄을 띄웁니다.
- 예시:
```python
import asyncio
import json
from pathlib import Path
from fastapi import FastAPI
import torch
from .integrated_translation_system import IntegratedTranslationSystem
```
- **Docstrings**: 모든 모듈, 함수, 클래스, 메서드에 대해 Google Python 스타일 가이드를 따르는 독스트링을 작성합니다.
- **오류 처리**: `try...except` 블록을 사용하여 예상되는 오류를 명시적으로 처리하고, 오류 발생 시 로그를 남기거나 적절한 예외를 발생시킵니다.
## 4. FastAPI 관련 규칙
- **엔드포인트**:
- URL 경로는 소문자와 하이픈(`-`)을 사용합니다. (예: `/system-status`)
- 응답 모델을 사용하여 API의 입출력 형식을 명확히 정의합니다.
- **비동기 처리**: I/O 바운드 작업(파일 읽기/쓰기, 네트워크 요청 등)에는 `async`와 `await`를 적극적으로 사용하여 비동기 처리를 구현합니다.
- **백그라운드 작업**: 시간이 오래 걸리는 작업(모델 로딩, 번역 등)은 `BackgroundTasks`를 사용하여 사용자 경험을 저해하지 않도록 합니다.
## 5. 프로젝트 구조
- `config/`: 설정 파일 (예: `settings.json`)
- `data/`: 원본 데이터 및 학습 데이터
- `models/`: 학습된 AI 모델 파일
- `src/`: 핵심 소스 코드
- `tests/`: 테스트 코드
- `static/`, `templates/`: FastAPI 웹 인터페이스 파일
## 6. 네트워크 설정
- **Mac Mini IP**: `192.168.1.122` (AI 번역 서버 호스트)
- **DS1525+ NAS IP**: `192.168.1.227` (문서 저장소)
- **NAS 마운트 포인트**: `/Volumes/DS1525+`
- **서버 포트**: `8080` (FastAPI 웹 서비스)
- **대시보드 접속**: `http://192.168.1.122:8080/dashboard`
### 6.1. API 키 인증
관리자용 API(`/api/restart-models`, `/api/clear-cache` 등)를 호출하려면 HTTP 요청 헤더에 API 키를 포함해야 합니다.
- **헤더 이름**: `X-API-KEY`
- **API 키 값**: `config/settings.json` 파일의 `security.api_key` 값을 사용합니다.
**cURL 예시:**
```bash
curl -X POST http://192.168.1.122:20080/api/restart-models \
-H "X-API-KEY: nllb-secret-key-!@#$%"
```
## 7. 서버 운영 및 배포 (macOS)
이 서버는 24/7 무중단 운영을 위해 macOS의 `launchd` 서비스를 통해 관리됩니다. 이를 통해 시스템 재부팅 시 자동 시작 및 예기치 않은 종료 시 자동 재시작이 보장됩니다.
- **서비스 설정 파일**: `~/Library/LaunchAgents/com.nllb-translation-system.app.plist`
- 이 파일은 서비스의 실행 방법, 로그 경로, 자동 재시작 여부 등을 정의합니다.
- **서비스 제어 명령어**:
- **시작**: `launchctl start com.nllb-translation-system.app`
- **중지**: `launchctl stop com.nllb-translation-system.app`
- **재시작 (설정 파일 수정 후)**:
1. `launchctl unload ~/Library/LaunchAgents/com.nllb-translation-system.app.plist`
2. `launchctl load ~/Library/LaunchAgents/com.nllb-translation-system.app.plist`
- **로그 확인**:
- **일반 로그**: `tail -f logs/service.log`
- **에러 로그**: `tail -f logs/service_error.log`
- **주의**: 개발 및 디버깅 시에는 `launchd` 서비스를 중지(`launchctl stop ...`)한 후, 터미널에서 직접 `python src/fastapi_with_dashboard.py`를 실행해야 포트 충돌이 발생하지 않습니다.

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
환경 설정 확인 스크립트
"""
def check_installation():
print("🔍 설치된 패키지 확인 중...")
try:
import torch
print(f"✅ PyTorch: {torch.__version__}")
# Apple Silicon MPS 확인
if torch.backends.mps.is_available():
print("✅ Apple Silicon MPS 가속 사용 가능")
else:
print("⚠️ MPS 가속 사용 불가 (CPU 모드)")
except ImportError:
print("❌ PyTorch 설치 실패")
return False
try:
import transformers
print(f"✅ Transformers: {transformers.__version__}")
except ImportError:
print("❌ Transformers 설치 실패")
return False
try:
import sentencepiece
print("✅ SentencePiece 설치 완료")
except ImportError:
print("❌ SentencePiece 설치 실패")
return False
try:
import accelerate
print("✅ Accelerate 설치 완료")
except ImportError:
print("❌ Accelerate 설치 실패")
return False
# 문서 처리 라이브러리 확인
doc_libs = []
try:
import PyPDF2
doc_libs.append("PyPDF2")
except ImportError:
pass
try:
import pdfplumber
doc_libs.append("pdfplumber")
except ImportError:
pass
try:
from docx import Document
doc_libs.append("python-docx")
except ImportError:
pass
if doc_libs:
print(f"✅ 문서 처리: {', '.join(doc_libs)}")
else:
print("⚠️ 문서 처리 라이브러리 설치 필요")
# 시스템 정보
print(f"\n📊 시스템 정보:")
print(f" Python 버전: {torch.version.python}")
if torch.backends.mps.is_available():
print(f" 디바이스: Apple Silicon (MPS)")
else:
print(f" 디바이스: CPU")
return True
if __name__ == "__main__":
print("🚀 NLLB 번역 시스템 환경 확인")
print("=" * 40)
if check_installation():
print("\n🎉 환경 설정 완료!")
print("다음 단계: NLLB 모델 다운로드")
else:
print("\n❌ 환경 설정 실패")
print("패키지 재설치 필요")

View File

@@ -0,0 +1,45 @@
{
"models": {
"translation": "facebook/nllb-200-3.3B",
"summarization": "ainize/kobart-news",
"embedding": "nomic-embed-text"
},
"translation": {
"max_length": 512,
"num_beams": 4,
"early_stopping": true,
"batch_size": 4
},
"summarization": {
"max_length": 150,
"min_length": 30,
"num_beams": 4
},
"processing": {
"chunk_size": 500,
"overlap": 50,
"concurrent_chunks": 3
},
"output": {
"html_template": "modern",
"include_toc": true,
"include_summary": true
},
"network": {
"mac_mini_ip": "192.168.1.122",
"nas_ip": "192.168.1.227",
"server_port": 20080
},
"paths": {
"nas_mount_point": "/Volumes/Media",
"document_upload_base": "Document-upload",
"originals": "originals",
"translated": "translated",
"static_hosting": "static-hosting",
"metadata": "metadata",
"local_work_path": "~/Scripts/nllb-translation-system"
},
"security": {
"api_key": "nllb-secret-key-!@#$%"
}
}

View File

@@ -0,0 +1,11 @@
번역 시스템 초기화 (디바이스: mps)
모델 로딩 중...
NLLB 번역 모델...
INFO: 192.168.1.122:51436 - "GET /api/dashboard HTTP/1.1" 200 OK
NLLB 모델 로드 완료
KoBART 요약 모델...
KoBART 모델 로드 완료
모델 로딩 완료!
INFO: 192.168.1.122:51443 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 192.168.1.122:51518 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 192.168.1.122:51563 - "GET /api/dashboard HTTP/1.1" 200 OK

View File

@@ -0,0 +1,23 @@
2025-07-25 06:59:45,307 - __main__ - INFO - 🚀 Mac Mini AI 번역 서버 with 대시보드 (v2.1)
2025-07-25 06:59:45,307 - __main__ - INFO - 📡 서버 주소: http://192.168.1.122:20080
2025-07-25 06:59:45,307 - __main__ - INFO - 📊 대시보드: http://192.168.1.122:20080/dashboard
2025-07-25 06:59:45,307 - __main__ - INFO - 📁 NAS 주소: 192.168.1.227
INFO: Started server process [20661]
INFO: Waiting for application startup.
2025-07-25 06:59:45,328 - __main__ - INFO - 🚀 Mac Mini AI 번역 서버 시작 (v2.1)
2025-07-25 06:59:45,328 - __main__ - INFO - 📍 Mac Mini IP: 192.168.1.122
2025-07-25 06:59:45,328 - __main__ - INFO - 📍 NAS IP: 192.168.1.227
2025-07-25 06:59:45,328 - __main__ - INFO - --------------------------------------------------
2025-07-25 06:59:45,328 - background_ai_service - INFO - 🚀 백그라운드 AI 서비스 시작
2025-07-25 06:59:45,328 - background_ai_service - INFO - ✅ 백그라운드 서비스 준비 완료
2025-07-25 06:59:45,351 - __main__ - INFO - ✅ NAS 연결 정상: /Volumes/Media
2025-07-25 06:59:45,357 - __main__ - INFO - ✅ 폴더 구조 확인/생성 완료
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:20080 (Press CTRL+C to quit)
Loading checkpoint shards: 0%| | 0/3 [00:00<?, ?it/s]
Loading checkpoint shards: 33%|███▎ | 1/3 [00:00<00:01, 1.80it/s]
Loading checkpoint shards: 67%|██████▋ | 2/3 [00:01<00:00, 1.68it/s]
Loading checkpoint shards: 100%|██████████| 3/3 [00:01<00:00, 2.66it/s]
Loading checkpoint shards: 100%|██████████| 3/3 [00:01<00:00, 2.32it/s]
You passed along `num_labels=3` with an incompatible id to label map: {'0': 'NEGATIVE', '1': 'POSITIVE'}. The number of labels will be overwritten to 2.

View File

@@ -0,0 +1,35 @@
# AI 모델 관련
torch>=2.0.0
transformers>=4.30.0
sentencepiece>=0.1.99
accelerate>=0.20.0
datasets>=2.12.0
# PDF 처리
PyPDF2>=3.0.1
pdfplumber>=0.9.0
# 문서 처리
python-docx>=0.8.11
# 언어 감지
langdetect>=1.0.9
# 웹 프레임워크
fastapi>=0.100.0
uvicorn[standard]>=0.22.0
python-multipart>=0.0.6
jinja2>=3.1.2
aiofiles>=23.1.0
# 유틸리티
tqdm>=4.65.0
numpy>=1.24.0
pandas>=2.0.0
# 추가 도구
requests>=2.31.0
pathlib2>=2.3.7
# 시스템 모니터링 및 백그라운드 서비스
psutil>=5.9.0

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
백그라운드 AI 모델 서비스 및 모니터링 시스템
실시간 모델 상태 추적 및 성능 지표 수집
"""
import asyncio
import time
import psutil
import threading
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
from pathlib import Path
import json
import logging
from integrated_translation_system import IntegratedTranslationSystem
@dataclass
class ModelStatus:
"""개별 모델 상태 정보"""
name: str
status: str # "loading", "ready", "error", "unloaded"
load_time: Optional[float] = None
memory_usage_mb: float = 0.0
last_used: Optional[datetime] = None
error_message: Optional[str] = None
total_processed: int = 0
@dataclass
class SystemMetrics:
"""전체 시스템 성능 지표"""
total_memory_usage_mb: float
cpu_usage_percent: float
active_jobs: int
queued_jobs: int
completed_jobs_today: int
average_processing_time: float
uptime_seconds: float
class BackgroundAIService:
"""백그라운드 AI 모델 서비스 관리자"""
def __init__(self):
self.models_status: Dict[str, ModelStatus] = {}
self.translation_system: Optional[IntegratedTranslationSystem] = None
self.start_time = datetime.now()
self.job_queue = asyncio.Queue()
self.active_jobs = {}
self.completed_jobs = []
self.metrics_history = []
# 로깅 설정
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.INFO)
# 모델 상태 초기화
self._initialize_model_status()
# 백그라운드 워커 및 모니터링 시작
self.worker_task = None
self.monitoring_task = None
def _initialize_model_status(self):
"""모델 상태 초기화"""
model_names = ["NLLB 번역", "KoBART 요약"]
for name in model_names:
self.models_status[name] = ModelStatus(
name=name,
status="unloaded"
)
async def start_service(self):
"""백그라운드 서비스 시작"""
self.logger.info("🚀 백그라운드 AI 서비스 시작")
# 모델 로딩 시작
asyncio.create_task(self._load_models())
# 백그라운드 워커 시작
self.worker_task = asyncio.create_task(self._process_job_queue())
# 모니터링 시작
self.monitoring_task = asyncio.create_task(self._collect_metrics())
self.logger.info("✅ 백그라운드 서비스 준비 완료")
async def _load_models(self):
"""AI 모델들을 순차적으로 로딩"""
try:
# NLLB 모델 로딩 시작
self.models_status["NLLB 번역"].status = "loading"
load_start = time.time()
self.translation_system = IntegratedTranslationSystem()
# 실제 모델 로딩
success = await asyncio.to_thread(self.translation_system.load_models)
if success:
load_time = time.time() - load_start
# 각 모델 상태 업데이트
self.models_status["NLLB 번역"].status = "ready"
self.models_status["NLLB 번역"].load_time = load_time
self.models_status["KoBART 요약"].status = "ready"
self.models_status["KoBART 요약"].load_time = load_time
self.logger.info(f"✅ 모든 AI 모델 로딩 완료 ({load_time:.1f}초)")
else:
# 로딩 실패
for model_name in self.models_status.keys():
self.models_status[model_name].status = "error"
self.models_status[model_name].error_message = "모델 로딩 실패"
self.logger.error("❌ AI 모델 로딩 실패")
except Exception as e:
self.logger.error(f"❌ 모델 로딩 중 오류: {e}")
for model_name in self.models_status.keys():
self.models_status[model_name].status = "error"
self.models_status[model_name].error_message = str(e)
def _get_memory_usage(self) -> float:
"""현재 프로세스의 메모리 사용량 반환 (MB)"""
try:
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024 # bytes to MB
except:
return 0.0
async def _collect_metrics(self):
"""주기적으로 시스템 메트릭 수집"""
while True:
try:
# 메모리 사용량 업데이트
total_memory = self._get_memory_usage()
# 각 모델별 메모리 사용량 추정 (실제로는 더 정교한 측정 필요)
if self.models_status["NLLB 번역"].status == "ready":
self.models_status["NLLB 번역"].memory_usage_mb = total_memory * 0.6
if self.models_status["KoBART 요약"].status == "ready":
self.models_status["KoBART 요약"].memory_usage_mb = total_memory * 0.4
# 전체 시스템 메트릭 수집
metrics = SystemMetrics(
total_memory_usage_mb=total_memory,
cpu_usage_percent=psutil.cpu_percent(),
active_jobs=len(self.active_jobs),
queued_jobs=self.job_queue.qsize(),
completed_jobs_today=len([
job for job in self.completed_jobs
if job.get('completed_at', datetime.min).date() == datetime.now().date()
]),
average_processing_time=self._calculate_average_processing_time(),
uptime_seconds=(datetime.now() - self.start_time).total_seconds()
)
# 메트릭 히스토리에 추가 (최근 100개만 유지)
self.metrics_history.append({
'timestamp': datetime.now(),
'metrics': metrics
})
if len(self.metrics_history) > 100:
self.metrics_history.pop(0)
await asyncio.sleep(5) # 5초마다 수집
except Exception as e:
self.logger.error(f"메트릭 수집 오류: {e}")
await asyncio.sleep(10)
def _calculate_average_processing_time(self) -> float:
"""최근 처리된 작업들의 평균 처리 시간 계산"""
recent_jobs = [
job for job in self.completed_jobs[-20:] # 최근 20개
if 'processing_time' in job
]
if not recent_jobs:
return 0.0
return sum(job['processing_time'] for job in recent_jobs) / len(recent_jobs)
async def _process_job_queue(self):
"""작업 큐 처리 워커"""
while True:
try:
# 큐에서 작업 가져오기
job = await self.job_queue.get()
job_id = job['job_id']
# 활성 작업에 추가
self.active_jobs[job_id] = {
'start_time': datetime.now(),
'job_data': job
}
# 실제 AI 처리
await self._process_single_job(job)
# 완료된 작업으로 이동
processing_time = (datetime.now() - self.active_jobs[job_id]['start_time']).total_seconds()
self.completed_jobs.append({
'job_id': job_id,
'completed_at': datetime.now(),
'processing_time': processing_time
})
# 활성 작업에서 제거
del self.active_jobs[job_id]
# 모델 사용 시간 업데이트
for model_status in self.models_status.values():
if model_status.status == "ready":
model_status.last_used = datetime.now()
model_status.total_processed += 1
except Exception as e:
self.logger.error(f"작업 처리 오류: {e}")
if job_id in self.active_jobs:
del self.active_jobs[job_id]
async def _process_single_job(self, job):
"""개별 작업 처리"""
# 기존 번역 시스템 로직 사용
if self.translation_system and self.models_status["NLLB 번역"].status == "ready":
result = await asyncio.to_thread(
self.translation_system.process_document,
job['file_path']
)
return result
else:
raise Exception("AI 모델이 준비되지 않음")
async def add_job(self, job_data: Dict):
"""새 작업을 큐에 추가"""
await self.job_queue.put(job_data)
def get_dashboard_data(self) -> Dict:
"""대시보드용 데이터 반환"""
current_metrics = self.metrics_history[-1]['metrics'] if self.metrics_history else None
return {
'models_status': {name: asdict(status) for name, status in self.models_status.items()},
'current_metrics': asdict(current_metrics) if current_metrics else None,
'recent_metrics': [
{
'timestamp': entry['timestamp'].isoformat(),
'metrics': asdict(entry['metrics'])
}
for entry in self.metrics_history[-20:] # 최근 20개
],
'active_jobs': len(self.active_jobs),
'completed_today': len([
job for job in self.completed_jobs
if job.get('completed_at', datetime.min).date() == datetime.now().date()
])
}
async def restart_models(self):
"""모델 재시작"""
self.logger.info("🔄 AI 모델 재시작 중...")
# 모든 모델 상태를 재로딩으로 설정
for model_status in self.models_status.values():
model_status.status = "loading"
model_status.error_message = None
# 기존 시스템 정리
if self.translation_system:
del self.translation_system
# 모델 재로딩
await self._load_models()
# 전역 서비스 인스턴스
ai_service = BackgroundAIService()

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
설정 파일(settings.json) 로더
프로젝트 전체에서 사용되는 설정을 중앙에서 관리하고 제공합니다.
"""
import json
from pathlib import Path
from typing import Dict, Any
class ConfigLoader:
def __init__(self, config_path: str = "config/settings.json"):
self.config_path = Path(config_path)
self.config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""JSON 설정 파일을 읽어 딕셔너리로 반환합니다."""
if not self.config_path.exists():
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {self.config_path}")
with open(self.config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
# 경로 설정에서 '~'를 실제 홈 디렉토리로 확장
if 'paths' in config_data:
for key, value in config_data['paths'].items():
if isinstance(value, str) and value.startswith('~/'):
config_data['paths'][key] = str(Path.home() / value[2:])
return config_data
def get_section(self, section_name: str) -> Dict[str, Any]:
"""설정의 특정 섹션을 반환합니다."""
return self.config.get(section_name, {})
@property
def network_config(self) -> Dict[str, Any]:
return self.get_section("network")
@property
def paths_config(self) -> Dict[str, Any]:
return self.get_section("paths")
@property
def models_config(self) -> Dict[str, Any]:
return self.get_section("models")
# 전역 설정 인스턴스 생성
# 프로젝트 어디서든 `from config_loader import settings`로 불러와 사용 가능
settings = ConfigLoader()
if __name__ == "__main__":
# 설정 로더 테스트
print("✅ 설정 로더 테스트")
print("-" * 30)
network = settings.network_config
print(f"네트워크 설정: {network}")
print(f" - 서버 IP: {network.get('mac_mini_ip')}")
print(f" - 서버 포트: {network.get('server_port')}")
paths = settings.paths_config
print(f"경로 설정: {paths}")
print(f" - 로컬 작업 경로: {paths.get('local_work_path')}")
print("-" * 30)
print("설정 로드 완료!")

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
KoBART 한국어 요약 모델 다운로드 및 테스트
"""
import torch
import time
from transformers import BartForConditionalGeneration, AutoTokenizer
from pathlib import Path
def download_kobart_model():
print("🔄 KoBART 한국어 요약 모델 다운로드 시작...")
print("📊 모델 크기: ~500MB, 다운로드 시간 3-5분 예상")
model_name = "ainize/kobart-news"
# 로컬 모델 저장 경로
model_dir = Path("models/kobart-news")
model_dir.mkdir(parents=True, exist_ok=True)
try:
# 1. 토크나이저 다운로드
print("\n📥 1/2: 토크나이저 다운로드 중...")
tokenizer = AutoTokenizer.from_pretrained(
model_name,
cache_dir=str(model_dir)
)
print("✅ 토크나이저 다운로드 완료")
# 2. 모델 다운로드
print("\n📥 2/2: KoBART 모델 다운로드 중...")
start_time = time.time()
model = BartForConditionalGeneration.from_pretrained(
model_name,
cache_dir=str(model_dir),
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
download_time = time.time() - start_time
print(f"✅ 모델 다운로드 완료 ({download_time/60:.1f}분 소요)")
return model, tokenizer
except Exception as e:
print(f"❌ 다운로드 실패: {e}")
return None, None
def test_kobart_model(model, tokenizer):
print("\n🧪 KoBART 요약 모델 테스트...")
# Apple Silicon 최적화
if torch.backends.mps.is_available():
device = torch.device("mps")
model = model.to(device)
print("🚀 Apple Silicon MPS 가속 사용")
else:
device = torch.device("cpu")
print("💻 CPU 모드 사용")
def summarize_text(text, max_length=150, min_length=30):
print(f"\n📝 요약 테스트:")
print(f"원문 ({len(text)}자):")
print(f"{text}")
start_time = time.time()
# 텍스트 토큰화
inputs = tokenizer(
text,
return_tensors="pt",
max_length=1024,
truncation=True,
padding=True
).to(device)
# 요약 생성
with torch.no_grad():
summary_ids = model.generate(
**inputs,
max_length=max_length,
min_length=min_length,
num_beams=4,
early_stopping=True,
no_repeat_ngram_size=2,
length_penalty=2.0
)
# 결과 디코딩
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
process_time = time.time() - start_time
print(f"\n요약 ({len(summary)}자):")
print(f"{summary}")
print(f"소요 시간: {process_time:.2f}")
print(f"압축률: {len(summary)/len(text)*100:.1f}%")
return summary, process_time
try:
# 테스트 케이스 1: 기술 문서
tech_text = """
인공지능 기술이 급속히 발전하면서 우리의 일상생활과 업무 환경에 큰 변화를 가져오고 있습니다.
특히 자연어 처리 분야에서는 번역, 요약, 질의응답 등의 기술이 크게 향상되어 실용적인 수준에
도달했습니다. 기계학습 알고리즘은 대량의 데이터를 학습하여 패턴을 파악하고, 이를 바탕으로
새로운 입력에 대해 예측이나 분류를 수행합니다. 딥러닝 기술의 발전으로 이미지 인식, 음성 인식,
자연어 이해 등의 성능이 인간 수준에 근접하거나 이를 넘어서는 경우도 생겨나고 있습니다.
이러한 기술들은 의료, 금융, 교육, 엔터테인먼트 등 다양한 분야에서 활용되고 있으며,
앞으로도 더 많은 혁신을 가져올 것으로 예상됩니다.
"""
summarize_text(tech_text.strip())
# 테스트 케이스 2: 뉴스 스타일
news_text = """
최근 발표된 연구에 따르면 인공지능을 활용한 번역 시스템의 정확도가 크게 향상되었다고 합니다.
특히 한국어와 영어, 일본어 간의 번역에서 기존 시스템 대비 20% 이상의 성능 개선을 보였습니다.
연구팀은 대규모 언어 모델과 특화된 번역 모델을 결합하여 문맥을 더 정확히 이해하고
자연스러운 번역을 생성할 수 있게 되었다고 설명했습니다. 이번 기술은 개인 사용자뿐만 아니라
기업의 글로벌 비즈니스에도 큰 도움이 될 것으로 기대됩니다.
"""
summarize_text(news_text.strip())
print(f"\n✅ KoBART 요약 모델 테스트 완료!")
return True
except Exception as e:
print(f"❌ 요약 테스트 실패: {e}")
return False
def check_total_model_size():
"""전체 모델 크기 확인"""
model_dir = Path("models")
if model_dir.exists():
import subprocess
try:
result = subprocess.run(
["du", "-sh", str(model_dir)],
capture_output=True,
text=True
)
if result.returncode == 0:
size = result.stdout.strip().split()[0]
print(f"📊 전체 모델 크기: {size}")
except:
print("📊 모델 크기 확인 불가")
if __name__ == "__main__":
print("🚀 KoBART 한국어 요약 모델 설치")
print("="*50)
# 기존 모델 확인
model_dir = Path("models/kobart-news")
if model_dir.exists() and any(model_dir.iterdir()):
print("✅ 기존 KoBART 모델 발견, 로딩 시도...")
try:
tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
model = BartForConditionalGeneration.from_pretrained(
str(model_dir),
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
print("✅ 기존 모델 로딩 완료")
except:
print("⚠️ 기존 모델 손상, 재다운로드 필요")
model, tokenizer = download_kobart_model()
else:
model, tokenizer = download_kobart_model()
if model is not None and tokenizer is not None:
print("\n🧪 요약 모델 테스트 시작...")
if test_kobart_model(model, tokenizer):
check_total_model_size()
print("\n🎉 KoBART 요약 모델 설치 및 테스트 완료!")
print("📝 다음 단계: 통합 번역 시스템 구축")
else:
print("\n❌ 요약 모델 테스트 실패")
else:
print("\n❌ KoBART 모델 다운로드 실패")

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
안정적인 한국어 요약 모델
"""
import torch
import time
from transformers import PreTrainedTokenizerFast, BartForConditionalGeneration
from pathlib import Path
def download_korean_summarizer():
print("🔄 한국어 요약 모델 다운로드 (대안)")
# 더 안정적인 모델 사용
model_name = "gogamza/kobart-summarization"
model_dir = Path("models/korean-summarizer")
model_dir.mkdir(parents=True, exist_ok=True)
try:
print("📥 토크나이저 다운로드 중...")
tokenizer = PreTrainedTokenizerFast.from_pretrained(
model_name,
cache_dir=str(model_dir)
)
print("✅ 토크나이저 다운로드 완료")
print("📥 모델 다운로드 중...")
start_time = time.time()
model = BartForConditionalGeneration.from_pretrained(
model_name,
cache_dir=str(model_dir),
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
download_time = time.time() - start_time
print(f"✅ 모델 다운로드 완료 ({download_time/60:.1f}분 소요)")
return model, tokenizer
except Exception as e:
print(f"❌ 다운로드 실패: {e}")
# 마지막 대안: 간단한 T5 모델
print("\n🔄 더 간단한 모델로 재시도...")
try:
from transformers import T5Tokenizer, T5ForConditionalGeneration
alt_model_name = "t5-small"
print(f"📥 {alt_model_name} 다운로드 중...")
tokenizer = T5Tokenizer.from_pretrained(alt_model_name)
model = T5ForConditionalGeneration.from_pretrained(
alt_model_name,
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
print("✅ T5 백업 모델 다운로드 완료")
return model, tokenizer
except Exception as e2:
print(f"❌ 백업 모델도 실패: {e2}")
return None, None
def test_summarizer(model, tokenizer, model_type="kobart"):
print(f"\n🧪 {model_type} 요약 모델 테스트...")
# Apple Silicon 최적화
if torch.backends.mps.is_available():
device = torch.device("mps")
model = model.to(device)
print("🚀 Apple Silicon MPS 가속 사용")
else:
device = torch.device("cpu")
print("💻 CPU 모드 사용")
def summarize_korean_text(text):
print(f"\n📝 한국어 요약 테스트:")
print(f"원문 ({len(text)}자):")
print(f"{text[:200]}...")
start_time = time.time()
if model_type == "t5":
# T5 모델용 프롬프트
input_text = f"summarize: {text}"
else:
# KoBART 모델용
input_text = text
# 텍스트 토큰화
inputs = tokenizer(
input_text,
return_tensors="pt",
max_length=1024,
truncation=True,
padding=True
).to(device)
# 요약 생성
with torch.no_grad():
summary_ids = model.generate(
**inputs,
max_length=150,
min_length=30,
num_beams=4,
early_stopping=True,
no_repeat_ngram_size=2,
length_penalty=1.5
)
# 결과 디코딩
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
process_time = time.time() - start_time
print(f"\n📋 요약 결과 ({len(summary)}자):")
print(f"{summary}")
print(f"⏱️ 처리 시간: {process_time:.2f}")
print(f"📊 압축률: {len(summary)/len(text)*100:.1f}%")
return summary
try:
# 한국어 테스트 텍스트
korean_text = """
인공지능과 기계학습 기술이 급속도로 발전하면서 우리의 일상생활과 업무 환경에
혁신적인 변화를 가져오고 있습니다. 특히 자연어 처리 분야에서는 번역, 요약,
대화형 AI 등의 기술이 실용적인 수준에 도달하여 다양한 서비스에 적용되고 있습니다.
기계학습 알고리즘은 대량의 텍스트 데이터를 학습하여 언어의 패턴과 의미를 이해하고,
이를 바탕으로 인간과 유사한 수준의 언어 처리 능력을 보여주고 있습니다.
딥러닝 기술의 발전으로 번역의 정확도가 크게 향상되었으며, 실시간 번역 서비스도
일상적으로 사용할 수 있게 되었습니다. 앞으로 이러한 기술들은 교육, 의료,
비즈니스 등 더 많은 분야에서 활용될 것으로 예상되며, 언어 장벽을 허물어
글로벌 소통을 더욱 원활하게 만들 것입니다.
"""
summarize_korean_text(korean_text.strip())
print(f"\n✅ 요약 모델 테스트 완료!")
return True
except Exception as e:
print(f"❌ 요약 테스트 실패: {e}")
return False
if __name__ == "__main__":
print("🚀 한국어 요약 모델 설치 (대안)")
print("="*50)
model, tokenizer = download_korean_summarizer()
if model is not None and tokenizer is not None:
# 모델 타입 판단
model_type = "t5" if "t5" in str(type(model)).lower() else "kobart"
print(f"\n🧪 {model_type} 모델 테스트 시작...")
if test_summarizer(model, tokenizer, model_type):
print("\n🎉 한국어 요약 모델 설치 및 테스트 완료!")
print("📝 다음 단계: 통합 번역 시스템 구축")
else:
print("\n❌ 요약 모델 테스트 실패")
else:
print("\n❌ 모든 요약 모델 다운로드 실패")
print("📝 요약 기능 없이 번역만으로 진행 가능")

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
NLLB-200-3.3B 모델 다운로드 및 초기 테스트
"""
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from pathlib import Path
def download_nllb_model():
print("🔄 NLLB-200-3.3B 모델 다운로드 시작...")
print("⚠️ 모델 크기: ~7GB, 다운로드 시간 10-15분 예상")
model_name = "facebook/nllb-200-3.3B"
# 로컬 모델 저장 경로
model_dir = Path("models/nllb-200-3.3B")
model_dir.mkdir(parents=True, exist_ok=True)
try:
# 1. 토크나이저 다운로드
print("\n📥 1/2: 토크나이저 다운로드 중...")
tokenizer = AutoTokenizer.from_pretrained(
model_name,
cache_dir=str(model_dir),
local_files_only=False
)
print("✅ 토크나이저 다운로드 완료")
# 2. 모델 다운로드 (시간이 오래 걸림)
print("\n📥 2/2: 모델 다운로드 중...")
print("⏳ 진행률을 확인하려면 별도 터미널에서 다음 명령어 실행:")
print(f" du -sh {model_dir}/models--facebook--nllb-200-3.3B")
start_time = time.time()
model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
cache_dir=str(model_dir),
local_files_only=False,
torch_dtype=torch.float16, # 메모리 절약
low_cpu_mem_usage=True
)
download_time = time.time() - start_time
print(f"✅ 모델 다운로드 완료 ({download_time/60:.1f}분 소요)")
return model, tokenizer
except Exception as e:
print(f"❌ 다운로드 실패: {e}")
return None, None
def test_nllb_model(model, tokenizer):
print("\n🧪 NLLB 모델 기본 테스트...")
# Apple Silicon 최적화
if torch.backends.mps.is_available():
device = torch.device("mps")
print("🚀 Apple Silicon MPS 가속 사용")
else:
device = torch.device("cpu")
print("💻 CPU 모드 사용")
try:
model = model.to(device)
# NLLB 언어 코드
lang_codes = {
"eng_Latn": "English",
"jpn_Jpan": "Japanese",
"kor_Hang": "Korean"
}
def translate_test(text, src_lang, tgt_lang, desc):
print(f"\n📝 {desc} 테스트:")
print(f"원문: {text}")
tokenizer.src_lang = src_lang
encoded = tokenizer(text, return_tensors="pt", padding=True).to(device)
start_time = time.time()
generated_tokens = model.generate(
**encoded,
forced_bos_token_id=tokenizer.lang_code_to_id[tgt_lang],
max_length=200,
num_beams=4,
early_stopping=True,
do_sample=False
)
translation_time = time.time() - start_time
result = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]
print(f"번역: {result}")
print(f"소요 시간: {translation_time:.2f}")
return result, translation_time
# 테스트 케이스들
print("\n" + "="*50)
# 1. 영어 → 한국어
en_text = "Artificial intelligence is transforming the way we work and live."
translate_test(en_text, "eng_Latn", "kor_Hang", "영어 → 한국어")
# 2. 일본어 → 한국어
ja_text = "人工知能は私たちの働き方と生活を変革しています。"
translate_test(ja_text, "jpn_Jpan", "kor_Hang", "일본어 → 한국어")
# 3. 기술 문서 스타일
tech_text = "Machine learning algorithms require large datasets for training."
translate_test(tech_text, "eng_Latn", "kor_Hang", "기술 문서")
print("\n✅ 모든 테스트 완료!")
return True
except Exception as e:
print(f"❌ 테스트 실패: {e}")
return False
def check_model_size():
"""다운로드된 모델 크기 확인"""
model_dir = Path("models")
if model_dir.exists():
import subprocess
try:
result = subprocess.run(
["du", "-sh", str(model_dir)],
capture_output=True,
text=True
)
if result.returncode == 0:
size = result.stdout.strip().split()[0]
print(f"📊 다운로드된 모델 크기: {size}")
except:
print("📊 모델 크기 확인 불가")
if __name__ == "__main__":
print("🚀 NLLB-200-3.3B 모델 설치")
print("="*50)
# 기존 모델 확인
model_dir = Path("models/nllb-200-3.3B")
if model_dir.exists() and any(model_dir.iterdir()):
print("✅ 기존 모델 발견, 로딩 시도...")
try:
tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
model = AutoModelForSeq2SeqLM.from_pretrained(
str(model_dir),
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
print("✅ 기존 모델 로딩 완료")
except:
print("⚠️ 기존 모델 손상, 재다운로드 필요")
model, tokenizer = download_nllb_model()
else:
model, tokenizer = download_nllb_model()
if model is not None and tokenizer is not None:
print("\n🧪 모델 테스트 시작...")
if test_nllb_model(model, tokenizer):
check_model_size()
print("\n🎉 NLLB 모델 설치 및 테스트 완료!")
print("📝 다음 단계: KoBART 요약 모델 설치")
else:
print("\n❌ 모델 테스트 실패")
else:
print("\n❌ 모델 다운로드 실패")
print("네트워크 연결을 확인하고 다시 시도해주세요.")

View File

@@ -0,0 +1,462 @@
#!/usr/bin/env python3
"""
Mac Mini (192.168.1.122) FastAPI + DS1525+ (192.168.1.227) 연동
최종 운영 버전
"""
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
import asyncio
import json
import uuid
from pathlib import Path
from typing import Dict, List, Optional
import time
import shutil
from dataclasses import dataclass, asdict
import aiofiles
from datetime import datetime
import subprocess
# 실제 네트워크 설정
MAC_MINI_IP = "192.168.1.227"
NAS_IP = "192.168.1.122"
# NAS 마운트 경로 (Finder에서 연결 시 생성되는 경로)
NAS_MOUNT_POINT = Path("/Volumes/DS1525+")
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
# 세부 경로들
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
# 로컬 작업 디렉토리
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
app = FastAPI(
title="AI 번역 시스템",
description=f"Mac Mini ({MAC_MINI_IP}) + DS1525+ ({NAS_IP}) 연동",
version="1.0.0"
)
# 정적 파일 및 템플릿
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
# 작업 상태 관리
processing_jobs: Dict[str, Dict] = {}
def check_nas_connection():
"""NAS 연결 상태 실시간 확인"""
try:
# 1. ping 테스트
ping_result = subprocess.run(
["ping", "-c", "1", "-W", "3000", NAS_IP],
capture_output=True,
timeout=5
)
if ping_result.returncode != 0:
return {"status": "offline", "error": f"NAS {NAS_IP} ping 실패"}
# 2. 마운트 상태 확인
if not NAS_MOUNT_POINT.exists():
return {"status": "not_mounted", "error": "마운트 포인트 없음"}
# 3. 실제 마운트 확인
mount_result = subprocess.run(
["mount"],
capture_output=True,
text=True
)
if str(NAS_MOUNT_POINT) not in mount_result.stdout:
return {"status": "not_mounted", "error": "NAS가 마운트되지 않음"}
# 4. Document-upload 폴더 확인
if not DOCUMENT_UPLOAD_BASE.exists():
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
except subprocess.TimeoutExpired:
return {"status": "timeout", "error": "연결 타임아웃"}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.on_event("startup")
async def startup_event():
"""서버 시작시 전체 시스템 상태 확인"""
print(f"🚀 Mac Mini AI 번역 서버 시작")
print(f"📍 Mac Mini IP: {MAC_MINI_IP}")
print(f"📍 NAS IP: {NAS_IP}")
print("-" * 50)
# NAS 연결 상태 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
print(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
# 필요한 폴더 구조 자동 생성
try:
current_month = datetime.now().strftime("%Y-%m")
folders_to_create = [
NAS_ORIGINALS_PATH / current_month / "pdfs",
NAS_ORIGINALS_PATH / current_month / "docs",
NAS_ORIGINALS_PATH / current_month / "txts",
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
NAS_TRANSLATED_PATH / current_month / "korean-only",
NAS_STATIC_HOSTING_PATH / "docs",
NAS_STATIC_HOSTING_PATH / "assets",
NAS_METADATA_PATH / "processing-logs"
]
for folder in folders_to_create:
folder.mkdir(parents=True, exist_ok=True)
print(f"✅ 폴더 구조 확인/생성 완료")
except Exception as e:
print(f"⚠️ 폴더 생성 실패: {e}")
else:
print(f"❌ NAS 연결 실패: {nas_status['error']}")
print("해결 방법:")
print("1. NAS 전원 및 네트워크 상태 확인")
print("2. Finder에서 DS1525+ 수동 연결:")
print(f" - 이동 → 서버에 연결 → smb://{NAS_IP}")
print("3. 연결 후 서버 재시작")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""메인 페이지"""
nas_status = check_nas_connection()
return templates.TemplateResponse("index.html", {
"request": request,
"nas_status": nas_status
})
@app.get("/system-status")
async def system_status():
"""전체 시스템 상태 확인"""
# NAS 상태
nas_status = check_nas_connection()
# Mac Mini 상태
mac_status = {
"ip": MAC_MINI_IP,
"hostname": subprocess.getoutput("hostname"),
"uptime": subprocess.getoutput("uptime"),
"disk_usage": {}
}
# 디스크 사용량 확인
try:
disk_result = subprocess.run(
["df", "-h", str(Path.home())],
capture_output=True,
text=True
)
if disk_result.returncode == 0:
lines = disk_result.stdout.strip().split('\n')
if len(lines) > 1:
parts = lines[1].split()
mac_status["disk_usage"] = {
"total": parts[1],
"used": parts[2],
"available": parts[3],
"percentage": parts[4]
}
except:
pass
# AI 모델 상태 (간단 체크)
ai_status = {
"models_available": False,
"memory_usage": "Unknown"
}
try:
# NLLB 모델 폴더 확인
model_path = LOCAL_WORK_PATH / "models"
if model_path.exists():
ai_status["models_available"] = True
except:
pass
# 작업 통계
job_stats = {
"total_jobs": len(processing_jobs),
"completed": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
"processing": len([j for j in processing_jobs.values() if j["status"] == "processing"]),
"failed": len([j for j in processing_jobs.values() if j["status"] == "error"])
}
return {
"timestamp": datetime.now().isoformat(),
"nas": nas_status,
"mac_mini": mac_status,
"ai_models": ai_status,
"jobs": job_stats
}
@app.post("/upload")
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
"""파일 업로드 (NAS 연결 상태 확인 포함)"""
# NAS 연결 상태 먼저 확인
nas_status = check_nas_connection()
if nas_status["status"] != "connected":
raise HTTPException(
status_code=503,
detail=f"NAS 연결 실패: {nas_status['error']}. Finder에서 DS1525+를 연결해주세요."
)
# 파일 검증
if not file.filename:
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
file_ext = Path(file.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"지원되지 않는 파일 형식입니다. 지원 형식: {', '.join(allowed_extensions)}"
)
# 파일 크기 확인 (100MB 제한)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
# 고유 작업 ID 생성
job_id = str(uuid.uuid4())
# NAS에 체계적으로 저장
current_month = datetime.now().strftime("%Y-%m")
file_type_folder = {
'.pdf': 'pdfs',
'.doc': 'docs',
'.docx': 'docs',
'.txt': 'txts'
}.get(file_ext, 'others')
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
nas_original_dir.mkdir(parents=True, exist_ok=True)
# 안전한 파일명 생성
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
nas_file_path = nas_original_dir / safe_filename
# 파일 저장
try:
async with aiofiles.open(nas_file_path, 'wb') as f:
await f.write(content)
print(f"📁 파일 저장 완료: {nas_file_path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
# 작업 상태 초기화
job = {
"id": job_id,
"filename": file.filename,
"file_type": file_ext,
"status": "uploaded",
"progress": 0,
"message": f"파일 업로드 완료, NAS에 저장됨 ({nas_file_path.name})",
"nas_original_path": str(nas_file_path),
"created_at": time.time()
}
processing_jobs[job_id] = job
# 백그라운드에서 처리 시작
background_tasks.add_task(process_document_with_nas_final, job_id, nas_file_path)
return {
"job_id": job_id,
"message": "파일 업로드 완료, AI 처리를 시작합니다.",
"nas_path": str(nas_file_path)
}
async def process_document_with_nas_final(job_id: str, nas_file_path: Path):
"""최종 문서 처리 파이프라인"""
try:
processing_jobs[job_id].update({
"status": "processing",
"progress": 10,
"message": "AI 모델 로딩 및 텍스트 추출 중..."
})
# 1. 통합 번역 시스템 로드
import sys
sys.path.append(str(LOCAL_WORK_PATH / "src"))
from integrated_translation_system import IntegratedTranslationSystem
system = IntegratedTranslationSystem()
if not system.load_models():
raise Exception("AI 모델 로드 실패")
processing_jobs[job_id].update({
"progress": 30,
"message": "문서 분석 및 언어 감지 중..."
})
# 2. 문서 처리
result = system.process_document(str(nas_file_path))
detected_lang = result.metadata["detected_language"]
processing_jobs[job_id].update({
"progress": 60,
"message": f"언어 감지: {detected_lang}, 다국어 HTML 생성 중..."
})
# 3. HTML 생성
from html_generator import MultilingualHTMLGenerator
generator = MultilingualHTMLGenerator()
# 언어별 콘텐츠 준비
contents = {}
translation_folder = ""
if detected_lang == "korean":
contents["korean"] = result.original_text
translation_folder = "korean-only"
elif detected_lang == "english":
contents["english"] = result.original_text
contents["korean"] = result.translated_text
translation_folder = "english-to-korean"
elif detected_lang == "japanese":
contents["japanese"] = result.original_text
contents["korean"] = result.translated_text
translation_folder = "japanese-to-korean"
else:
contents[detected_lang] = result.original_text
contents["korean"] = result.translated_text
translation_folder = "unknown-language"
# NAS 저장 경로 결정
current_month = datetime.now().strftime("%Y-%m")
nas_translated_dir = NAS_TRANSLATED_PATH / current_month / translation_folder
nas_translated_dir.mkdir(parents=True, exist_ok=True)
# 원본 파일명에서 HTML 파일명 생성
base_name = Path(nas_file_path.stem).stem
# 타임스탬프와 job_id 제거
if '_' in base_name:
parts = base_name.split('_')[2:] # 처음 2개 부분 제거
if parts:
base_name = '_'.join(parts)
html_filename = f"{base_name}.html"
nas_html_path = nas_translated_dir / html_filename
# 중복 파일명 처리
counter = 1
while nas_html_path.exists():
html_filename = f"{base_name}_{counter}.html"
nas_html_path = nas_translated_dir / html_filename
counter += 1
# HTML 생성
generator.generate_multilingual_html(
title=base_name,
contents=contents,
summary=result.summary,
metadata={
**result.metadata,
"nas_original_path": str(nas_file_path),
"translation_type": translation_folder,
"nas_ip": NAS_IP,
"mac_mini_ip": MAC_MINI_IP
},
output_path=str(nas_html_path)
)
processing_jobs[job_id].update({
"progress": 85,
"message": "정적 호스팅 준비 및 인덱스 업데이트 중..."
})
# 4. 정적 호스팅 폴더에 복사
static_hosting_file = NAS_STATIC_HOSTING_PATH / "docs" / html_filename
shutil.copy2(nas_html_path, static_hosting_file)
# 5. 메타데이터 저장
await save_processing_metadata(job_id, nas_file_path, nas_html_path, result.metadata)
processing_jobs[job_id].update({
"progress": 100,
"status": "completed",
"message": f"처리 완료! ({translation_folder})",
"nas_translated_path": str(nas_html_path),
"static_hosting_path": str(static_hosting_file),
"detected_language": detected_lang,
"translation_type": translation_folder
})
print(f"✅ 작업 완료: {job_id} - {html_filename}")
except Exception as e:
processing_jobs[job_id].update({
"status": "error",
"progress": 0,
"message": f"처리 실패: {str(e)}"
})
print(f"❌ 작업 실패: {job_id} - {str(e)}")
async def save_processing_metadata(job_id: str, original_path: Path, html_path: Path, metadata: Dict):
"""처리 메타데이터 NAS에 저장"""
log_data = {
"job_id": job_id,
"timestamp": datetime.now().isoformat(),
"original_file": str(original_path),
"html_file": str(html_path),
"metadata": metadata,
"nas_ip": NAS_IP,
"mac_mini_ip": MAC_MINI_IP
}
# 월별 로그 파일에 추가
current_month = datetime.now().strftime("%Y-%m")
log_file = NAS_METADATA_PATH / "processing-logs" / f"{current_month}.jsonl"
try:
async with aiofiles.open(log_file, 'a', encoding='utf-8') as f:
await f.write(json.dumps(log_data, ensure_ascii=False) + '\n')
except Exception as e:
print(f"메타데이터 저장 실패: {e}")
@app.get("/status/{job_id}")
async def get_job_status(job_id: str):
"""작업 상태 조회"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
return processing_jobs[job_id]
if __name__ == "__main__":
import uvicorn
print(f"🚀 Mac Mini AI 번역 서버")
print(f"📡 서버 주소: http://{MAC_MINI_IP}:8080")
print(f"📁 NAS 주소: {NAS_IP}")
uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@@ -0,0 +1,575 @@
#!/usr/bin/env python3
"""
/Volumes/Media 마운트 기반 FastAPI
최종 운영 버전
"""
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
import asyncio
import json
import uuid
from pathlib import Path
from typing import Dict, List, Optional
import time
import shutil
from dataclasses import dataclass, asdict
import aiofiles
from datetime import datetime
import subprocess
# 실제 네트워크 설정
MAC_MINI_IP = "192.168.1.122"
NAS_IP = "192.168.1.227"
# 기존 연결된 Media 마운트 사용
NAS_MOUNT_POINT = Path("/Volumes/Media")
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
# 세부 경로들
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
# 로컬 작업 디렉토리
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
app = FastAPI(
title="AI 번역 시스템",
description=f"Mac Mini ({MAC_MINI_IP}) + Media Mount 연동",
version="1.0.0"
)
# 정적 파일 및 템플릿 (있는 경우에만)
if (LOCAL_WORK_PATH / "static").exists():
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
if (LOCAL_WORK_PATH / "templates").exists():
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
else:
templates = None
# 작업 상태 관리
processing_jobs: Dict[str, Dict] = {}
def check_nas_connection():
"""NAS 연결 상태 확인"""
try:
# 1. Media 마운트 확인
if not NAS_MOUNT_POINT.exists():
return {"status": "not_mounted", "error": "Media 마운트 포인트 없음"}
# 2. 쓰기 권한 확인
try:
test_file = NAS_MOUNT_POINT / ".test_write"
test_file.touch()
test_file.unlink()
except:
return {"status": "read_only", "error": "Media 마운트 읽기 전용"}
# 3. Document-upload 폴더 확인
if not DOCUMENT_UPLOAD_BASE.exists():
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
except Exception as e:
return {"status": "error", "error": str(e)}
def ensure_nas_directories():
"""NAS 디렉토리 구조 생성"""
try:
# Document-upload 기본 구조
DOCUMENT_UPLOAD_BASE.mkdir(exist_ok=True)
current_month = datetime.now().strftime("%Y-%m")
folders_to_create = [
NAS_ORIGINALS_PATH / current_month / "pdfs",
NAS_ORIGINALS_PATH / current_month / "docs",
NAS_ORIGINALS_PATH / current_month / "txts",
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
NAS_TRANSLATED_PATH / current_month / "korean-only",
NAS_STATIC_HOSTING_PATH / "docs",
NAS_STATIC_HOSTING_PATH / "assets",
NAS_STATIC_HOSTING_PATH / "index",
NAS_METADATA_PATH / "processing-logs"
]
for folder in folders_to_create:
folder.mkdir(parents=True, exist_ok=True)
# README 파일 생성
readme_content = f"""# AI 번역 시스템 문서 저장소
자동 생성 시간: {datetime.now().isoformat()}
Mac Mini IP: {MAC_MINI_IP}
NAS IP: {NAS_IP}
## 폴더 구조
- originals/: 업로드된 원본 파일들
- translated/: 번역된 HTML 파일들
- static-hosting/: 웹 호스팅용 파일들
- metadata/: 처리 로그 및 메타데이터
## 자동 관리
이 폴더는 AI 번역 시스템에서 자동으로 관리됩니다.
수동으로 파일을 수정하지 마세요.
"""
readme_path = DOCUMENT_UPLOAD_BASE / "README.md"
with open(readme_path, 'w', encoding='utf-8') as f:
f.write(readme_content)
return True
except Exception as e:
print(f"❌ 디렉토리 생성 실패: {e}")
return False
@app.on_event("startup")
async def startup_event():
"""서버 시작시 전체 시스템 상태 확인"""
print(f"🚀 Mac Mini AI 번역 서버 시작")
print(f"📍 Mac Mini IP: {MAC_MINI_IP}")
print(f"📍 NAS Mount: {NAS_MOUNT_POINT}")
print("-" * 50)
# NAS 연결 상태 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
print(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
if ensure_nas_directories():
print(f"✅ 폴더 구조 확인/생성 완료")
print(f"📁 문서 저장 경로: {DOCUMENT_UPLOAD_BASE}")
else:
print(f"⚠️ 폴더 생성 실패")
else:
print(f"❌ NAS 연결 실패: {nas_status['error']}")
@app.get("/")
async def index(request: Request = None):
"""메인 페이지"""
nas_status = check_nas_connection()
if templates:
return templates.TemplateResponse("index.html", {
"request": request,
"nas_status": nas_status
})
else:
# 템플릿이 없으면 간단한 HTML 반환
html_content = f"""
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>AI 번역 시스템</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 40px; }}
.container {{ max-width: 800px; margin: 0 auto; }}
.status {{ padding: 20px; border-radius: 8px; margin: 20px 0; }}
.status-good {{ background: #d1fae5; color: #065f46; }}
.status-bad {{ background: #fee2e2; color: #dc2626; }}
.upload-area {{ border: 2px dashed #ccc; padding: 40px; text-align: center; border-radius: 8px; }}
input[type="file"] {{ margin: 20px 0; }}
button {{ background: #2563eb; color: white; padding: 12px 24px; border: none; border-radius: 6px; cursor: pointer; }}
</style>
</head>
<body>
<div class="container">
<h1>🤖 AI 번역 시스템</h1>
<div class="status {'status-good' if nas_status['status'] == 'connected' else 'status-bad'}">
{'✅ NAS 연결됨: ' + nas_status.get('mount_point', '') if nas_status['status'] == 'connected' else '❌ NAS 연결 실패: ' + nas_status.get('error', '')}
</div>
<div class="upload-area">
<h3>파일 업로드</h3>
<form id="uploadForm" enctype="multipart/form-data">
<input type="file" id="fileInput" accept=".pdf,.txt,.docx,.doc" />
<br>
<button type="submit" {'disabled' if nas_status['status'] != 'connected' else ''}>업로드</button>
</form>
<div id="status"></div>
</div>
<div>
<h3>API 엔드포인트</h3>
<ul>
<li><a href="/system-status">시스템 상태</a></li>
<li><a href="/nas-info">NAS 정보</a></li>
<li><a href="/docs">API 문서</a></li>
</ul>
</div>
</div>
<script>
document.getElementById('uploadForm').onsubmit = async function(e) {{
e.preventDefault();
const fileInput = document.getElementById('fileInput');
const statusDiv = document.getElementById('status');
if (!fileInput.files[0]) {{
statusDiv.innerHTML = '파일을 선택해주세요.';
return;
}}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
statusDiv.innerHTML = '업로드 중...';
try {{
const response = await fetch('/upload', {{
method: 'POST',
body: formData
}});
const result = await response.json();
if (response.ok) {{
statusDiv.innerHTML = `✅ 업로드 성공! Job ID: ${{result.job_id}}`;
// 진행 상황 모니터링
monitorJob(result.job_id);
}} else {{
statusDiv.innerHTML = `❌ 업로드 실패: ${{result.detail}}`;
}}
}} catch (error) {{
statusDiv.innerHTML = `❌ 오류: ${{error.message}}`;
}}
}};
async function monitorJob(jobId) {{
const statusDiv = document.getElementById('status');
while (true) {{
try {{
const response = await fetch(`/status/${{jobId}}`);
const status = await response.json();
statusDiv.innerHTML = `진행률: ${{status.progress}}% - ${{status.message}}`;
if (status.status === 'completed') {{
statusDiv.innerHTML += `<br><a href="/download/${{jobId}}">📥 다운로드</a>`;
break;
}} else if (status.status === 'error') {{
statusDiv.innerHTML = `❌ 처리 실패: ${{status.message}}`;
break;
}}
await new Promise(resolve => setTimeout(resolve, 2000));
}} catch (error) {{
console.error('상태 확인 오류:', error);
await new Promise(resolve => setTimeout(resolve, 5000));
}}
}}
}}
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@app.get("/system-status")
async def system_status():
"""전체 시스템 상태"""
nas_status = check_nas_connection()
# 파일 통계
file_stats = {"originals": 0, "translated": 0, "total_size": 0}
try:
if NAS_ORIGINALS_PATH.exists():
original_files = list(NAS_ORIGINALS_PATH.rglob("*.*"))
file_stats["originals"] = len([f for f in original_files if f.is_file()])
if NAS_TRANSLATED_PATH.exists():
html_files = list(NAS_TRANSLATED_PATH.rglob("*.html"))
file_stats["translated"] = len(html_files)
file_stats["total_size"] = sum(f.stat().st_size for f in html_files if f.exists())
except:
pass
return {
"timestamp": datetime.now().isoformat(),
"mac_mini_ip": MAC_MINI_IP,
"nas_mount": str(NAS_MOUNT_POINT),
"nas_status": nas_status,
"file_stats": file_stats,
"active_jobs": len(processing_jobs),
"document_upload_path": str(DOCUMENT_UPLOAD_BASE)
}
@app.post("/upload")
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
"""파일 업로드"""
# NAS 연결 상태 먼저 확인
nas_status = check_nas_connection()
if nas_status["status"] != "connected":
raise HTTPException(
status_code=503,
detail=f"NAS 연결 실패: {nas_status['error']}"
)
# 파일 검증
if not file.filename:
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
file_ext = Path(file.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"지원되지 않는 파일 형식입니다. 지원: {', '.join(allowed_extensions)}"
)
# 파일 크기 확인 (100MB 제한)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
# 고유 작업 ID 생성
job_id = str(uuid.uuid4())
# NAS에 저장
current_month = datetime.now().strftime("%Y-%m")
file_type_folder = {
'.pdf': 'pdfs',
'.doc': 'docs',
'.docx': 'docs',
'.txt': 'txts'
}.get(file_ext, 'others')
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
nas_original_dir.mkdir(parents=True, exist_ok=True)
# 안전한 파일명 생성
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
nas_file_path = nas_original_dir / safe_filename
# 파일 저장
try:
async with aiofiles.open(nas_file_path, 'wb') as f:
await f.write(content)
print(f"📁 파일 저장: {nas_file_path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
# 작업 상태 초기화
job = {
"id": job_id,
"filename": file.filename,
"file_type": file_ext,
"status": "uploaded",
"progress": 0,
"message": f"파일 저장 완료: {safe_filename}",
"nas_original_path": str(nas_file_path),
"created_at": time.time()
}
processing_jobs[job_id] = job
# 백그라운드 처리 시작
background_tasks.add_task(process_document_simple, job_id, nas_file_path)
return {
"job_id": job_id,
"message": "파일 업로드 완료, 처리를 시작합니다.",
"nas_path": str(nas_file_path)
}
async def process_document_simple(job_id: str, nas_file_path: Path):
"""간단한 문서 처리 (AI 모델 연동 전 테스트용)"""
try:
processing_jobs[job_id].update({
"status": "processing",
"progress": 30,
"message": "텍스트 추출 중..."
})
await asyncio.sleep(2) # 시뮬레이션
processing_jobs[job_id].update({
"progress": 60,
"message": "언어 감지 및 번역 중..."
})
await asyncio.sleep(3) # 시뮬레이션
# 간단한 HTML 생성 (테스트용)
current_month = datetime.now().strftime("%Y-%m")
nas_translated_dir = NAS_TRANSLATED_PATH / current_month / "korean-only"
nas_translated_dir.mkdir(parents=True, exist_ok=True)
base_name = Path(nas_file_path.stem).stem
if '_' in base_name:
parts = base_name.split('_')[2:] # 타임스탬프와 job_id 제거
if parts:
base_name = '_'.join(parts)
html_filename = f"{base_name}.html"
nas_html_path = nas_translated_dir / html_filename
# 테스트용 HTML 생성
html_content = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{base_name}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 40px; line-height: 1.6; }}
.header {{ background: #f8fafc; padding: 20px; border-radius: 8px; margin-bottom: 30px; }}
.content {{ max-width: 800px; margin: 0 auto; }}
</style>
</head>
<body>
<div class="content">
<div class="header">
<h1>📄 {base_name}</h1>
<p>처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
<p>원본 파일: {nas_file_path.name}</p>
<p>Job ID: {job_id}</p>
</div>
<div>
<h2>테스트 문서</h2>
<p>이것은 AI 번역 시스템의 테스트 출력입니다.</p>
<p>실제 AI 모델이 연동되면 이 부분에 번역된 내용이 표시됩니다.</p>
<h3>시스템 정보</h3>
<ul>
<li>Mac Mini IP: {MAC_MINI_IP}</li>
<li>NAS Mount: {NAS_MOUNT_POINT}</li>
<li>저장 경로: {nas_html_path}</li>
</ul>
</div>
</div>
</body>
</html>"""
# HTML 파일 저장
async with aiofiles.open(nas_html_path, 'w', encoding='utf-8') as f:
await f.write(html_content)
processing_jobs[job_id].update({
"progress": 100,
"status": "completed",
"message": "처리 완료!",
"nas_translated_path": str(nas_html_path)
})
print(f"✅ 테스트 처리 완료: {job_id} - {html_filename}")
except Exception as e:
processing_jobs[job_id].update({
"status": "error",
"progress": 0,
"message": f"처리 실패: {str(e)}"
})
print(f"❌ 처리 실패: {job_id} - {str(e)}")
@app.get("/status/{job_id}")
async def get_job_status(job_id: str):
"""작업 상태 조회"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
return processing_jobs[job_id]
@app.get("/download/{job_id}")
async def download_result(job_id: str):
"""결과 파일 다운로드"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
job = processing_jobs[job_id]
if job["status"] != "completed" or not job.get("nas_translated_path"):
raise HTTPException(status_code=400, detail="처리가 완료되지 않았습니다.")
result_path = Path(job["nas_translated_path"])
if not result_path.exists():
raise HTTPException(status_code=404, detail="결과 파일을 찾을 수 없습니다.")
return FileResponse(
path=result_path,
filename=f"{Path(job['filename']).stem}.html",
media_type="text/html"
)
@app.get("/nas-info")
async def nas_info():
"""NAS 정보 및 통계"""
nas_status = check_nas_connection()
info = {
"nas_status": nas_status,
"mount_point": str(NAS_MOUNT_POINT),
"document_upload_base": str(DOCUMENT_UPLOAD_BASE),
"folders": {},
"statistics": {
"total_jobs": len(processing_jobs),
"completed_jobs": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
"failed_jobs": len([j for j in processing_jobs.values() if j["status"] == "error"])
}
}
# 폴더 정보 수집
try:
for folder_name, folder_path in [
("originals", NAS_ORIGINALS_PATH),
("translated", NAS_TRANSLATED_PATH),
("static-hosting", NAS_STATIC_HOSTING_PATH),
("metadata", NAS_METADATA_PATH)
]:
if folder_path.exists():
files = list(folder_path.rglob("*.*"))
file_count = len([f for f in files if f.is_file()])
total_size = sum(f.stat().st_size for f in files if f.is_file())
info["folders"][folder_name] = {
"exists": True,
"file_count": file_count,
"total_size_mb": round(total_size / (1024 * 1024), 2)
}
else:
info["folders"][folder_name] = {"exists": False}
except Exception as e:
info["error"] = str(e)
return info
if __name__ == "__main__":
import uvicorn
print(f"🚀 Mac Mini AI 번역 서버")
print(f"📡 서버 주소: http://{MAC_MINI_IP}:8080")
print(f"📁 NAS Mount: {NAS_MOUNT_POINT}")
# 시작 전 연결 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
print(f"✅ NAS 연결 확인됨")
ensure_nas_directories()
else:
print(f"❌ NAS 연결 문제: {nas_status['error']}")
uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@@ -0,0 +1,848 @@
#!/usr/bin/env python3
"""
포트 20080으로 실행하는 AI 번역 시스템
Mac Mini (192.168.1.122:20080) + Media Mount 연동
"""
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
import asyncio
import json
import uuid
from pathlib import Path
from typing import Dict, List, Optional
import time
import shutil
from dataclasses import dataclass, asdict
import aiofiles
from datetime import datetime
import subprocess
# 실제 네트워크 설정 (포트 20080)
MAC_MINI_IP = "192.168.1.122"
MAC_MINI_PORT = 20080
NAS_IP = "192.168.1.227"
# 기존 연결된 Media 마운트 사용
NAS_MOUNT_POINT = Path("/Volumes/Media")
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
# 세부 경로들
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
# 로컬 작업 디렉토리
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
app = FastAPI(
title="AI 번역 시스템",
description=f"Mac Mini ({MAC_MINI_IP}:{MAC_MINI_PORT}) + Media Mount 연동",
version="1.0.0"
)
# 정적 파일 및 템플릿 (있는 경우에만)
if (LOCAL_WORK_PATH / "static").exists():
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
if (LOCAL_WORK_PATH / "templates").exists():
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
else:
templates = None
# 작업 상태 관리
processing_jobs: Dict[str, Dict] = {}
def check_nas_connection():
"""NAS 연결 상태 확인"""
try:
# 1. Media 마운트 확인
if not NAS_MOUNT_POINT.exists():
return {"status": "not_mounted", "error": "Media 마운트 포인트 없음"}
# 2. 쓰기 권한 확인
try:
test_file = NAS_MOUNT_POINT / ".test_write"
test_file.touch()
test_file.unlink()
except:
return {"status": "read_only", "error": "Media 마운트 읽기 전용"}
# 3. Document-upload 폴더 확인
if not DOCUMENT_UPLOAD_BASE.exists():
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
except Exception as e:
return {"status": "error", "error": str(e)}
def ensure_nas_directories():
"""NAS 디렉토리 구조 생성"""
try:
# Document-upload 기본 구조
DOCUMENT_UPLOAD_BASE.mkdir(exist_ok=True)
current_month = datetime.now().strftime("%Y-%m")
folders_to_create = [
NAS_ORIGINALS_PATH / current_month / "pdfs",
NAS_ORIGINALS_PATH / current_month / "docs",
NAS_ORIGINALS_PATH / current_month / "txts",
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
NAS_TRANSLATED_PATH / current_month / "korean-only",
NAS_STATIC_HOSTING_PATH / "docs",
NAS_STATIC_HOSTING_PATH / "assets",
NAS_STATIC_HOSTING_PATH / "index",
NAS_METADATA_PATH / "processing-logs"
]
for folder in folders_to_create:
folder.mkdir(parents=True, exist_ok=True)
# README 파일 생성
readme_content = f"""# AI 번역 시스템 문서 저장소
자동 생성 시간: {datetime.now().isoformat()}
Mac Mini: {MAC_MINI_IP}:{MAC_MINI_PORT}
NAS IP: {NAS_IP}
## 접속 정보
- 웹 인터페이스: http://{MAC_MINI_IP}:{MAC_MINI_PORT}
- 시스템 상태: http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status
- NAS 정보: http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info
- API 문서: http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs
## 폴더 구조
- originals/: 업로드된 원본 파일들
- translated/: 번역된 HTML 파일들
- static-hosting/: 웹 호스팅용 파일들
- metadata/: 처리 로그 및 메타데이터
## VPN 접속
내부 네트워크에서만 접근 가능합니다.
외부에서 접속시 VPN 연결이 필요합니다.
"""
readme_path = DOCUMENT_UPLOAD_BASE / "README.md"
with open(readme_path, 'w', encoding='utf-8') as f:
f.write(readme_content)
return True
except Exception as e:
print(f"❌ 디렉토리 생성 실패: {e}")
return False
@app.on_event("startup")
async def startup_event():
"""서버 시작시 전체 시스템 상태 확인"""
print(f"🚀 Mac Mini AI 번역 서버 시작")
print(f"📍 접속 주소: http://{MAC_MINI_IP}:{MAC_MINI_PORT}")
print(f"📍 NAS Mount: {NAS_MOUNT_POINT}")
print("-" * 60)
# NAS 연결 상태 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
print(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
if ensure_nas_directories():
print(f"✅ 폴더 구조 확인/생성 완료")
print(f"📁 문서 저장 경로: {DOCUMENT_UPLOAD_BASE}")
else:
print(f"⚠️ 폴더 생성 실패")
else:
print(f"❌ NAS 연결 실패: {nas_status['error']}")
@app.get("/")
async def index(request: Request = None):
"""메인 페이지"""
nas_status = check_nas_connection()
if templates:
return templates.TemplateResponse("index.html", {
"request": request,
"nas_status": nas_status
})
else:
# 포트 20080 반영된 기본 HTML
html_content = f"""
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🤖 AI 번역 시스템</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}}
.container {{
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: white;
padding: 40px;
text-align: center;
}}
.header h1 {{ font-size: 2.5rem; margin-bottom: 10px; }}
.header p {{ font-size: 1.1rem; opacity: 0.9; }}
.server-info {{
background: #f0f9ff;
padding: 20px;
border-bottom: 1px solid #bae6fd;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}}
.info-item {{ display: flex; align-items: center; gap: 10px; color: #0369a1; }}
.status {{ padding: 20px; border-radius: 8px; margin: 20px; }}
.status-good {{ background: #d1fae5; color: #065f46; }}
.status-bad {{ background: #fee2e2; color: #dc2626; }}
.main-content {{ padding: 40px; }}
.upload-area {{
border: 3px dashed #e5e7eb;
padding: 60px 40px;
text-align: center;
border-radius: 16px;
transition: all 0.3s ease;
margin: 20px 0;
}}
.upload-area:hover {{ border-color: #2563eb; background: #f8fafc; }}
.upload-area h3 {{ font-size: 1.5rem; margin-bottom: 20px; color: #374151; }}
input[type="file"] {{ margin: 20px 0; padding: 10px; }}
button {{
background: #2563eb;
color: white;
padding: 15px 30px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background 0.3s ease;
}}
button:hover {{ background: #1d4ed8; }}
button:disabled {{ background: #9ca3af; cursor: not-allowed; }}
.progress {{ margin: 20px 0; padding: 20px; background: #f8fafc; border-radius: 12px; display: none; }}
.progress-bar {{ width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }}
.progress-fill {{ height: 100%; background: #2563eb; width: 0%; transition: width 0.3s ease; }}
.links {{ margin: 30px 0; }}
.links h3 {{ margin-bottom: 15px; color: #374151; }}
.links ul {{ list-style: none; }}
.links li {{ margin: 8px 0; }}
.links a {{
color: #2563eb;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
transition: background 0.3s ease;
}}
.links a:hover {{ background: #eff6ff; }}
.result {{
margin: 20px 0;
padding: 20px;
background: #f0f9ff;
border: 1px solid #0ea5e9;
border-radius: 12px;
display: none;
}}
.result h4 {{ color: #0369a1; margin-bottom: 15px; }}
.result-actions {{ display: flex; gap: 15px; }}
.btn {{
padding: 10px 20px;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}}
.btn-primary {{ background: #2563eb; color: white; }}
.btn-secondary {{ background: #f8fafc; color: #374151; border: 1px solid #e5e7eb; }}
@media (max-width: 768px) {{
.server-info {{ grid-template-columns: 1fr; }}
.result-actions {{ flex-direction: column; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 AI 번역 시스템</h1>
<p>PDF/문서를 다국어 HTML로 자동 변환</p>
</div>
<div class="server-info">
<div class="info-item">
<span>🖥️</span>
<span>Mac Mini: {MAC_MINI_IP}:{MAC_MINI_PORT}</span>
</div>
<div class="info-item">
<span>💾</span>
<span>NAS: {NAS_IP} (DS1525+)</span>
</div>
</div>
<div class="status {'status-good' if nas_status['status'] == 'connected' else 'status-bad'}">
{'✅ NAS 연결됨: ' + nas_status.get('mount_point', '') if nas_status['status'] == 'connected' else '❌ NAS 연결 실패: ' + nas_status.get('error', '')}
</div>
<div class="main-content">
<div class="upload-area">
<h3>📁 파일 업로드</h3>
<p style="color: #6b7280; margin-bottom: 20px;">PDF, TXT, DOCX 파일 지원 (최대 100MB)</p>
<form id="uploadForm" enctype="multipart/form-data">
<input type="file" id="fileInput" accept=".pdf,.txt,.docx,.doc" style="margin: 20px 0;" />
<br>
<button type="submit" {'disabled' if nas_status['status'] != 'connected' else ''}>
{'📤 파일 업로드' if nas_status['status'] == 'connected' else '❌ NAS 연결 필요'}
</button>
</form>
<div id="progress" class="progress">
<div style="margin-bottom: 10px; font-weight: 500;" id="progressText">처리 중...</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div id="result" class="result">
<h4>🎉 변환 완료!</h4>
<div class="result-actions">
<a href="#" id="downloadBtn" class="btn btn-primary">📥 HTML 다운로드</a>
<a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info" class="btn btn-secondary">📊 NAS 정보</a>
</div>
</div>
</div>
<div class="links">
<h3>🔗 시스템 링크</h3>
<ul>
<li><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status" target="_blank">📊 시스템 상태</a></li>
<li><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info" target="_blank">💾 NAS 정보</a></li>
<li><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs" target="_blank">📖 API 문서</a></li>
<li><a href="/Volumes/Media/Document-upload" onclick="alert('Finder에서 열기: /Volumes/Media/Document-upload'); return false;">📁 NAS 폴더 (로컬)</a></li>
</ul>
</div>
</div>
</div>
<script>
let currentJobId = null;
document.getElementById('uploadForm').onsubmit = async function(e) {{
e.preventDefault();
const fileInput = document.getElementById('fileInput');
const progressDiv = document.getElementById('progress');
const resultDiv = document.getElementById('result');
if (!fileInput.files[0]) {{
alert('파일을 선택해주세요.');
return;
}}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
// UI 업데이트
progressDiv.style.display = 'block';
resultDiv.style.display = 'none';
document.getElementById('progressText').textContent = '업로드 중...';
document.getElementById('progressFill').style.width = '10%';
try {{
const response = await fetch('/upload', {{
method: 'POST',
body: formData
}});
const result = await response.json();
if (response.ok) {{
currentJobId = result.job_id;
document.getElementById('progressText').textContent = '업로드 완료, 처리 시작...';
document.getElementById('progressFill').style.width = '20%';
// 진행 상황 모니터링
monitorJob();
}} else {{
throw new Error(result.detail || '업로드 실패');
}}
}} catch (error) {{
progressDiv.style.display = 'none';
alert('업로드 실패: ' + error.message);
}}
}};
async function monitorJob() {{
if (!currentJobId) return;
try {{
const response = await fetch(`/status/${{currentJobId}}`);
const status = await response.json();
// 진행률 업데이트
document.getElementById('progressText').textContent = status.message;
document.getElementById('progressFill').style.width = status.progress + '%';
if (status.status === 'completed') {{
// 완료 처리
document.getElementById('progress').style.display = 'none';
document.getElementById('result').style.display = 'block';
document.getElementById('downloadBtn').href = `/download/${{currentJobId}}`;
}} else if (status.status === 'error') {{
document.getElementById('progress').style.display = 'none';
alert('처리 실패: ' + status.message);
}} else {{
// 계속 모니터링
setTimeout(monitorJob, 2000);
}}
}} catch (error) {{
console.error('상태 확인 오류:', error);
setTimeout(monitorJob, 5000);
}}
}}
// 페이지 로드시 서버 상태 확인
window.onload = function() {{
console.log('🚀 AI 번역 시스템 로드 완료');
console.log('📍 서버: http://{MAC_MINI_IP}:{MAC_MINI_PORT}');
}};
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@app.get("/system-status")
async def system_status():
"""전체 시스템 상태"""
nas_status = check_nas_connection()
# 파일 통계
file_stats = {"originals": 0, "translated": 0, "total_size": 0}
try:
if NAS_ORIGINALS_PATH.exists():
original_files = list(NAS_ORIGINALS_PATH.rglob("*.*"))
file_stats["originals"] = len([f for f in original_files if f.is_file()])
if NAS_TRANSLATED_PATH.exists():
html_files = list(NAS_TRANSLATED_PATH.rglob("*.html"))
file_stats["translated"] = len(html_files)
file_stats["total_size"] = sum(f.stat().st_size for f in html_files if f.exists())
except:
pass
return {
"timestamp": datetime.now().isoformat(),
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}",
"nas_ip": NAS_IP,
"nas_mount": str(NAS_MOUNT_POINT),
"nas_status": nas_status,
"file_stats": file_stats,
"active_jobs": len(processing_jobs),
"document_upload_path": str(DOCUMENT_UPLOAD_BASE),
"access_urls": {
"main": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}",
"system_status": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status",
"nas_info": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info",
"api_docs": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs"
}
}
@app.post("/upload")
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
"""파일 업로드"""
# NAS 연결 상태 먼저 확인
nas_status = check_nas_connection()
if nas_status["status"] != "connected":
raise HTTPException(
status_code=503,
detail=f"NAS 연결 실패: {nas_status['error']}"
)
# 파일 검증
if not file.filename:
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
file_ext = Path(file.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"지원되지 않는 파일 형식입니다. 지원: {', '.join(allowed_extensions)}"
)
# 파일 크기 확인 (100MB 제한)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
# 고유 작업 ID 생성
job_id = str(uuid.uuid4())
# NAS에 저장
current_month = datetime.now().strftime("%Y-%m")
file_type_folder = {
'.pdf': 'pdfs',
'.doc': 'docs',
'.docx': 'docs',
'.txt': 'txts'
}.get(file_ext, 'others')
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
nas_original_dir.mkdir(parents=True, exist_ok=True)
# 안전한 파일명 생성
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
nas_file_path = nas_original_dir / safe_filename
# 파일 저장
try:
async with aiofiles.open(nas_file_path, 'wb') as f:
await f.write(content)
print(f"📁 파일 저장: {nas_file_path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
# 작업 상태 초기화
job = {
"id": job_id,
"filename": file.filename,
"file_type": file_ext,
"status": "uploaded",
"progress": 20,
"message": f"파일 저장 완료: {safe_filename}",
"nas_original_path": str(nas_file_path),
"created_at": time.time(),
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}"
}
processing_jobs[job_id] = job
# 백그라운드 처리 시작
background_tasks.add_task(process_document_simple, job_id, nas_file_path)
return {
"job_id": job_id,
"message": "파일 업로드 완료, 처리를 시작합니다.",
"nas_path": str(nas_file_path),
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}"
}
async def process_document_simple(job_id: str, nas_file_path: Path):
"""간단한 문서 처리 (AI 모델 연동 전 테스트용)"""
try:
processing_jobs[job_id].update({
"status": "processing",
"progress": 40,
"message": "텍스트 추출 중..."
})
await asyncio.sleep(2) # 시뮬레이션
processing_jobs[job_id].update({
"progress": 70,
"message": "언어 감지 및 번역 중..."
})
await asyncio.sleep(3) # 시뮬레이션
# 간단한 HTML 생성 (테스트용)
current_month = datetime.now().strftime("%Y-%m")
nas_translated_dir = NAS_TRANSLATED_PATH / current_month / "korean-only"
nas_translated_dir.mkdir(parents=True, exist_ok=True)
base_name = Path(nas_file_path.stem).stem
if '_' in base_name:
parts = base_name.split('_')[2:] # 타임스탬프와 job_id 제거
if parts:
base_name = '_'.join(parts)
html_filename = f"{base_name}.html"
nas_html_path = nas_translated_dir / html_filename
# 테스트용 HTML 생성
html_content = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{base_name} - AI 번역 결과</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 40px;
line-height: 1.6;
background: #f8fafc;
}}
.container {{ max-width: 900px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }}
.header {{
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: white;
padding: 40px;
text-align: center;
}}
.header h1 {{ font-size: 2rem; margin-bottom: 10px; }}
.meta {{ background: #f0f9ff; padding: 20px; border-bottom: 1px solid #bae6fd; }}
.meta-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }}
.meta-item {{ display: flex; align-items: center; gap: 8px; color: #0369a1; }}
.content {{ padding: 40px; }}
.section {{ margin-bottom: 30px; }}
.section h2 {{ color: #1f2937; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px; }}
.info-box {{ background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 20px 0; }}
.footer {{ background: #f8fafc; padding: 20px 40px; text-align: center; color: #6b7280; font-size: 0.9rem; }}
.system-info {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; }}
.system-card {{ background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 15px; }}
@media (max-width: 768px) {{
body {{ padding: 20px; }}
.meta-grid, .system-info {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📄 {base_name}</h1>
<p>AI 번역 시스템 처리 결과</p>
</div>
<div class="meta">
<div class="meta-grid">
<div class="meta-item">
<span>⏰</span>
<span>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</span>
</div>
<div class="meta-item">
<span>📁</span>
<span>{nas_file_path.name}</span>
</div>
<div class="meta-item">
<span>🆔</span>
<span>{job_id[:8]}</span>
</div>
<div class="meta-item">
<span>🖥️</span>
<span>{MAC_MINI_IP}:{MAC_MINI_PORT}</span>
</div>
</div>
</div>
<div class="content">
<div class="section">
<h2>📋 문서 정보</h2>
<div class="info-box">
<p><strong>원본 파일:</strong> {nas_file_path.name}</p>
<p><strong>저장 위치:</strong> {nas_html_path}</p>
<p><strong>처리 서버:</strong> Mac Mini ({MAC_MINI_IP}:{MAC_MINI_PORT})</p>
<p><strong>NAS 저장소:</strong> DS1525+ ({NAS_IP})</p>
</div>
</div>
<div class="section">
<h2>🤖 AI 처리 결과</h2>
<div class="info-box">
<h3>테스트 모드</h3>
<p>현재 AI 번역 시스템이 테스트 모드로 실행 중입니다.</p>
<p>실제 AI 모델(NLLB, KoBART)이 연동되면 이 부분에 다음 내용이 표시됩니다:</p>
<ul style="margin-left: 20px;">
<li>자동 언어 감지 결과</li>
<li>한국어 번역 텍스트</li>
<li>문서 요약</li>
<li>다국어 지원 인터페이스</li>
</ul>
</div>
</div>
<div class="section">
<h2>🌐 시스템 구성</h2>
<div class="system-info">
<div class="system-card">
<h4>Mac Mini M4 Pro</h4>
<p>IP: {MAC_MINI_IP}:{MAC_MINI_PORT}</p>
<p>역할: AI 처리 서버</p>
<p>모델: NLLB + KoBART</p>
</div>
<div class="system-card">
<h4>Synology DS1525+</h4>
<p>IP: {NAS_IP}</p>
<p>역할: 파일 저장소</p>
<p>마운트: /Volumes/Media</p>
</div>
<div class="system-card">
<h4>네트워크</h4>
<p>연결: 2.5GbE</p>
<p>접근: VPN 필요</p>
<p>프로토콜: SMB</p>
</div>
</div>
</div>
<div class="section">
<h2>🔗 관련 링크</h2>
<div class="info-box">
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}" target="_blank">🏠 메인 페이지</a></p>
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status" target="_blank">📊 시스템 상태</a></p>
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info" target="_blank">💾 NAS 정보</a></p>
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs" target="_blank">📖 API 문서</a></p>
</div>
</div>
</div>
<div class="footer">
<p>AI 번역 시스템 v1.0.0 | Mac Mini M4 Pro + Synology DS1525+ | 자동 생성 문서</p>
</div>
</div>
</body>
</html>"""
# HTML 파일 저장
async with aiofiles.open(nas_html_path, 'w', encoding='utf-8') as f:
await f.write(html_content)
processing_jobs[job_id].update({
"progress": 100,
"status": "completed",
"message": "처리 완료!",
"nas_translated_path": str(nas_html_path)
})
print(f"✅ 테스트 처리 완료: {job_id} - {html_filename}")
except Exception as e:
processing_jobs[job_id].update({
"status": "error",
"progress": 0,
"message": f"처리 실패: {str(e)}"
})
print(f"❌ 처리 실패: {job_id} - {str(e)}")
@app.get("/status/{job_id}")
async def get_job_status(job_id: str):
"""작업 상태 조회"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
return processing_jobs[job_id]
@app.get("/download/{job_id}")
async def download_result(job_id: str):
"""결과 파일 다운로드"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
job = processing_jobs[job_id]
if job["status"] != "completed" or not job.get("nas_translated_path"):
raise HTTPException(status_code=400, detail="처리가 완료되지 않았습니다.")
result_path = Path(job["nas_translated_path"])
if not result_path.exists():
raise HTTPException(status_code=404, detail="결과 파일을 찾을 수 없습니다.")
return FileResponse(
path=result_path,
filename=f"{Path(job['filename']).stem}.html",
media_type="text/html"
)
@app.get("/nas-info")
async def nas_info():
"""NAS 정보 및 통계"""
nas_status = check_nas_connection()
info = {
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}",
"nas_ip": NAS_IP,
"nas_status": nas_status,
"mount_point": str(NAS_MOUNT_POINT),
"document_upload_base": str(DOCUMENT_UPLOAD_BASE),
"folders": {},
"statistics": {
"total_jobs": len(processing_jobs),
"completed_jobs": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
"processing_jobs": len([j for j in processing_jobs.values() if j["status"] == "processing"]),
"failed_jobs": len([j for j in processing_jobs.values() if j["status"] == "error"])
},
"recent_jobs": list(processing_jobs.values())[-5:] if processing_jobs else []
}
# 폴더 정보 수집
try:
for folder_name, folder_path in [
("originals", NAS_ORIGINALS_PATH),
("translated", NAS_TRANSLATED_PATH),
("static-hosting", NAS_STATIC_HOSTING_PATH),
("metadata", NAS_METADATA_PATH)
]:
if folder_path.exists():
files = list(folder_path.rglob("*.*"))
file_count = len([f for f in files if f.is_file()])
total_size = sum(f.stat().st_size for f in files if f.is_file())
info["folders"][folder_name] = {
"exists": True,
"path": str(folder_path),
"file_count": file_count,
"total_size_mb": round(total_size / (1024 * 1024), 2)
}
else:
info["folders"][folder_name] = {
"exists": False,
"path": str(folder_path)
}
except Exception as e:
info["error"] = str(e)
return info
if __name__ == "__main__":
import uvicorn
print(f"🚀 Mac Mini AI 번역 서버")
print(f"📡 접속 주소: http://{MAC_MINI_IP}:{MAC_MINI_PORT}")
print(f"📁 NAS Mount: {NAS_MOUNT_POINT}")
print(f"🔗 VPN 접속 전용 (내부 네트워크)")
# 시작 전 연결 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
print(f"✅ NAS 연결 확인됨")
ensure_nas_directories()
else:
print(f"❌ NAS 연결 문제: {nas_status['error']}")
print("-" * 60)
uvicorn.run(app, host="0.0.0.0", port=MAC_MINI_PORT)

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""
Mac Mini FastAPI + DS1525+ 연동
백그라운드 AI 서비스 및 대시보드 통합 버전 (v2.1 - 설정 중앙화 및 Lifespan 적용)
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request, Depends
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
import asyncio
import uuid
from pathlib import Path
from typing import Dict
import time
import shutil
import aiofiles
from datetime import datetime
import subprocess
import logging
# 중앙 설정 로더 및 AI 서비스 임포트
from config_loader import settings
from background_ai_service import ai_service
from security import get_api_key
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 설정에서 값 불러오기
network_cfg = settings.network_config
paths_cfg = settings.paths_config
MAC_MINI_IP = network_cfg.get("mac_mini_ip")
NAS_IP = network_cfg.get("nas_ip")
SERVER_PORT = network_cfg.get("server_port")
NAS_MOUNT_POINT = Path(paths_cfg.get("nas_mount_point"))
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / paths_cfg.get("document_upload_base")
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("originals")
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("translated")
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("static_hosting")
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("metadata")
LOCAL_WORK_PATH = Path(paths_cfg.get("local_work_path"))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI 라이프사이클 이벤트 핸들러"""
logger.info(f"🚀 Mac Mini AI 번역 서버 시작 (v2.1)")
logger.info(f"📍 Mac Mini IP: {MAC_MINI_IP}")
logger.info(f"📍 NAS IP: {NAS_IP}")
logger.info("-" * 50)
# 백그라운드 AI 서비스 시작
await ai_service.start_service()
# NAS 연결 및 폴더 구조 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
logger.info(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
create_nas_folders()
else:
logger.error(f"❌ NAS 연결 실패: {nas_status['error']}")
logger.info("해결 방법:")
logger.info("1. NAS 전원 및 네트워크 상태 확인")
logger.info("2. Finder에서 DS1525+ 수동 연결:")
logger.info(f" - 이동 → 서버에 연결 → smb://{NAS_IP}")
logger.info("3. 연결 후 서버 재시작")
yield
# 종료 시 실행될 코드 (필요 시)
logger.info("👋 서버 종료.")
app = FastAPI(
title="AI 번역 시스템 with 대시보드",
description=f"Mac Mini ({MAC_MINI_IP}) + DS1525+ ({NAS_IP}) 연동 + 실시간 모니터링",
version="2.1.0",
lifespan=lifespan
)
# 정적 파일 및 템플릿
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
# 작업 상태 관리
processing_jobs: Dict[str, Dict] = {}
def check_nas_connection():
"""NAS 연결 상태 실시간 확인"""
try:
# 1. ping 테스트
ping_result = subprocess.run(
["ping", "-c", "1", "-W", "3000", NAS_IP],
capture_output=True,
timeout=5
)
if ping_result.returncode != 0:
return {"status": "offline", "error": f"NAS {NAS_IP} ping 실패"}
# 2. 마운트 상태 확인
if not NAS_MOUNT_POINT.exists():
return {"status": "not_mounted", "error": "마운트 포인트 없음"}
# 3. 실제 마운트 확인
mount_result = subprocess.run(
["mount"],
capture_output=True,
text=True
)
if str(NAS_MOUNT_POINT) not in mount_result.stdout:
return {"status": "not_mounted", "error": "NAS가 마운트되지 않음"}
# 4. Document-upload 폴더 확인
if not DOCUMENT_UPLOAD_BASE.exists():
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
except subprocess.TimeoutExpired:
return {"status": "timeout", "error": "연결 타임아웃"}
except Exception as e:
return {"status": "error", "error": str(e)}
def create_nas_folders():
"""NAS에 필요한 폴더 구조 자동 생성"""
try:
current_month = datetime.now().strftime("%Y-%m")
folders_to_create = [
NAS_ORIGINALS_PATH / current_month / "pdfs",
NAS_ORIGINALS_PATH / current_month / "docs",
NAS_ORIGINALS_PATH / current_month / "txts",
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
NAS_TRANSLATED_PATH / current_month / "korean-only",
NAS_STATIC_HOSTING_PATH / "docs",
NAS_STATIC_HOSTING_PATH / "assets",
NAS_METADATA_PATH / "processing-logs"
]
for folder in folders_to_create:
folder.mkdir(parents=True, exist_ok=True)
logger.info(f"✅ 폴더 구조 확인/생성 완료")
except Exception as e:
logger.warning(f"⚠️ 폴더 생성 실패: {e}")
# ==========================================
# 기존 엔드포인트들
# ==========================================
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""메인 페이지"""
nas_status = check_nas_connection()
return templates.TemplateResponse("index.html", {
"request": request,
"nas_status": nas_status
})
@app.get("/system-status")
async def system_status():
"""전체 시스템 상태 확인"""
# NAS 상태
nas_status = check_nas_connection()
# Mac Mini 상태
mac_status = {
"ip": MAC_MINI_IP,
"hostname": subprocess.getoutput("hostname"),
"uptime": subprocess.getoutput("uptime"),
"disk_usage": {}
}
# 디스크 사용량 확인
try:
disk_result = subprocess.run(
["df", "-h", str(Path.home())],
capture_output=True,
text=True
)
if disk_result.returncode == 0:
lines = disk_result.stdout.strip().split('\n')
if len(lines) > 1:
parts = lines[1].split()
mac_status["disk_usage"] = {
"total": parts[1],
"used": parts[2],
"available": parts[3],
"percentage": parts[4]
}
except:
pass
# AI 모델 상태 (백그라운드 서비스에서 가져오기)
ai_status = ai_service.get_dashboard_data()
# 작업 통계
job_stats = {
"total_jobs": len(processing_jobs),
"completed": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
"processing": len([j for j in processing_jobs.values() if j["status"] == "processing"]),
"failed": len([j for j in processing_jobs.values() if j["status"] == "error"])
}
return {
"timestamp": datetime.now().isoformat(),
"nas": nas_status,
"mac_mini": mac_status,
"ai_models": ai_status,
"jobs": job_stats
}
@app.post("/upload")
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
"""파일 업로드 (백그라운드 AI 서비스 연동)"""
# NAS 연결 상태 먼저 확인
nas_status = check_nas_connection()
if nas_status["status"] != "connected":
raise HTTPException(
status_code=503,
detail=f"NAS 연결 실패: {nas_status['error']}. Finder에서 DS1525+를 연결해주세요."
)
# 파일 검증
if not file.filename:
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
file_ext = Path(file.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"지원되지 않는 파일 형식입니다. 지원 형식: {', '.join(allowed_extensions)}"
)
# 파일 크기 확인 (100MB 제한)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
# 고유 작업 ID 생성
job_id = str(uuid.uuid4())
# NAS에 체계적으로 저장
current_month = datetime.now().strftime("%Y-%m")
file_type_folder = {
'.pdf': 'pdfs',
'.doc': 'docs',
'.docx': 'docs',
'.txt': 'txts'
}.get(file_ext, 'others')
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
nas_original_dir.mkdir(parents=True, exist_ok=True)
# 안전한 파일명 생성
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
nas_file_path = nas_original_dir / safe_filename
# 파일 저장
try:
async with aiofiles.open(nas_file_path, 'wb') as f:
await f.write(content)
logger.info(f"📁 파일 저장 완료: {nas_file_path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
# 작업 상태 초기화
job = {
"id": job_id,
"filename": file.filename,
"file_type": file_ext,
"status": "uploaded",
"progress": 0,
"message": f"파일 업로드 완료, NAS에 저장됨 ({nas_file_path.name})",
"nas_original_path": str(nas_file_path),
"created_at": time.time()
}
processing_jobs[job_id] = job
# 백그라운드 AI 서비스에 작업 추가
await ai_service.add_job({
'job_id': job_id,
'file_path': str(nas_file_path),
'original_filename': file.filename
})
return {
"job_id": job_id,
"message": "파일 업로드 완료, 백그라운드 AI 처리를 시작합니다.",
"nas_path": str(nas_file_path)
}
@app.get("/status/{job_id}")
async def get_job_status(job_id: str):
"""작업 상태 조회"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
return processing_jobs[job_id]
# ==========================================
# 새로운 대시보드 엔드포인트들
# ==========================================
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
"""AI 모델 대시보드 페이지"""
return templates.TemplateResponse("dashboard.html", {
"request": request
})
@app.get("/api/dashboard")
async def get_dashboard_data():
"""대시보드용 실시간 데이터 API"""
return ai_service.get_dashboard_data()
@app.post("/api/restart-models")
async def restart_models(api_key: str = Depends(get_api_key)):
"""AI 모델 재시작 API (보안)"""
try:
await ai_service.restart_models()
return {"message": "AI 모델 재시작이 완료되었습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"모델 재시작 실패: {str(e)}")
@app.post("/api/clear-cache")
async def clear_cache(api_key: str = Depends(get_api_key)):
"""시스템 캐시 정리 API (보안)"""
try:
# 여기서 실제 캐시 정리 로직 구현
# 예: 임시 파일 삭제, 메모리 정리 등
import gc
gc.collect() # 가비지 컬렉션 실행
return {"message": "시스템 캐시가 정리되었습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"캐시 정리 실패: {str(e)}")
@app.get("/api/models/status")
async def get_models_status():
"""AI 모델 상태만 조회하는 API"""
dashboard_data = ai_service.get_dashboard_data()
return dashboard_data.get('models_status', {})
@app.get("/api/metrics/history")
async def get_metrics_history():
"""성능 지표 히스토리 조회 API"""
dashboard_data = ai_service.get_dashboard_data()
return dashboard_data.get('recent_metrics', [])
# ==========================================
# 유틸리티 함수들
# ==========================================
async def save_processing_metadata(job_id: str, original_path: Path, html_path: Path, metadata: Dict):
"""처리 메타데이터 NAS에 저장"""
log_data = {
"job_id": job_id,
"timestamp": datetime.now().isoformat(),
"original_file": str(original_path),
"html_file": str(html_path),
"metadata": metadata,
"nas_ip": NAS_IP,
"mac_mini_ip": MAC_MINI_IP,
"ai_service_version": "2.0"
}
# 월별 로그 파일에 추가
current_month = datetime.now().strftime("%Y-%m")
log_file = NAS_METADATA_PATH / "processing-logs" / f"{current_month}.jsonl"
try:
async with aiofiles.open(log_file, 'a', encoding='utf-8') as f:
await f.write(json.dumps(log_data, ensure_ascii=False) + '\n')
except Exception as e:
logger.error(f"메타데이터 저장 실패: {e}")
if __name__ == "__main__":
import uvicorn
logger.info(f"🚀 Mac Mini AI 번역 서버 with 대시보드 (v2.1)")
logger.info(f"📡 서버 주소: http://{MAC_MINI_IP}:{SERVER_PORT}")
logger.info(f"📊 대시보드: http://{MAC_MINI_IP}:{SERVER_PORT}/dashboard")
logger.info(f"📁 NAS 주소: {NAS_IP}")
uvicorn.run(app, host="0.0.0.0", port=SERVER_PORT)

View File

@@ -0,0 +1,597 @@
#!/usr/bin/env python3
"""
다국어 HTML 생성기
원문 + 번역문을 언어 전환 가능한 HTML로 생성
"""
from pathlib import Path
from typing import Dict, List
import json
import re
from datetime import datetime
class MultilingualHTMLGenerator:
def __init__(self):
self.templates = self._load_templates()
def _load_templates(self) -> Dict:
"""HTML 템플릿들"""
return {
"base": """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
:root {{
--primary-color: #2563eb;
--secondary-color: #64748b;
--background-color: #f8fafc;
--text-color: #1e293b;
--border-color: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}}
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
}}
.container {{
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}}
.title {{
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 12px;
}}
.metadata {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
font-size: 0.875rem;
color: var(--secondary-color);
}}
.metadata-item {{
display: flex;
align-items: center;
gap: 8px;
}}
.metadata-icon {{
font-size: 1.2em;
}}
.language-switcher {{
position: fixed;
top: 24px;
right: 24px;
z-index: 1000;
background: white;
border-radius: 12px;
padding: 12px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}}
.language-buttons {{
display: flex;
gap: 8px;
}}
.lang-btn {{
padding: 8px 16px;
border: 2px solid var(--border-color);
background: white;
color: var(--text-color);
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
font-size: 0.875rem;
}}
.lang-btn:hover {{
border-color: var(--primary-color);
color: var(--primary-color);
}}
.lang-btn.active {{
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}}
.content-section {{
background: white;
border-radius: 12px;
margin-bottom: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
overflow: hidden;
}}
.section-header {{
background: linear-gradient(135deg, var(--primary-color), #3b82f6);
color: white;
padding: 16px 24px;
font-weight: 600;
font-size: 1.1rem;
}}
.section-content {{
padding: 24px;
}}
.language-content {{
display: none;
}}
.language-content.active {{
display: block;
}}
.text-content {{
font-size: 1rem;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}}
.summary-box {{
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 1px solid #0ea5e9;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}}
.summary-title {{
font-weight: 600;
color: #0369a1;
margin-bottom: 12px;
font-size: 1.1rem;
}}
.toc {{
background: #f8fafc;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}}
.toc-title {{
font-weight: 600;
margin-bottom: 16px;
color: var(--primary-color);
}}
.toc-list {{
list-style: none;
}}
.toc-item {{
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}}
.toc-item:last-child {{
border-bottom: none;
}}
.toc-link {{
color: var(--text-color);
text-decoration: none;
transition: color 0.2s ease;
}}
.toc-link:hover {{
color: var(--primary-color);
}}
.reading-progress {{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: rgba(37, 99, 235, 0.1);
z-index: 1001;
}}
.progress-bar {{
height: 100%;
background: linear-gradient(90deg, var(--primary-color), #3b82f6);
width: 0%;
transition: width 0.1s ease;
}}
@media (max-width: 768px) {{
.container {{
padding: 12px;
}}
.language-switcher {{
position: static;
margin-bottom: 20px;
}}
.title {{
font-size: 1.5rem;
}}
.metadata {{
grid-template-columns: 1fr;
}}
}}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {{
:root {{
--background-color: #0f172a;
--text-color: #f1f5f9;
--border-color: #334155;
}}
.header, .content-section, .language-switcher {{
background: #1e293b;
}}
.lang-btn {{
background: #1e293b;
color: var(--text-color);
}}
}}
</style>
</head>
<body>
<div class="reading-progress">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="language-switcher">
<div class="language-buttons">
{language_buttons}
</div>
</div>
<div class="container">
<div class="header">
<h1 class="title">{title}</h1>
<div class="metadata">
{metadata}
</div>
</div>
{toc_section}
{summary_section}
<div class="content-section">
<div class="section-header">
📖 본문 내용
</div>
<div class="section-content">
{content_sections}
</div>
</div>
</div>
<script>
// 언어 전환 기능
function switchLanguage(lang) {{
// 모든 언어 콘텐츠 숨기기
document.querySelectorAll('.language-content').forEach(el => {{
el.classList.remove('active');
}});
// 선택된 언어 콘텐츠 표시
document.querySelectorAll(`.language-content[data-lang="${{lang}}"]`).forEach(el => {{
el.classList.add('active');
}});
// 버튼 상태 업데이트
document.querySelectorAll('.lang-btn').forEach(btn => {{
btn.classList.remove('active');
}});
document.querySelector(`.lang-btn[data-lang="${{lang}}"]`).classList.add('active');
// 로컬 스토리지에 언어 설정 저장
localStorage.setItem('selectedLanguage', lang);
}}
// 읽기 진행률 표시
function updateProgress() {{
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / scrollHeight) * 100;
document.getElementById('progressBar').style.width = progress + '%';
}}
// 초기화
document.addEventListener('DOMContentLoaded', function() {{
// 저장된 언어 설정 복원
const savedLang = localStorage.getItem('selectedLanguage') || '{default_lang}';
switchLanguage(savedLang);
// 스크롤 이벤트 리스너
window.addEventListener('scroll', updateProgress);
// 언어 버튼 이벤트 리스너
document.querySelectorAll('.lang-btn').forEach(btn => {{
btn.addEventListener('click', () => {{
switchLanguage(btn.dataset.lang);
}});
}});
}});
// 키보드 단축키 (1, 2, 3으로 언어 전환)
document.addEventListener('keydown', function(e) {{
const langButtons = document.querySelectorAll('.lang-btn');
const key = parseInt(e.key);
if (key >= 1 && key <= langButtons.length) {{
const targetLang = langButtons[key - 1].dataset.lang;
switchLanguage(targetLang);
}}
}});
</script>
</body>
</html>""",
"language_button": """<button class="lang-btn" data-lang="{lang_code}">{lang_name}</button>""",
"metadata_item": """<div class="metadata-item">
<span class="metadata-icon">{icon}</span>
<span>{label}: {value}</span>
</div>""",
"content_section": """<div class="language-content" data-lang="{lang_code}">
<div class="text-content">{content}</div>
</div>""",
"summary_section": """<div class="content-section">
<div class="section-header">
📋 요약
</div>
<div class="section-content">
<div class="summary-box">
<div class="summary-title">문서 요약</div>
{summary_content}
</div>
</div>
</div>""",
"toc_section": """<div class="toc">
<div class="toc-title">📑 목차</div>
<ul class="toc-list">
{toc_items}
</ul>
</div>"""
}
def _generate_toc(self, content: str) -> List[Dict]:
"""목차 자동 생성"""
# 간단한 헤딩 감지 (대문자로 시작하는 짧은 줄들)
lines = content.split('\n')
toc_items = []
for i, line in enumerate(lines):
line = line.strip()
# 헤딩 감지 조건
if (len(line) < 100 and
len(line) > 5 and
(line.isupper() or
line.startswith(('Chapter', '', '', '1.', '2.', '3.', '4.', '5.')))):
toc_items.append({
"title": line,
"anchor": f"section-{i}",
"line_number": i
})
return toc_items[:10] # 최대 10개만
def generate_multilingual_html(self,
title: str,
contents: Dict[str, str], # {lang_code: content}
summary: str = "",
metadata: Dict = None,
output_path: str = "output.html") -> str:
"""다국어 HTML 생성"""
# 언어 정보 매핑
lang_info = {
"korean": {"name": "한국어", "code": "ko", "icon": "🇰🇷"},
"english": {"name": "English", "code": "en", "icon": "🇺🇸"},
"japanese": {"name": "日本語", "code": "ja", "icon": "🇯🇵"}
}
# 기본 메타데이터
if metadata is None:
metadata = {}
default_metadata = {
"처리_시간": datetime.now().strftime("%Y-%m-%d %H:%M"),
"언어_수": len(contents),
"총_문자수": sum(len(content) for content in contents.values()),
"생성_도구": "NLLB 번역 시스템"
}
default_metadata.update(metadata)
# 언어 버튼 생성
language_buttons = []
for lang_key, content in contents.items():
lang_data = lang_info.get(lang_key, {"name": lang_key.title(), "code": lang_key[:2], "icon": "🌐"})
button_html = self.templates["language_button"].format(
lang_code=lang_data["code"],
lang_name=f"{lang_data['icon']} {lang_data['name']}"
)
language_buttons.append(button_html)
# 메타데이터 생성
metadata_items = []
metadata_icons = {
"처리_시간": "",
"언어_수": "🌍",
"총_문자수": "📝",
"생성_도구": "⚙️",
"원본_언어": "🔤",
"파일_크기": "📊"
}
for key, value in default_metadata.items():
icon = metadata_icons.get(key, "📄")
label = key.replace("_", " ").title()
metadata_html = self.templates["metadata_item"].format(
icon=icon,
label=label,
value=value
)
metadata_items.append(metadata_html)
# 콘텐츠 섹션 생성
content_sections = []
default_lang = list(contents.keys())[0]
for lang_key, content in contents.items():
lang_data = lang_info.get(lang_key, {"code": lang_key[:2]})
# 내용을 문단별로 정리
formatted_content = self._format_content(content)
section_html = self.templates["content_section"].format(
lang_code=lang_data["code"],
content=formatted_content
)
content_sections.append(section_html)
# 목차 생성 (첫 번째 언어 기준)
first_content = list(contents.values())[0]
toc_items = self._generate_toc(first_content)
toc_html = ""
if toc_items:
toc_item_htmls = []
for item in toc_items:
toc_item_htmls.append(f'<li class="toc-item"><a href="#{item["anchor"]}" class="toc-link">{item["title"]}</a></li>')
toc_html = self.templates["toc_section"].format(
toc_items="\n".join(toc_item_htmls)
)
# 요약 섹션
summary_html = ""
if summary:
summary_content_sections = []
for lang_key in contents.keys():
lang_data = lang_info.get(lang_key, {"code": lang_key[:2]})
if lang_key == "korean" or "korean" not in contents:
summary_content = f'<div class="language-content active" data-lang="{lang_data["code"]}">{summary}</div>'
else:
summary_content = f'<div class="language-content" data-lang="{lang_data["code"]}">{summary}</div>'
summary_content_sections.append(summary_content)
summary_html = self.templates["summary_section"].format(
summary_content="\n".join(summary_content_sections)
)
# 최종 HTML 생성
default_lang_code = lang_info.get(default_lang, {"code": default_lang[:2]})["code"]
html_content = self.templates["base"].format(
title=title,
language_buttons="\n".join(language_buttons),
metadata="\n".join(metadata_items),
toc_section=toc_html,
summary_section=summary_html,
content_sections="\n".join(content_sections),
default_lang=default_lang_code
)
# 파일 저장
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"다국어 HTML 생성 완료: {output_path}")
return str(output_file)
def _format_content(self, content: str) -> str:
"""내용 포맷팅"""
# 기본 텍스트 정리
content = content.strip()
# 문단 구분 개선
content = re.sub(r'\n\s*\n\s*\n+', '\n\n', content)
# 특수 문자 이스케이프
content = content.replace('<', '&lt;').replace('>', '&gt;')
return content
def main():
"""HTML 생성기 테스트"""
generator = MultilingualHTMLGenerator()
# 테스트 데이터
test_contents = {
"english": """Chapter 1: Introduction to Machine Learning
Machine learning represents one of the most transformative technologies of our time. This comprehensive guide explores the core concepts, methodologies, and applications that define this rapidly evolving field.
The power of machine learning lies in its ability to handle complex problems that would be difficult or impossible to solve using conventional programming methods.""",
"korean": """제1장: 기계학습 소개
기계학습은 우리 시대의 가장 혁신적인 기술 중 하나를 나타냅니다. 이 포괄적인 가이드는 빠르게 발전하는 이 분야를 정의하는 핵심 개념, 방법론 및 응용 분야를 탐구합니다.
기계학습의 힘은 기존 프로그래밍 방법으로는 해결하기 어렵거나 불가능한 복잡한 문제를 처리할 수 있는 능력에 있습니다."""
}
test_summary = "이 문서는 기계학습의 기본 개념과 응용 분야에 대해 설명합니다. 기계학습이 현대 기술에서 차지하는 중요성과 복잡한 문제 해결 능력을 강조합니다."
test_metadata = {
"원본_언어": "English",
"파일_크기": "2.3 MB"
}
# HTML 생성
output_path = generator.generate_multilingual_html(
title="기계학습 완전 가이드",
contents=test_contents,
summary=test_summary,
metadata=test_metadata,
output_path="output/test_multilingual.html"
)
print(f"테스트 HTML 생성됨: {output_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
완전한 PDF -> HTML 번역 시스템
PDF OCR -> NLLB 번역 -> KoBART 요약 -> HTML 생성
"""
import torch
import time
import json
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import re
from dataclasses import dataclass
# 문서 처리
try:
import PyPDF2
import pdfplumber
from docx import Document
except ImportError:
print("문서 처리 라이브러리 설치 필요: pip install PyPDF2 pdfplumber python-docx")
# 번역 및 요약 모델
from transformers import (
AutoTokenizer, AutoModelForSeq2SeqLM,
PreTrainedTokenizerFast, BartForConditionalGeneration
)
@dataclass
class TranslationResult:
original_text: str
translated_text: str
summary: str
processing_time: float
metadata: Dict
class IntegratedTranslationSystem:
def __init__(self):
self.device = self._setup_device()
self.models = {}
self.tokenizers = {}
self.config = self._load_config()
print(f"번역 시스템 초기화 (디바이스: {self.device})")
def _setup_device(self) -> torch.device:
"""최적 디바이스 설정"""
if torch.backends.mps.is_available():
return torch.device("mps")
elif torch.cuda.is_available():
return torch.device("cuda")
else:
return torch.device("cpu")
def _load_config(self) -> Dict:
"""설정 파일 로드"""
config_path = Path("config/settings.json")
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
# 기본 설정
return {
"translation": {
"chunk_size": 500,
"max_length": 512,
"num_beams": 4,
"batch_size": 4
},
"summarization": {
"max_length": 150,
"min_length": 30,
"num_beams": 4
}
}
def load_models(self):
"""모든 모델 로드"""
print("모델 로딩 중...")
# 1. NLLB 번역 모델
print(" NLLB 번역 모델...")
try:
self.tokenizers['nllb'] = AutoTokenizer.from_pretrained("facebook/nllb-200-3.3B")
self.models['nllb'] = AutoModelForSeq2SeqLM.from_pretrained(
"facebook/nllb-200-3.3B",
torch_dtype=torch.float16,
low_cpu_mem_usage=True
).to(self.device)
print(" NLLB 모델 로드 완료")
except Exception as e:
print(f" NLLB 모델 로드 실패: {e}")
return False
# 2. KoBART 요약 모델
print(" KoBART 요약 모델...")
try:
self.tokenizers['kobart'] = PreTrainedTokenizerFast.from_pretrained("gogamza/kobart-summarization")
self.models['kobart'] = BartForConditionalGeneration.from_pretrained(
"gogamza/kobart-summarization",
torch_dtype=torch.float16,
low_cpu_mem_usage=True
).to(self.device)
print(" KoBART 모델 로드 완료")
except Exception as e:
print(f" KoBART 모델 로드 실패: {e}")
print(" 요약 없이 번역만 진행")
self.models['kobart'] = None
print("모델 로딩 완료!")
return True
def detect_language(self, text: str) -> str:
"""언어 자동 감지"""
# 간단한 휴리스틱 언어 감지
korean_chars = len(re.findall(r'[가-힣]', text))
japanese_chars = len(re.findall(r'[ひらがなカタカナ一-龯]', text))
english_chars = len(re.findall(r'[a-zA-Z]', text))
total_chars = len(text.replace(' ', ''))
if total_chars == 0:
return "unknown"
if korean_chars / total_chars > 0.3:
return "korean"
elif japanese_chars / total_chars > 0.1:
return "japanese"
elif english_chars / total_chars > 0.5:
return "english"
else:
return "unknown"
def extract_text_from_pdf(self, pdf_path: str) -> str:
"""PDF에서 텍스트 추출"""
print(f"PDF 텍스트 추출: {pdf_path}")
text = ""
try:
# pdfplumber 우선 시도
import pdfplumber
with pdfplumber.open(pdf_path) as pdf:
for page_num, page in enumerate(pdf.pages, 1):
page_text = page.extract_text()
if page_text:
text += f"\n\n{page_text}"
print(f"PDF 텍스트 추출 완료: {len(text)}")
except Exception as e:
print(f"pdfplumber 실패: {e}")
# PyPDF2 백업
try:
import PyPDF2
with open(pdf_path, 'rb') as file:
pdf_reader = PyPDF2.PdfReader(file)
for page in pdf_reader.pages:
page_text = page.extract_text()
if page_text:
text += f"\n\n{page_text}"
print(f"PyPDF2로 텍스트 추출 완료: {len(text)}")
except Exception as e2:
print(f"PDF 텍스트 추출 완전 실패: {e2}")
return ""
return self._clean_text(text)
def _clean_text(self, text: str) -> str:
"""추출된 텍스트 정리"""
# 과도한 공백 정리
text = re.sub(r'\n\s*\n\s*\n', '\n\n', text)
text = re.sub(r'[ \t]+', ' ', text)
# 페이지 번호 제거
text = re.sub(r'\n\d+\n', '\n', text)
return text.strip()
def split_text_into_chunks(self, text: str, chunk_size: int = 500) -> List[str]:
"""텍스트를 번역 가능한 청크로 분할"""
sentences = re.split(r'[.!?]\s+', text)
chunks = []
current_chunk = ""
for sentence in sentences:
test_chunk = current_chunk + " " + sentence if current_chunk else sentence
if len(test_chunk) > chunk_size and current_chunk:
chunks.append(current_chunk.strip())
current_chunk = sentence
else:
current_chunk = test_chunk
if current_chunk:
chunks.append(current_chunk.strip())
print(f"텍스트 분할: {len(chunks)}개 청크")
return chunks
def translate_text(self, text: str, src_lang: str = "english") -> str:
"""NLLB로 텍스트 번역"""
if src_lang == "korean":
return text # 한국어는 번역하지 않음
# 언어 코드 매핑
lang_map = {
"english": "eng_Latn",
"japanese": "jpn_Jpan",
"korean": "kor_Hang"
}
src_code = lang_map.get(src_lang, "eng_Latn")
tgt_code = "kor_Hang"
tokenizer = self.tokenizers['nllb']
model = self.models['nllb']
# 청크별 번역
chunks = self.split_text_into_chunks(text, self.config["translation"]["chunk_size"])
translated_chunks = []
print(f"번역 시작: {src_lang} -> 한국어")
for i, chunk in enumerate(chunks):
print(f" 청크 {i+1}/{len(chunks)} 번역 중...")
inputs = tokenizer(chunk, return_tensors="pt", padding=True, truncation=True).to(self.device)
with torch.no_grad():
translated_tokens = model.generate(
**inputs,
forced_bos_token_id=tokenizer.convert_tokens_to_ids(tgt_code),
max_length=self.config["translation"]["max_length"],
num_beams=self.config["translation"]["num_beams"],
early_stopping=True
)
translated_chunk = tokenizer.decode(translated_tokens[0], skip_special_tokens=True)
translated_chunks.append(translated_chunk)
result = "\n\n".join(translated_chunks)
print(f"번역 완료: {len(result)}")
return result
def summarize_text(self, text: str) -> str:
"""KoBART로 한국어 텍스트 요약"""
if self.models['kobart'] is None:
print("요약 모델 없음, 첫 300자 반환")
return text[:300] + "..." if len(text) > 300 else text
print("텍스트 요약 중...")
tokenizer = self.tokenizers['kobart']
model = self.models['kobart']
inputs = tokenizer(
text,
return_tensors="pt",
max_length=1024,
truncation=True,
padding=True,
return_token_type_ids=False
).to(self.device)
with torch.no_grad():
summary_ids = model.generate(
input_ids=inputs['input_ids'],
attention_mask=inputs['attention_mask'],
max_length=self.config["summarization"]["max_length"],
min_length=self.config["summarization"]["min_length"],
num_beams=self.config["summarization"]["num_beams"],
early_stopping=True,
no_repeat_ngram_size=2,
length_penalty=1.2
)
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
print(f"요약 완료: {len(summary)}")
return summary
def process_document(self, input_path: str, output_dir: str = "output") -> TranslationResult:
"""전체 문서 처리 파이프라인"""
start_time = time.time()
print(f"문서 처리 시작: {input_path}")
# 1. 텍스트 추출
if input_path.lower().endswith('.pdf'):
original_text = self.extract_text_from_pdf(input_path)
else:
with open(input_path, 'r', encoding='utf-8') as f:
original_text = f.read()
if not original_text:
raise ValueError("텍스트 추출 실패")
# 2. 언어 감지
detected_lang = self.detect_language(original_text)
print(f"감지된 언어: {detected_lang}")
# 3. 번역
if detected_lang == "korean":
translated_text = original_text
print("한국어 문서, 번역 생략")
else:
translated_text = self.translate_text(original_text, detected_lang)
# 4. 요약
summary = self.summarize_text(translated_text)
# 5. 결과 저장
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
base_name = Path(input_path).stem
# 텍스트 파일 저장
with open(output_path / f"{base_name}_translated.txt", 'w', encoding='utf-8') as f:
f.write(translated_text)
with open(output_path / f"{base_name}_summary.txt", 'w', encoding='utf-8') as f:
f.write(summary)
processing_time = time.time() - start_time
result = TranslationResult(
original_text=original_text,
translated_text=translated_text,
summary=summary,
processing_time=processing_time,
metadata={
"input_file": input_path,
"detected_language": detected_lang,
"original_chars": len(original_text),
"translated_chars": len(translated_text),
"summary_chars": len(summary),
"compression_ratio": len(summary) / len(translated_text) * 100 if translated_text else 0
}
)
print(f"문서 처리 완료! ({processing_time/60:.1f}분 소요)")
return result
def main():
"""메인 실행 함수"""
system = IntegratedTranslationSystem()
if not system.load_models():
print("모델 로딩 실패")
return None
print("\n" + "="*60)
print("통합 번역 시스템 준비 완료!")
print("사용법:")
print(" result = system.process_document('input.pdf')")
print("="*60)
return system
if __name__ == "__main__":
system = main()

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
NAS 자동 마운트 및 연결 설정
DS1525+ (192.168.1.227) → Mac Mini (192.168.1.122)
"""
import subprocess
import os
from pathlib import Path
import time
class NASMountManager:
def __init__(self):
self.nas_ip = "192.168.1.227"
self.nas_share = "Media" # 또는 실제 공유 폴더명
self.mount_point = Path("/Volumes/DS1525+")
self.smb_url = f"smb://{self.nas_ip}/{self.nas_share}"
def check_nas_connection(self):
"""NAS 연결 상태 확인"""
try:
# ping으로 NAS 접근 가능한지 확인
result = subprocess.run(
["ping", "-c", "3", self.nas_ip],
capture_output=True,
timeout=10
)
if result.returncode == 0:
print(f"✅ NAS 연결 가능: {self.nas_ip}")
return True
else:
print(f"❌ NAS 연결 불가: {self.nas_ip}")
return False
except subprocess.TimeoutExpired:
print(f"⏰ NAS 연결 타임아웃: {self.nas_ip}")
return False
except Exception as e:
print(f"❌ 연결 확인 오류: {e}")
return False
def check_mount_status(self):
"""마운트 상태 확인"""
if self.mount_point.exists():
# 실제 마운트되어 있는지 확인
try:
result = subprocess.run(
["mount"],
capture_output=True,
text=True
)
if str(self.mount_point) in result.stdout:
print(f"✅ NAS 이미 마운트됨: {self.mount_point}")
return True
else:
print(f"📁 마운트 포인트 존재하지만 연결 안됨: {self.mount_point}")
return False
except Exception as e:
print(f"❌ 마운트 상태 확인 실패: {e}")
return False
else:
print(f"📁 마운트 포인트 없음: {self.mount_point}")
return False
def mount_nas(self, username=None, password=None):
"""NAS 마운트"""
if self.check_mount_status():
return True
if not self.check_nas_connection():
return False
try:
# 마운트 포인트 생성
self.mount_point.mkdir(parents=True, exist_ok=True)
# SMB 마운트 시도
if username and password:
# 인증 정보가 있는 경우
mount_cmd = [
"mount", "-t", "smbfs",
f"//{username}:{password}@{self.nas_ip}/{self.nas_share}",
str(self.mount_point)
]
else:
# 게스트 접근 시도
mount_cmd = [
"mount", "-t", "smbfs",
f"//{self.nas_ip}/{self.nas_share}",
str(self.mount_point)
]
print(f"🔄 NAS 마운트 시도: {self.smb_url}")
result = subprocess.run(mount_cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f"✅ NAS 마운트 성공: {self.mount_point}")
return True
else:
print(f"❌ NAS 마운트 실패: {result.stderr}")
return False
except Exception as e:
print(f"❌ 마운트 프로세스 오류: {e}")
return False
def unmount_nas(self):
"""NAS 언마운트"""
try:
if self.check_mount_status():
result = subprocess.run(
["umount", str(self.mount_point)],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f"✅ NAS 언마운트 완료: {self.mount_point}")
return True
else:
print(f"❌ 언마운트 실패: {result.stderr}")
return False
else:
print(" NAS가 마운트되어 있지 않음")
return True
except Exception as e:
print(f"❌ 언마운트 오류: {e}")
return False
def create_document_upload_structure(self):
"""Document-upload 폴더 구조 생성"""
if not self.check_mount_status():
print("❌ NAS가 마운트되지 않음")
return False
base_path = self.mount_point / "Document-upload"
try:
# 기본 구조 생성
folders = [
"originals",
"originals/2025-01/pdfs",
"originals/2025-01/docs",
"originals/2025-01/txts",
"translated",
"translated/2025-01/english-to-korean",
"translated/2025-01/japanese-to-korean",
"translated/2025-01/korean-only",
"static-hosting/docs",
"static-hosting/assets",
"static-hosting/index",
"metadata/processing-logs"
]
for folder in folders:
folder_path = base_path / folder
folder_path.mkdir(parents=True, exist_ok=True)
print(f"📁 폴더 생성: {folder}")
# 기본 README 파일 생성
readme_content = """# Document Upload System
이 폴더는 AI 번역 시스템에서 자동으로 관리됩니다.
## 폴더 구조
- originals/: 업로드된 원본 파일들 (월별/타입별 정리)
- translated/: 번역된 HTML 파일들 (월별/언어별 정리)
- static-hosting/: 웹 호스팅용 파일들
- metadata/: 처리 로그 및 인덱스 정보
## 자동 관리
- 파일은 월별로 자동 정리됩니다
- 언어 감지에 따라 적절한 폴더에 저장됩니다
- 메타데이터는 자동으로 기록됩니다
"""
readme_path = base_path / "README.md"
with open(readme_path, 'w', encoding='utf-8') as f:
f.write(readme_content)
print(f"✅ Document-upload 구조 생성 완료: {base_path}")
return True
except Exception as e:
print(f"❌ 폴더 구조 생성 실패: {e}")
return False
def main():
"""NAS 설정 메인 함수"""
print("🚀 NAS 마운트 및 설정 시작")
print("=" * 50)
mount_manager = NASMountManager()
# 1. NAS 연결 확인
if not mount_manager.check_nas_connection():
print("\n❌ NAS에 연결할 수 없습니다.")
print("확인사항:")
print("- NAS 전원이 켜져 있는지")
print("- 네트워크 연결 상태")
print("- IP 주소: 192.168.1.227")
return False
# 2. 마운트 시도
if not mount_manager.check_mount_status():
print("\n🔄 NAS 마운트 시도...")
# 우선 게스트 접근 시도
if not mount_manager.mount_nas():
print("\n🔐 인증이 필요할 수 있습니다.")
print("Finder에서 수동으로 연결하거나:")
print("1. Finder → 이동 → 서버에 연결")
print("2. smb://192.168.1.227 입력")
print("3. 인증 정보 입력")
return False
# 3. 폴더 구조 생성
print("\n📁 Document-upload 폴더 구조 생성...")
if mount_manager.create_document_upload_structure():
print("\n🎉 NAS 설정 완료!")
print(f"📁 마운트 위치: {mount_manager.mount_point}")
print(f"📁 문서 저장 위치: {mount_manager.mount_point}/Document-upload")
return True
else:
print("\n❌ 폴더 구조 생성 실패")
return False
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
API 보안 및 인증 모듈
API 키 기반의 인증을 처리합니다.
"""
from fastapi import Security, HTTPException
from fastapi.security import APIKeyHeader
from starlette import status
from config_loader import settings
# 설정에서 API 키 가져오기
API_KEY = settings.get_section("security").get("api_key")
API_KEY_NAME = "X-API-KEY"
# API 키 헤더 정의
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
async def get_api_key(header_value: str = Security(api_key_header)):
"""
요청 헤더에서 API 키를 추출하고 유효성을 검증합니다.
유효하지 않은 경우, HTTPException을 발생시킵니다.
"""
if not header_value:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API 키가 필요합니다."
)
if header_value != API_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="제공된 API 키가 유효하지 않습니다."
)
return header_value
if __name__ == "__main__":
# 보안 모듈 테스트
print("✅ 보안 모듈 테스트")
print("-" * 30)
print(f" - 설정된 API 키 이름: {API_KEY_NAME}")
print(f" - 설정된 API 키 값: {API_KEY}")
# 예제: get_api_key 함수 테스트 (비동기 함수라 직접 실행은 어려움)
async def test_key_validation():
print("\n[테스트 시나리오]")
# 1. 유효한 키
try:
await get_api_key(API_KEY)
print(" - 유효한 키 검증: 성공 ✅")
except HTTPException as e:
print(f" - 유효한 키 검증: 실패 ❌ ({e.detail})")
# 2. 유효하지 않은 키
try:
await get_api_key("invalid-key")
print(" - 유효하지 않은 키 검증: 성공 ✅")
except HTTPException as e:
print(f" - 유효하지 않은 키 검증: 실패 ❌ ({e.detail})")
# 3. 키 없음
try:
await get_api_key(None)
print(" - 키 없음 검증: 성공 ✅")
except HTTPException as e:
print(f" - 키 없음 검증: 실패 ❌ ({e.detail})")
import asyncio
asyncio.run(test_key_validation())
print("-" * 30)

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
NLLB 모델 테스트 (수정된 버전)
"""
import torch
import time
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
def test_nllb_fixed():
print("🧪 NLLB 모델 테스트 (수정된 버전)")
model_name = "facebook/nllb-200-3.3B"
# 모델 로드
print("📥 모델 로딩 중...")
tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir="models/nllb-200-3.3B")
model = AutoModelForSeq2SeqLM.from_pretrained(
model_name,
cache_dir="models/nllb-200-3.3B",
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
# Apple Silicon 최적화
if torch.backends.mps.is_available():
device = torch.device("mps")
model = model.to(device)
print("🚀 Apple Silicon MPS 가속 사용")
else:
device = torch.device("cpu")
print("💻 CPU 모드 사용")
# NLLB 언어 코드 (수정된 방식)
def get_lang_id(tokenizer, lang_code):
"""언어 코드를 토큰 ID로 변환"""
return tokenizer.convert_tokens_to_ids(lang_code)
def translate_text(text, src_lang, tgt_lang, description):
print(f"\n📝 {description}:")
print(f"원문: {text}")
start_time = time.time()
# 입력 텍스트 토큰화
inputs = tokenizer(text, return_tensors="pt", padding=True).to(device)
# 번역 생성 (수정된 방식)
with torch.no_grad():
generated_tokens = model.generate(
**inputs,
forced_bos_token_id=get_lang_id(tokenizer, tgt_lang),
max_length=200,
num_beams=4,
early_stopping=True,
do_sample=False,
pad_token_id=tokenizer.pad_token_id
)
# 결과 디코딩
result = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]
translation_time = time.time() - start_time
print(f"번역: {result}")
print(f"소요 시간: {translation_time:.2f}")
return result, translation_time
print("\n" + "="*60)
try:
# 1. 영어 → 한국어
en_text = "Artificial intelligence is transforming the way we work and live."
translate_text(en_text, "eng_Latn", "kor_Hang", "영어 → 한국어 번역")
# 2. 일본어 → 한국어
ja_text = "人工知能は私たちの働き方と生活を変革しています。"
translate_text(ja_text, "jpn_Jpan", "kor_Hang", "일본어 → 한국어 번역")
# 3. 기술 문서 테스트
tech_text = "Machine learning algorithms require large datasets for training and validation."
translate_text(tech_text, "eng_Latn", "kor_Hang", "기술 문서 번역")
print(f"\n✅ 모든 테스트 성공!")
return True
except Exception as e:
print(f"❌ 테스트 중 오류: {e}")
return False
if __name__ == "__main__":
if test_nllb_fixed():
print("\n🎉 NLLB 모델 테스트 완료!")
print("📝 다음 단계: KoBART 요약 모델 설치")
else:
print("\n❌ 테스트 실패")

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
요약 모델 테스트 (토큰 오류 수정)
"""
import torch
import time
from transformers import PreTrainedTokenizerFast, BartForConditionalGeneration
def test_summarizer_fixed():
print("🧪 한국어 요약 모델 테스트 (수정된 버전)")
model_name = "gogamza/kobart-summarization"
try:
# 모델 로드
print("📥 모델 로딩 중...")
tokenizer = PreTrainedTokenizerFast.from_pretrained(model_name)
model = BartForConditionalGeneration.from_pretrained(
model_name,
torch_dtype=torch.float16,
low_cpu_mem_usage=True
)
# Apple Silicon 최적화
if torch.backends.mps.is_available():
device = torch.device("mps")
model = model.to(device)
print("🚀 Apple Silicon MPS 가속 사용")
else:
device = torch.device("cpu")
print("💻 CPU 모드 사용")
def summarize_text_fixed(text):
print(f"\n📝 요약 테스트:")
print(f"원문 ({len(text)}자):")
print(f"{text[:150]}...")
start_time = time.time()
# 토큰화 (token_type_ids 제거)
inputs = tokenizer(
text,
return_tensors="pt",
max_length=1024,
truncation=True,
padding=True,
return_token_type_ids=False # 이 부분이 핵심!
).to(device)
# 요약 생성
with torch.no_grad():
summary_ids = model.generate(
input_ids=inputs['input_ids'],
attention_mask=inputs['attention_mask'],
max_length=150,
min_length=30,
num_beams=4,
early_stopping=True,
no_repeat_ngram_size=2,
length_penalty=1.2,
do_sample=False
)
# 결과 디코딩
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
process_time = time.time() - start_time
print(f"\n📋 요약 결과 ({len(summary)}자):")
print(f"{summary}")
print(f"⏱️ 처리 시간: {process_time:.2f}")
print(f"📊 압축률: {len(summary)/len(text)*100:.1f}%")
return summary
# 테스트 실행
test_text = """
인공지능과 기계학습 기술이 급속도로 발전하면서 우리의 일상생활과 업무 환경에 혁신적인 변화를 가져오고 있습니다.
특히 자연어 처리 분야에서는 번역, 요약, 대화형 AI 등의 기술이 실용적인 수준에 도달하여 다양한 서비스에 적용되고 있습니다.
기계학습 알고리즘은 대량의 텍스트 데이터를 학습하여 언어의 패턴과 의미를 이해하고, 이를 바탕으로 인간과 유사한 수준의
언어 처리 능력을 보여주고 있습니다. 딥러닝 기술의 발전으로 번역의 정확도가 크게 향상되었으며, 실시간 번역 서비스도
일상적으로 사용할 수 있게 되었습니다.
"""
summarize_text_fixed(test_text.strip())
print(f"\n✅ 요약 모델 테스트 성공!")
return True
except Exception as e:
print(f"❌ 테스트 실패: {e}")
return False
if __name__ == "__main__":
print("🚀 한국어 요약 모델 테스트 (수정)")
print("="*50)
if test_summarizer_fixed():
print("\n🎉 요약 모델 정상 작동!")
print("📝 다음 단계: 통합 번역 시스템 구축")
else:
print("\n❌ 여전히 문제 있음")
print("📝 요약 없이 번역만으로 진행 고려")

View File

@@ -0,0 +1,585 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 모델 대시보드 - NLLB 번역 시스템</title>
<style>
:root {
--primary-color: #2563eb;
--success-color: #059669;
--warning-color: #d97706;
--error-color: #dc2626;
--background-color: #f8fafc;
--text-color: #1e293b;
--border-color: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
}
.dashboard-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 8px;
}
.header .subtitle {
color: #64748b;
font-size: 1.1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.card-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.card-icon {
font-size: 1.5rem;
}
.model-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.model-info h4 {
font-weight: 600;
margin-bottom: 4px;
}
.model-details {
font-size: 0.875rem;
color: #64748b;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-ready {
background: #dcfce7;
color: var(--success-color);
}
.status-loading {
background: #fef3c7;
color: var(--warning-color);
}
.status-error {
background: #fee2e2;
color: var(--error-color);
}
.status-unloaded {
background: #f1f5f9;
color: #64748b;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.metric-item {
text-align: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 4px;
}
.metric-label {
font-size: 0.875rem;
color: #64748b;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s ease;
}
.control-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #64748b;
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.job-list {
max-height: 300px;
overflow-y: auto;
}
.job-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.job-item:last-child {
border-bottom: none;
}
.job-id {
font-family: monospace;
font-size: 0.75rem;
color: #64748b;
}
.auto-refresh {
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.refresh-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--success-color);
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.chart-container {
height: 200px;
position: relative;
margin-top: 16px;
}
@media (max-width: 768px) {
.dashboard-container {
padding: 12px;
}
.grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="auto-refresh">
<div style="display: flex; align-items: center;">
<div class="refresh-indicator"></div>
<span style="font-size: 0.875rem;">실시간 업데이트</span>
</div>
</div>
<div class="dashboard-container">
<div class="header">
<h1>🤖 AI 모델 대시보드</h1>
<p class="subtitle">NLLB 번역 시스템 - 실시간 모니터링</p>
</div>
<div class="grid">
<!-- 모델 상태 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">🧠</span>
<h3>AI 모델 상태</h3>
</div>
<div class="model-status" id="modelStatus">
<!-- 동적으로 채워짐 -->
</div>
<div class="control-buttons">
<button class="btn btn-primary" onclick="restartModels()">
🔄 모델 재시작
</button>
<button class="btn btn-secondary" onclick="clearCache()">
🗑️ 캐시 정리
</button>
</div>
</div>
<!-- 시스템 성능 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">📊</span>
<h3>시스템 성능</h3>
</div>
<div class="metrics-grid" id="systemMetrics">
<!-- 동적으로 채워짐 -->
</div>
</div>
<!-- 작업 통계 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">📈</span>
<h3>작업 통계</h3>
</div>
<div class="metrics-grid" id="jobStats">
<!-- 동적으로 채워짐 -->
</div>
<div class="chart-container">
<canvas id="performanceChart" width="400" height="200"></canvas>
</div>
</div>
<!-- 활성 작업 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon"></span>
<h3>활성 작업</h3>
</div>
<div class="job-list" id="activeJobs">
<!-- 동적으로 채워짐 -->
</div>
</div>
</div>
</div>
<script>
// 대시보드 데이터 관리
let dashboardData = {};
let performanceChart = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeChart();
loadDashboardData();
// 5초마다 자동 새로고침
setInterval(loadDashboardData, 5000);
});
// 대시보드 데이터 로드
async function loadDashboardData() {
try {
const response = await fetch('/api/dashboard');
dashboardData = await response.json();
updateModelStatus();
updateSystemMetrics();
updateJobStats();
updateActiveJobs();
updatePerformanceChart();
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
}
}
// 모델 상태 업데이트
function updateModelStatus() {
const container = document.getElementById('modelStatus');
container.innerHTML = '';
if (dashboardData.models_status) {
Object.values(dashboardData.models_status).forEach(model => {
const modelItem = document.createElement('div');
modelItem.className = 'model-item';
const memoryUsage = model.memory_usage_mb ? `${model.memory_usage_mb.toFixed(0)}MB` : 'N/A';
const totalProcessed = model.total_processed || 0;
const lastUsed = model.last_used ? new Date(model.last_used).toLocaleTimeString() : 'N/A';
modelItem.innerHTML = `
<div class="model-info">
<h4>${model.name}</h4>
<div class="model-details">
메모리: ${memoryUsage} | 처리 완료: ${totalProcessed}개 | 마지막 사용: ${lastUsed}
</div>
</div>
<span class="status-badge status-${model.status}">${getStatusText(model.status)}</span>
`;
container.appendChild(modelItem);
});
}
}
// 시스템 메트릭 업데이트
function updateSystemMetrics() {
const container = document.getElementById('systemMetrics');
container.innerHTML = '';
if (dashboardData.current_metrics) {
const metrics = dashboardData.current_metrics;
const metricItems = [
{ value: `${metrics.total_memory_usage_mb.toFixed(0)}MB`, label: '메모리 사용량' },
{ value: `${metrics.cpu_usage_percent.toFixed(1)}%`, label: 'CPU 사용률' },
{ value: formatUptime(metrics.uptime_seconds), label: '서비스 가동시간' },
{ value: `${metrics.average_processing_time.toFixed(1)}`, label: '평균 처리시간' }
];
metricItems.forEach(item => {
const metricDiv = document.createElement('div');
metricDiv.className = 'metric-item';
metricDiv.innerHTML = `
<div class="metric-value">${item.value}</div>
<div class="metric-label">${item.label}</div>
`;
container.appendChild(metricDiv);
});
}
}
// 작업 통계 업데이트
function updateJobStats() {
const container = document.getElementById('jobStats');
container.innerHTML = '';
if (dashboardData.current_metrics) {
const metrics = dashboardData.current_metrics;
const jobItems = [
{ value: metrics.active_jobs, label: '진행 중인 작업' },
{ value: metrics.queued_jobs, label: '대기 중인 작업' },
{ value: metrics.completed_jobs_today, label: '오늘 완료된 작업' },
{ value: dashboardData.completed_today || 0, label: '총 완료 작업' }
];
jobItems.forEach(item => {
const jobDiv = document.createElement('div');
jobDiv.className = 'metric-item';
jobDiv.innerHTML = `
<div class="metric-value">${item.value}</div>
<div class="metric-label">${item.label}</div>
`;
container.appendChild(jobDiv);
});
}
}
// 활성 작업 업데이트
function updateActiveJobs() {
const container = document.getElementById('activeJobs');
if (dashboardData.active_jobs === 0) {
container.innerHTML = '<div style="text-align: center; color: #64748b; padding: 20px;">현재 진행 중인 작업이 없습니다.</div>';
} else {
container.innerHTML = `<div style="text-align: center; color: #2563eb; padding: 20px;">현재 ${dashboardData.active_jobs}개의 작업이 진행 중입니다.</div>`;
}
}
// 성능 차트 초기화
function initializeChart() {
const canvas = document.getElementById('performanceChart');
const ctx = canvas.getContext('2d');
// 간단한 차트 구현 (실제로는 Chart.js 등 사용 권장)
performanceChart = {
canvas: canvas,
ctx: ctx,
data: []
};
}
// 성능 차트 업데이트
function updatePerformanceChart() {
if (!performanceChart || !dashboardData.recent_metrics) return;
const ctx = performanceChart.ctx;
const canvas = performanceChart.canvas;
// 캔버스 지우기
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 간단한 메모리 사용량 그래프 그리기
const metrics = dashboardData.recent_metrics.slice(-20); // 최근 20개
if (metrics.length === 0) return;
const maxMemory = Math.max(...metrics.map(m => m.metrics.total_memory_usage_mb));
const width = canvas.width;
const height = canvas.height;
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 2;
ctx.beginPath();
metrics.forEach((metric, index) => {
const x = (index / (metrics.length - 1)) * width;
const y = height - (metric.metrics.total_memory_usage_mb / maxMemory) * height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// 레이블 추가
ctx.fillStyle = '#64748b';
ctx.font = '12px Arial';
ctx.fillText('메모리 사용량 추이', 10, 20);
ctx.fillText(`현재: ${metrics[metrics.length - 1]?.metrics.total_memory_usage_mb.toFixed(0)}MB`, 10, height - 10);
}
// 헬퍼 함수들
function getStatusText(status) {
const statusMap = {
'ready': '준비완료',
'loading': '로딩중',
'error': '오류',
'unloaded': '로드안됨'
};
return statusMap[status] || status;
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}시간 ${minutes}`;
}
// 제어 함수들
async function restartModels() {
if (confirm('AI 모델을 재시작하시겠습니까? 진행 중인 작업이 중단될 수 있습니다.')) {
try {
await fetch('/api/restart-models', { method: 'POST' });
alert('모델 재시작을 시작했습니다. 잠시 후 상태가 업데이트됩니다.');
} catch (error) {
alert('모델 재시작 실패: ' + error.message);
}
}
}
async function clearCache() {
if (confirm('시스템 캐시를 정리하시겠습니까?')) {
try {
await fetch('/api/clear-cache', { method: 'POST' });
alert('캐시 정리가 완료되었습니다.');
} catch (error) {
alert('캐시 정리 실패: ' + error.message);
}
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,468 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 번역 시스템</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: white;
padding: 40px;
text-align: center;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.nav-menu {
background: #f8fafc;
padding: 20px 40px;
border-bottom: 1px solid #e5e7eb;
}
.nav-links {
display: flex;
gap: 30px;
}
.nav-link {
color: #374151;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
}
.nav-link:hover {
color: #2563eb;
}
.nav-link.active {
color: #2563eb;
border-bottom: 2px solid #2563eb;
padding-bottom: 5px;
}
.status-bar {
background: #f0f9ff;
padding: 15px 40px;
border-bottom: 1px solid #bae6fd;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
}
.status-good { color: #065f46; }
.status-bad { color: #dc2626; }
.status-warning { color: #d97706; }
.main-content {
padding: 40px;
}
.upload-section {
text-align: center;
margin-bottom: 40px;
}
.file-drop-zone {
border: 3px dashed #e5e7eb;
border-radius: 16px;
padding: 60px 20px;
margin: 20px 0;
transition: all 0.3s ease;
cursor: pointer;
}
.file-drop-zone:hover {
border-color: #2563eb;
background: #f8fafc;
}
.file-drop-zone.drag-over {
border-color: #2563eb;
background: #eff6ff;
}
.upload-icon {
font-size: 4rem;
color: #9ca3af;
margin-bottom: 20px;
}
.upload-text {
font-size: 1.2rem;
color: #374151;
margin-bottom: 10px;
}
.upload-hint {
color: #6b7280;
font-size: 0.9rem;
}
.file-input {
display: none;
}
.upload-btn {
background: #2563eb;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
margin-top: 20px;
transition: background 0.3s ease;
}
.upload-btn:hover {
background: #1d4ed8;
}
.upload-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.progress-section {
display: none;
background: #f8fafc;
border-radius: 12px;
padding: 30px;
margin: 20px 0;
}
.progress-title {
font-weight: 600;
margin-bottom: 15px;
color: #374151;
}
.progress-bar {
width: 100%;
height: 12px;
background: #e5e7eb;
border-radius: 6px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
width: 0%;
transition: width 0.5s ease;
}
.progress-text {
font-size: 0.9rem;
color: #6b7280;
}
.result-section {
display: none;
background: #f0f9ff;
border: 1px solid #0ea5e9;
border-radius: 12px;
padding: 30px;
margin: 20px 0;
}
.result-title {
color: #0369a1;
font-weight: 600;
margin-bottom: 15px;
}
.result-actions {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
border: none;
cursor: pointer;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #f8fafc;
color: #374151;
border: 1px solid #e5e7eb;
}
.btn-secondary:hover {
background: #f1f5f9;
}
.error-section {
display: none;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 20px;
margin: 20px 0;
color: #dc2626;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 AI 번역 시스템</h1>
<p>PDF/문서를 다국어 HTML로 자동 변환</p>
</div>
<div class="nav-menu">
<div class="nav-links">
<a href="/" class="nav-link active">📤 업로드</a>
<a href="/library" class="nav-link">📚 라이브러리</a>
<a href="/system-status" class="nav-link">📊 시스템</a>
</div>
</div>
<div class="status-bar">
<div>
{% if nas_status.status == "connected" %}
<span class="status-good">✅ NAS 연결됨 (DS1525+)</span>
{% else %}
<span class="status-bad">❌ NAS 연결 실패: {{ nas_status.error }}</span>
{% endif %}
</div>
<div>
<span class="status-good">🚀 Mac Mini Ready</span>
</div>
</div>
<div class="main-content">
{% if nas_status.status != "connected" %}
<div class="error-section" style="display: block;">
<h3>NAS 연결 필요</h3>
<p>{{ nas_status.error }}</p>
<p><strong>해결 방법:</strong></p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li>Finder → 이동 → 서버에 연결</li>
<li>smb://192.168.1.227 입력</li>
<li>DS1525+ 연결 후 페이지 새로고침</li>
</ul>
</div>
{% endif %}
<div class="upload-section">
<div class="file-drop-zone" id="dropZone">
<div class="upload-icon">📁</div>
<div class="upload-text">파일을 드래그하거나 클릭하여 업로드</div>
<div class="upload-hint">PDF, TXT, DOCX 파일 지원 (최대 100MB)</div>
<input type="file" id="fileInput" class="file-input" accept=".pdf,.txt,.docx,.doc">
<button type="button" class="upload-btn" id="uploadBtn"
{% if nas_status.status != "connected" %}disabled{% endif %}
onclick="document.getElementById('fileInput').click()">
{% if nas_status.status == "connected" %}
파일 선택
{% else %}
NAS 연결 필요
{% endif %}
</button>
</div>
</div>
<div class="progress-section" id="progressSection">
<div class="progress-title">처리 진행 상황</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">준비 중...</div>
</div>
<div class="result-section" id="resultSection">
<div class="result-title">🎉 변환 완료!</div>
<div class="result-actions">
<a href="#" id="downloadBtn" class="btn btn-primary">📥 HTML 다운로드</a>
<a href="#" id="previewBtn" class="btn btn-secondary" target="_blank">👁️ 미리보기</a>
<a href="/library" class="btn btn-secondary">📚 라이브러리 보기</a>
</div>
</div>
</div>
</div>
<script>
let currentJobId = null;
const nasConnected = {{ 'true' if nas_status.status == 'connected' else 'false' }};
// 파일 드래그 앤 드롭
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
if (nasConnected) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
});
dropZone.addEventListener('click', () => {
if (!uploadBtn.disabled) {
fileInput.click();
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
});
}
async function handleFileUpload(file) {
if (!nasConnected) {
alert('NAS 연결이 필요합니다.');
return;
}
// 파일 크기 검증 (100MB)
if (file.size > 100 * 1024 * 1024) {
alert('파일 크기는 100MB를 초과할 수 없습니다.');
return;
}
// 파일 형식 검증
const allowedTypes = ['.pdf', '.txt', '.docx', '.doc'];
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedTypes.includes(fileExt)) {
alert('지원되지 않는 파일 형식입니다. PDF, TXT, DOCX 파일만 업로드 가능합니다.');
return;
}
// UI 업데이트
document.getElementById('progressSection').style.display = 'block';
document.getElementById('resultSection').style.display = 'none';
uploadBtn.disabled = true;
// 파일 업로드
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '업로드 실패');
}
const result = await response.json();
currentJobId = result.job_id;
// 진행 상황 모니터링 시작
monitorProgress();
} catch (error) {
alert('업로드 실패: ' + error.message);
document.getElementById('progressSection').style.display = 'none';
uploadBtn.disabled = false;
}
}
async function monitorProgress() {
if (!currentJobId) return;
try {
const response = await fetch(`/status/${currentJobId}`);
const status = await response.json();
// 진행률 업데이트
document.getElementById('progressFill').style.width = status.progress + '%';
document.getElementById('progressText').textContent = status.message;
if (status.status === 'completed') {
// 완료 처리
document.getElementById('progressSection').style.display = 'none';
document.getElementById('resultSection').style.display = 'block';
// 다운로드 링크 설정
document.getElementById('downloadBtn').href = `/download/${currentJobId}`;
uploadBtn.disabled = false;
} else if (status.status === 'error') {
alert('처리 실패: ' + status.message);
document.getElementById('progressSection').style.display = 'none';
uploadBtn.disabled = false;
} else {
// 계속 모니터링
setTimeout(monitorProgress, 2000); // 2초마다 확인
}
} catch (error) {
console.error('상태 확인 오류:', error);
setTimeout(monitorProgress, 5000); // 오류시 5초 후 재시도
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문서 라이브러리 - AI 번역 시스템</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: white;
padding: 40px;
text-align: center;
}
.header h1 { font-size: 2.5rem; margin-bottom: 10px; }
.header p { font-size: 1.1rem; opacity: 0.9; }
.nav-menu {
background: #f8fafc;
padding: 20px 40px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-links { display: flex; gap: 30px; }
.nav-link {
color: #374151;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
}
.nav-link:hover { color: #2563eb; }
.nav-link.active {
color: #2563eb;
border-bottom: 2px solid #2563eb;
padding-bottom: 5px;
}
.stats-bar {
background: #f0f9ff;
padding: 15px 40px;
border-bottom: 1px solid #bae6fd;
display: flex;
gap: 30px;
font-size: 0.9rem;
color: #0369a1;
}
.stat-item { display: flex; align-items: center; gap: 8px; }
.main-content { padding: 40px; }
.search-section {
margin-bottom: 30px;
display: flex;
gap: 15px;
align-items: center;
}
.search-box {
flex: 1;
padding: 15px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
}
.filter-select {
padding: 15px;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
min-width: 200px;
}
.category-section {
margin-bottom: 40px;
background: #f8fafc;
border-radius: 16px;
overflow: hidden;
}
.category-header {
background: linear-gradient(135deg, #1e40af, #2563eb);
color: white;
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.category-title { font-size: 1.3rem; font-weight: 600; }
.category-count {
background: rgba(255,255,255,0.2);
padding: 5px 12px;
border-radius: 20px;
font-size: 0.9rem;
}
.documents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
padding: 30px;
}
.document-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 24px;
transition: all 0.3s ease;
cursor: pointer;
}
.document-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.1);
border-color: #2563eb;
}
.document-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
line-height: 1.4;
}
.document-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
font-size: 0.85rem;
color: #6b7280;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.language-badge {
display: inline-block;
padding: 4px 8px;
background: #eff6ff;
color: #1d4ed8;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
margin-bottom: 12px;
}
.document-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 16px;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.3s ease;
border: none;
cursor: pointer;
flex: 1;
text-align: center;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary {
background: #f8fafc;
color: #374151;
border: 1px solid #e5e7eb;
}
.btn-secondary:hover { background: #f1f5f9; }
.empty-state {
text-align: center;
padding: 80px 20px;
color: #6b7280;
}
.empty-icon { font-size: 4rem; margin-bottom: 20px; }
.empty-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 10px;
color: #374151;
}
@media (max-width: 768px) {
.documents-grid { grid-template-columns: 1fr; }
.nav-menu { flex-direction: column; gap: 20px; }
.stats-bar { flex-wrap: wrap; }
.search-section { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 문서 라이브러리</h1>
<p>체계적으로 분류된 다국어 번역 문서</p>
</div>
<div class="nav-menu">
<div class="nav-links">
<a href="/" class="nav-link">📤 업로드</a>
<a href="/library" class="nav-link active">📚 라이브러리</a>
<a href="/system-status" class="nav-link">📊 시스템</a>
</div>
</div>
<div class="stats-bar">
<div class="stat-item">
<span>📄</span>
<span>총 {{ total_documents }}개 문서</span>
</div>
<div class="stat-item">
<span>🗂️</span>
<span>{{ categories|length }}개 카테고리</span>
</div>
<div class="stat-item">
<span>🌍</span>
<span>다국어 지원</span>
</div>
</div>
<div class="main-content">
<div class="search-section">
<input type="text" class="search-box" placeholder="🔍 문서 제목으로 검색..." id="searchInput">
<select class="filter-select" id="categoryFilter">
<option value="">모든 카테고리</option>
{% for category_name in categories.keys() %}
<option value="{{ category_name }}">{{ category_name|replace("_", " ")|title }}</option>
{% endfor %}
</select>
</div>
{% if categories %}
{% for category_name, documents in categories.items() %}
<div class="category-section" data-category="{{ category_name }}">
<div class="category-header">
<div class="category-title">
{% if category_name == "English To Korean" %}
🇺🇸→🇰🇷 English to Korean
{% elif category_name == "Japanese To Korean" %}
🇯🇵→🇰🇷 Japanese to Korean
{% elif category_name == "Korean Only" %}
🇰🇷 Korean Documents
{% else %}
{{ category_name }}
{% endif %}
</div>
<div class="category-count">{{ documents|length }}개</div>
</div>
<div class="documents-grid">
{% for doc in documents %}
<div class="document-card" data-title="{{ doc.name.lower() }}" data-category="{{ category_name }}">
<div class="language-badge">{{ doc.language_display }}</div>
<div class="document-title">{{ doc.name }}</div>
<div class="document-meta">
<div class="meta-item">
<span>📅</span>
<span>{{ doc.created }}</span>
</div>
<div class="meta-item">
<span>📊</span>
<span>{{ doc.size }}</span>
</div>
<div class="meta-item">
<span>📁</span>
<span>{{ doc.month }}</span>
</div>
<div class="meta-item">
<span>🌐</span>
<span>{{ doc.language_type.replace("-", " ")|title }}</span>
</div>
</div>
<div class="document-actions">
<a href="{{ doc.url }}" class="btn btn-primary" target="_blank">👁️ 보기</a>
<a href="{{ doc.url }}" class="btn btn-secondary" download="{{ doc.filename }}">📥 다운로드</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-icon">📚</div>
<div class="empty-title">아직 변환된 문서가 없습니다</div>
<div class="empty-text">PDF나 텍스트 파일을 업로드하여 다국어 문서를 만들어보세요!</div>
<a href="/" class="btn btn-primary">📤 첫 번째 문서 업로드</a>
</div>
{% endif %}
</div>
</div>
<script>
// 검색 및 필터링
const searchInput = document.getElementById('searchInput');
const categoryFilter = document.getElementById('categoryFilter');
function filterDocuments() {
const query = searchInput.value.toLowerCase();
const selectedCategory = categoryFilter.value;
// 모든 카테고리 섹션 처리
document.querySelectorAll('.category-section').forEach(section => {
const categoryName = section.dataset.category;
let hasVisibleCards = false;
// 카테고리 필터 확인
if (selectedCategory && categoryName !== selectedCategory) {
section.style.display = 'none';
return;
}
// 카드 필터링
section.querySelectorAll('.document-card').forEach(card => {
const title = card.dataset.title;
const matchesSearch = !query || title.includes(query);
if (matchesSearch) {
card.style.display = 'block';
hasVisibleCards = true;
} else {
card.style.display = 'none';
}
});
// 카테고리 섹션 표시/숨김
section.style.display = hasVisibleCards ? 'block' : 'none';
});
}
searchInput.addEventListener('input', filterDocuments);
categoryFilter.addEventListener('change', filterDocuments);
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<?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>com.nllb-translation-system.app</string>
<!-- 실행할 명령어와 인자들 -->
<key>ProgramArguments</key>
<array>
<!-- 1. 가상환경의 파이썬 실행 파일 경로 (절대 경로) -->
<string>/Users/hyungi/Scripts/nllb-translation-system/nllb_env/bin/python</string>
<!-- 2. 실행할 파이썬 스크립트 경로 (절대 경로) -->
<string>/Users/hyungi/Scripts/nllb-translation-system/src/fastapi_with_dashboard.py</string>
</array>
<!-- 스크립트의 작업 디렉토리 설정 (매우 중요) -->
<key>WorkingDirectory</key>
<string>/Users/hyungi/Scripts/nllb-translation-system</string>
<!-- 서비스를 로드할 때 바로 실행 -->
<key>RunAtLoad</key>
<true/>
<!-- 프로세스가 종료되면 자동으로 다시 시작 (무중단 운영의 핵심) -->
<key>KeepAlive</key>
<true/>
<!-- 표준 출력 로그 파일 경로 -->
<key>StandardOutPath</key>
<string>/Users/hyungi/Scripts/nllb-translation-system/logs/service.log</string>
<!-- 표준 에러 로그 파일 경로 -->
<key>StandardErrorPath</key>
<string>/Users/hyungi/Scripts/nllb-translation-system/logs/service_error.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,493 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 Industrial Safety and Health Management(7:ED)_0 Contents-2025-08-13 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 Industrial Safety and Health Management(7:ED)_0 Contents-2025-08-13 (원문)</h1>
<div class="chunk" id="c0">Industrial Safety and Health
Management
Seventh Edition
C. Ray Asfahl
David W. Rieske
University of Arkansas
Pearson
33o Hudson Street, NY NY 10013</div>
<div class="chunk" id="c1">
Senior Vice President Courseware Portfolio Management: Marcia Horton
Director, Portfolio Management: Engineering, Computer Science & Global Editions: Julian Partridge
Specialist, Higher Ed Portfolio Management: Holly Stark
Portfolio Management Assistant: Emily Egan
Managing Content Producer: Scott Disanno
Content Producer: Carole Snyder
Web Developer: Steve Wright
Rights and Permissions Manager: Ben Ferrini
Manufacturing Buyer, Higher Ed, Lake Side Communications Inc (LSC): Maura Zaldivar-Garcia
Inventory Manager: Ann Lam
Product Marketing Manager: Yvonne Vannatta
Field Marketing Manager: Demetrius Hall
Marketing Assistant: Jon Bryant
Cover Designer: Black Horse Designs
Full-Service Project Manager: Billu Suresh, SPi Global
Composition: SPi Global
Copyright © 2019 Pearson Education, Inc. All rights reserved. Manufactured in the United States of America. This
publication is protected by copyright, and permission should be obtained from the publisher prior to any prohibited
reproduction, storage in a retrieval system, or transmissio</div>
<div class="chunk" id="c2">tates of America. This
publication is protected by copyright, and permission should be obtained from the publisher prior to any prohibited
reproduction, storage in a retrieval system, or transmission in any form or by any means, electronic, mechanical,
photocopying, recording, or likewise. For information regarding permissions, request forms and the appropriate
contacts within the Pearson Education Global Rights & Permissions department, please visit http://www.pearsoned
.compermissions.
Many of the designations by manufacturers and sellers to distinguish their products are claimed as trademarks. Where
those designations appear in this book, and the publisher was aware of a trademark claim, the designations have been
printed in initial caps or all caps.
The author and publisher of this book have used their best efforts in preparing this book. These efforts include the
development, research, and testing of theories and programs to determine their effectiveness. The author and publisher
make no warranty of any kind, expressed or implied, with regard to these programs or the documentation contained
in this book. The author and publisher shall not be liable in any event for</div>
<div class="chunk" id="c3">ublisher
make no warranty of any kind, expressed or implied, with regard to these programs or the documentation contained
in this book. The author and publisher shall not be liable in any event for incidental or consequential damages with, or
arising out of, the furnishing, performance, or use of these programs.
Library of Congress Cataioging in-Publication Data
Names: Asfahl, C. Ray, 1938- author. Rieske, David W., author.
Title: Industrial safety and health management C. Ray Asfahl, David W.
Rieske, University of Arkansas.
Description: Seventh edition. NY, NY : Pearson, [2019]
Includes bibliographical references and index.
Identifiers: LCCN 2017050947 ISBN 9780134630564 (alk. paper)
ISBN 0134630564 (alk. paper)
Subjects: LCSH: Industrial safety. Industrial hygiene.
Classification: LCC T55 .A83 2019 DDC 658.408--dc23 LC record available at https://lccn.loc.gov/2017050947
Pearson ISBN-13: 978-0-13-463056-4
ISBN-10: 0-13-463056-4
17 2024</div>
<div class="chunk" id="c4">
Contents
Preface ix
CHAPTER 1 The Safety and Health Manager 1
A Reasonable Objective 2
Safety versus Health 4
Role in the Corporate Structure 5
Resources at Hand 6
Summary 12
Exercises and Study Questions 12
Research Exercises</div>
<div class="chunk" id="c5">1 The Safety and Health Manager 1
A Reasonable Objective 2
Safety versus Health 4
Role in the Corporate Structure 5
Resources at Hand 6
Summary 12
Exercises and Study Questions 12
Research Exercises 13
CHAPTER 2 Development of the Safety and Health Function 15
Workers Compensation 16
Recordkeeping 21
Accident Cause Analysis 35
Organization of Committees 36
Safety and Health Economics 37
Training 41
Job Placement Testing 43
The Smoke-Free Workplace 44
Bloodborne Pathogens 45
Workplace Violence 47
Summary 48
Exercises and Study Questions 49
Research Exercises 53
CHAPTER Z Concepts of Hazard Avoidance 54
The Enforcement Approach 55
The Psychological Approach 57
The Engineering Approach 59
The Analytical Approach 67
Hazard-Classification Scale 76
Summary 82
Exercises and Study Questions 83
Research Exercises 86
Standards Research Questions 87
iii</div>
<div class="chunk" id="c6">
iv Contents
CHAPTER 4 Impact of Federal Regulation 88
Standards 88
NIOSH 93
Enforcement 94
Public Uproar IOO
Role of the States 102
Political Trends 104
Immigrant Workers 111
Summary 111
Exercises and Study Questions 112
Research Exercises 113
Standards Research Questions 114
CHAPTER 5 Information Systems 115
Hazard Communication 116
Inter</div>
<div class="chunk" id="c7">Trends 104
Immigrant Workers 111
Summary 111
Exercises and Study Questions 112
Research Exercises 113
Standards Research Questions 114
CHAPTER 5 Information Systems 115
Hazard Communication 116
International Standards 123
Environmental Protection Agency 123
Department of Homeland Security 128
Computer Information Systems 129
Summary 131
Exercises and Study Questions 131
Research Exercises 132
Standards Research Questions 133
CHAPTER 6 Process Safety and Disaster Preparedness 134
Process Information 135
Process Analysis 139
Operating Procedures 140
Training 141
Contractor Personnel 142
Acts of Terrorism 142
Workplace Security 145
Active Shooter Incidents 146
Summary 146
Exercises and Study Questions 147
Research Exercises 148
Standards Research Questions 148
CHAPTER 7 Buildings and Facilities 150
Walking and Working Surfaces 151
Exits 162
Illumination 164
Miscellaneous Facilities 165
Sanitation 169
Summary 169</div>
<div class="chunk" id="c8">
Contents v
Exercises and Study Questions 170
Research Exercises 171
Standards Research Questions 171
CHAPTER 8 Ergonomics 172
Facets of Ergonomics 172
Workplace Musculoskeletal Disorders 176
Affected Industries 179
Ergonomics Standards 179
WMSD Management Programs 182
Ergon</div>
<div class="chunk" id="c9">rds Research Questions 171
CHAPTER 8 Ergonomics 172
Facets of Ergonomics 172
Workplace Musculoskeletal Disorders 176
Affected Industries 179
Ergonomics Standards 179
WMSD Management Programs 182
Ergonomic Risk Analysis 184
NIOSH Lifting Equation 185
Sources of Ergonomic Hazards 193
Summary 202
Exercises and Study Questions 203
Research Exercises 204
Standards Research Question 205
CHAPTER 9 Health and Toxic Substances 206
Baseline Examinations 206
Toxic Substances 207
Measures of Exposure 216
Standards Completion Project 220
Detecting Contaminants 222
Summary 229
Exercises and Study Questions 230
Research Exercises 234
Standards Research Questions 235
CHAPTER 10 Environmental Control and Noise 236
Ventilation 236
ASHRAE Standards and Indoor Air Quality 242
Industrial Noise 243
Radiation 260
Summary 260
Exercises and Study Questions 261
Research Exercises 265
Standards Research Questions 265
CHAPTER 11 Flammable and Explosive Materials 267
Flammable Liquids 267
Sources of Ignition 272
Standards Compliance 274
Combustible Liquids 276
Spray Finishing 278</div>
<div class="chunk" id="c10">
vi Contents
Dip Tanks 281
Explosives 281
Liquefied Petroleum Gas 282
Combustible Dust 284
Conclusion 285
Exercises and Study Quest</div>
<div class="chunk" id="c11">tandards Compliance 274
Combustible Liquids 276
Spray Finishing 278</div>
<div class="chunk" id="c12">
vi Contents
Dip Tanks 281
Explosives 281
Liquefied Petroleum Gas 282
Combustible Dust 284
Conclusion 285
Exercises and Study Questions 285
Research Exercises 287
Standards Research Questions 288
CHAPTER 12 Personal Protection and First Aid 289
Protection Need Assessment 290
Personal Protective Equipment (PPE) Training 291
Hearing Protection 292
Determining the Noise Reduction Rating 293
Eye and Face Protection 294
Respiratory Protection 296
Confined Space Entry 309
Head Protection 312
Miscellaneous Personal Protective Equipment 313
First Aid 315
Summary 316
Exercises and Study Questions 317
Research Exercises 319
Standards Research Questions 320
CHAPTER 13 Fire Protection 321
Mechanics of Fire 322
Industrial Fires 322
Fire Prevention 323
Dust Explosions 323
Emergency Evacuation 324
Fire Brigades 326
Fire Extinguishers 327
Standpipe and Hose Systems 329
Automatic Sprinkler Systems 330
Fixed Extinguishing Systems 330
Summary 331
Exercises and Study Questions 332
Research Exercises 334
Standards Research Questions 334
CHAPTER 14 Materials Handling and Storage 335
Materials Storage 336
Industrial Trucks 337
Passenger</div>
<div class="chunk" id="c13">ummary 331
Exercises and Study Questions 332
Research Exercises 334
Standards Research Questions 334
CHAPTER 14 Materials Handling and Storage 335
Materials Storage 336
Industrial Trucks 337
Passengers 343
Cranes 344</div>
<div class="chunk" id="c14">
Contents vii
Slings 358
Conveyors 362
Lifting 363
Summary 365
Exercises and Study Questions 365
Research Exercise 368
CHAPTER 15 Machine Guarding 369
General Machine Guarding 369
Safeguarding the Point of Operation 379
Power Presses 386
Heat Processes 406
Grinding Machines 406
Saws 408
Miscellaneous Machine Guarding 413
Miscellaneous Machines and Processes 416
Industrial Robots 417
Evolution in Robotics and Intelligent Machines 420
Summary 421
Exercises and Study Questions 422
Standards Research Questions 425
CHAPTER 16 Welding 426
Process Terminology 426
Gas Welding Hazards 430
Arc Welding Hazards 437
Resistance Welding Hazards 438
Fires and Explosions 439
Eye Protection 441
Protective Clothing 442
Gases and Fumes 443
Summary 446
Exercises and Study Questions 447
Research Exercises 449
Standards Research Questions 450
CHAPTER 17 Electrical Hazards 451
Electrocution Hazards 451
Fire Hazards 464
Arc Flash 469
Test Equipment 471
Exposure to High-Voltage Power Lines 473</div>
<div class="chunk" id="c15">ch Exercises 449
Standards Research Questions 450
CHAPTER 17 Electrical Hazards 451
Electrocution Hazards 451
Fire Hazards 464
Arc Flash 469
Test Equipment 471
Exposure to High-Voltage Power Lines 473
Frequent Violations 473
Summary 474
Exercises and Study Questions 475</div>
<div class="chunk" id="c16">
viii Contents
Research Exercises 478
Standards Research Questions 478
CHAPTER 18 Construction 479
General Facilities 480
Personal Protective Equipment 482
Fire Protection 486
Tools 486
Electrical 488
Ladders and Scaffolds 490
Floors and Stairways 493
Cranes and Hoists 493
Heavy Vehicles and Equipment 498
ROPS 498
Trenching and Excavations 501
Concrete Work 505
Steel Erection 507
Demolition 508
Explosive Blasting 509
Electric Utilities 510
Summary 511
Exercises and Study Questions 512
Research Exercises 515
APPENDICES
A OSHA Permissible Exposure Limits 516
B Medical Treatment 535
C First-Aid Treatment 536
D Classification of Medical Treatment 538
E Highly Hazardous Chemicals, Toxics, and Reactives 540
F North American Industry Classification System (NAICS) Code 544
G States Having Federally Approved State Plans for
Occupational Safety and Health Standards and Enforcement 548
Bibliography 549
Glossary 560
Index 568</div>
<div class="chunk" id="c17">Industry Classification System (NAICS) Code 544
G States Having Federally Approved State Plans for
Occupational Safety and Health Standards and Enforcement 548
Bibliography 549
Glossary 560
Index 568</div>
<div class="chunk" id="c18">
Preface
The seventh edition of Industrial Safety and Health Management remains true to
the purpose of engaging the reader in the common sense approaches to safety and
health from a concept, process, and compliance perspective. The book retains its
easy-to-read format while increasing the retention of the reader through additional
case studies and statistics, relevant topics, and additional explanation of difficult-to-
Understand concepts.
Much of the safety change we see comes on the heels of major disasters or
social trends and changes. The past decade has seen many. The explosion of a major
sugar processing plant has driven a renewed focus on combustible dust, an outbreak
of Ebola brought focus on contagious diseases, the sinking of a major oil derrick
initiated a discussion on regulatory oversight and process health, and numerous
acts of violence bring our attention to security in the workplace. Social trends such
as the rise of "gig" or "on-demand" employment have brou</div>
<div class="chunk" id="c19">n regulatory oversight and process health, and numerous
acts of violence bring our attention to security in the workplace. Social trends such
as the rise of "gig" or "on-demand" employment have brought about questions
of the definition of an "employee" and coverage for safety nets such as workers
compensation. Regulatory changes have even precipitated the complete removal of
workers compensation in some states. In other areas, the effectiveness of workers
compensation led to a robust dialog on whether or not a permanently injured em­
ployee truly receives compensation commensurate to his or her injury. Meanwhile
rises in the number of states legalizing marijuana have caused companies to ques­
tion current drug screening programs and medical treatment programs.
Regulation has changed as well. The adoption of the Globally Harmonized System
for Hazard Communication or GHS has completely changed the way we think about
hazard communication. The new system crosses language barriers and helps workers
who may not be able to read or may not be fluent in a given language with a series of
pictograms depicting the dangers of certain chemicals. Hazards are now categorized
in a st</div>
<div class="chunk" id="c20">rs and helps workers
who may not be able to read or may not be fluent in a given language with a series of
pictograms depicting the dangers of certain chemicals. Hazards are now categorized
in a standard way which drives increased consistency of approach. For the first time in
nearly 20 years, fines associated with citations have gone up considerably. Meanwhile,
record fines have been levied against corporations associated with major disasters. The
classification of companies has also been changed to the modernized North America
Industry Classification System (NAICS).
As the authors have used the text in their classrooms, a critical focus has been
on addressing the most common areas that students will be expected to apply in
an industrial setting. Additional explanation around the concepts of PELs has been
given to help students to understand the differences among PELS, Ceilings, and
other measures. Calculations around the Noise Reduction Rating (NRR) and how it
is practically used will help students address the prevalent danger of industrial noise
in their work environments. In addition, explained in more detail is sometimes the
confusing concept of applying workers</div>
<div class="chunk" id="c21">ally used will help students address the prevalent danger of industrial noise
in their work environments. In addition, explained in more detail is sometimes the
confusing concept of applying workers compensation and practical aspects of pro­
tecting employees.</div>
<div class="chunk" id="c22">
X Preface
WHAT'S NEW IN THIS EDITION?
For easy reference, the authors have summarized the new features of this edition as
follows:
• Overhaul of hazard communication standard and incorporation of the Globally
Harmonized System
• Increased discussion on workers compensation rates and calculations
• Trends in workers compensation privatization and states “opting-out”
• Layers of coverage for permanent injuries
• Coverage of the trends in the gig economy and the changing nature of
employees
• OSHA usage of reporting in “Big Data”
• Changes in SIC to North American Industry Classification System (NAICS)
• Discussion of bloodborne pathogens and protecting workers from diseases such
as HIV and Ebola
• Increased coverage of workplace security
• Discussion of preparation and response techniques for active shooter scenarios
• Impact of medical marijuana
• Changes in OSHA citation penalty levels
• Increased coverage of Targe</div>
<div class="chunk" id="c23">orkplace security
• Discussion of preparation and response techniques for active shooter scenarios
• Impact of medical marijuana
• Changes in OSHA citation penalty levels
• Increased coverage of Target Industry programs
• Coverage of fatigue and worker safety
• Practical discussion of PELs, STELs, Ceiling Limits and how they interact
• Changes to flammable liquid classification
• Coverage of calculations and usage of Noise Reduction Rating (NRR)
• Coverage of long-term health impact to World Trade Center first responders
• OSHA s work against the dangers of combustible dust
• Additional practical and pragmatic assessment of penalty levels
• Additional review of OSHA programs such as SHARP and VPP as OSHA is
increasing its collaborative approach in recent years
• Additional case studies to bring home to readers about the concepts of safety and
health</div>
<div class="chunk" id="c24">
Preface xi
ACKNOWLEDGMENTS
Both authors wish to express their appreciation to companies and individuals who have
contributed ideas and support for the seventh edition. Special thanks to Richard
Wallace, Jimmy Baker, and the entire team at Pratt & Whitney for ideas, pictures,
and best practices from their world-class facility. And</div>
<div class="chunk" id="c25">d support for the seventh edition. Special thanks to Richard
Wallace, Jimmy Baker, and the entire team at Pratt & Whitney for ideas, pictures,
and best practices from their world-class facility. Andrew Hilliard, President of Safety
Maker, Inc. and E.C. Daven, President of Safety Services, Inc. provided valuable insights
and visual examples. Erica Asfahl provided mechanical engineering advice. David Trigg
and David Bryan answered questions and provided data on OSHA developments.
We are grateful to Ken Kolosh and the team at the National Safety Council for their
statistics provided in many areas of the text. Tara Mercer and the National Council on
Compensation Insurance shared valuable insights into trends and developments such
as the gig economy and the impact of medical marijuana. We learned from Alejandra
Nolibos about developments in state workers compensation changes. Finally, we
dedicate this edition to our patient and supportive families who have endured the
process of bringing forth this seventh edition.
C. Ray Asfahl
David W. Rieske</div>
</article>
</body>
</html>

20
outputs/html/test.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 test-doc-001 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 test-doc-001 (원문)</h1>
<div class="chunk" id="c0">테스트 문서입니다.</div>
</article>
</body>
</html>

20
outputs/html/ui-test.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 ui-test-001 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 ui-test-001 (원문)</h1>
<div class="chunk" id="c0">UI 테스트용 문서입니다. 웹 인터페이스를 통한 업로드 테스트.</div>
</article>
</body>
</html>

View File

@@ -4,3 +4,5 @@ requests==2.32.4
pydantic==2.8.2
pypdf==6.0.0
tiktoken==0.11.0
python-multipart
jinja2

54
scripts/install_launchd.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
LABEL="net.hyungi.ai-server"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
WORKDIR="$(pwd)"
# load .env if present
if [ -f "$WORKDIR/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$WORKDIR/.env"
set +a
fi
cat > "$PLIST" <<PLIST
<?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>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${WORKDIR}/.venv/bin/uvicorn</string>
<string>server.main:app</string>
<string>--host</string><string>0.0.0.0</string>
<string>--port</string><string>${AI_SERVER_PORT:-26000}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>OLLAMA_HOST</key><string>${OLLAMA_HOST:-http://localhost:11434}</string>
<key>BASE_MODEL</key><string>${BASE_MODEL:-qwen2.5:7b-instruct}</string>
<key>BOOST_MODEL</key><string>${BOOST_MODEL:-qwen2.5:14b-instruct}</string>
<key>ENGLISH_MODEL</key><string>${ENGLISH_MODEL:-llama3:8b-instruct}</string>
<key>ENGLISH_RATIO_THRESHOLD</key><string>${ENGLISH_RATIO_THRESHOLD:-0.65}</string>
<key>EMBEDDING_MODEL</key><string>${EMBEDDING_MODEL:-bge-m3}</string>
<key>INDEX_PATH</key><string>${INDEX_PATH:-data/index.jsonl}</string>
<key>API_KEY</key><string>${API_KEY:-}</string>
<key>CORS_ORIGINS</key><string>${CORS_ORIGINS:-}</string>
<key>PAPERLESS_BASE_URL</key><string>${PAPERLESS_BASE_URL:-}</string>
<key>PAPERLESS_TOKEN</key><string>${PAPERLESS_TOKEN:-}</string>
</dict>
<key>WorkingDirectory</key><string>${WORKDIR}</string>
<key>StandardOutPath</key><string>${WORKDIR}/ai-server.out.log</string>
<key>StandardErrorPath</key><string>${WORKDIR}/ai-server.err.log</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>
PLIST
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load -w "$PLIST"
echo "[ok] launchd agent installed: $PLIST"

229
server/auth.py Normal file
View File

@@ -0,0 +1,229 @@
"""
JWT Authentication System for AI Server Admin
Phase 3: Security Enhancement
"""
import jwt
import bcrypt
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import os
# JWT Configuration
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24
JWT_REMEMBER_DAYS = 30
# Security
security = HTTPBearer()
# In-memory user store (in production, use a proper database)
USERS_DB = {
"admin": {
"username": "admin",
"password_hash": bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "admin",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_attempts": 0,
"locked_until": None
},
"hyungi": {
"username": "hyungi",
"password_hash": bcrypt.hashpw("hyungi123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "system",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_attempts": 0,
"locked_until": None
}
}
# Login attempt tracking
LOGIN_ATTEMPTS = {}
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
class AuthManager:
@staticmethod
def hash_password(password: str) -> str:
"""Hash password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""Verify password against hash"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
@staticmethod
def create_jwt_token(user_data: Dict[str, Any], remember_me: bool = False) -> str:
"""Create JWT token"""
expiration = datetime.utcnow() + timedelta(
days=JWT_REMEMBER_DAYS if remember_me else 0,
hours=JWT_EXPIRATION_HOURS if not remember_me else 0
)
payload = {
"username": user_data["username"],
"role": user_data["role"],
"exp": expiration,
"iat": datetime.utcnow(),
"type": "remember" if remember_me else "session"
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
@staticmethod
def verify_jwt_token(token: str) -> Dict[str, Any]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@staticmethod
def is_account_locked(username: str) -> bool:
"""Check if account is locked due to failed attempts"""
user = USERS_DB.get(username)
if not user:
return False
if user["locked_until"]:
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now() < locked_until:
return True
else:
# Unlock account
user["locked_until"] = None
user["login_attempts"] = 0
return False
@staticmethod
def record_login_attempt(username: str, success: bool, ip_address: str = None):
"""Record login attempt"""
user = USERS_DB.get(username)
if not user:
return
if success:
user["login_attempts"] = 0
user["locked_until"] = None
user["last_login"] = datetime.now().isoformat()
else:
user["login_attempts"] += 1
# Lock account after max attempts
if user["login_attempts"] >= MAX_LOGIN_ATTEMPTS:
user["locked_until"] = (
datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
).isoformat()
@staticmethod
def authenticate_user(username: str, password: str) -> Optional[Dict[str, Any]]:
"""Authenticate user credentials"""
user = USERS_DB.get(username)
if not user:
return None
if AuthManager.is_account_locked(username):
raise HTTPException(
status_code=423,
detail=f"Account locked due to too many failed attempts. Try again later."
)
if AuthManager.verify_password(password, user["password_hash"]):
return user
return None
# Dependency functions
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Get current authenticated user from JWT token"""
try:
payload = AuthManager.verify_jwt_token(credentials.credentials)
username = payload.get("username")
user = USERS_DB.get(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"username": user["username"],
"role": user["role"],
"token_type": payload.get("type", "session")
}
except Exception as e:
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
async def require_admin_role(current_user: dict = Depends(get_current_user)):
"""Require admin or system role"""
if current_user["role"] not in ["admin", "system"]:
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
async def require_system_role(current_user: dict = Depends(get_current_user)):
"""Require system role"""
if current_user["role"] != "system":
raise HTTPException(status_code=403, detail="System privileges required")
return current_user
# Legacy API key support (for backward compatibility)
async def get_current_user_or_api_key(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
x_api_key: Optional[str] = None
):
"""Support both JWT and API key authentication"""
# Try JWT first
if credentials:
try:
return await get_current_user(credentials)
except HTTPException:
pass
# Fall back to API key
api_key = x_api_key or request.headers.get("X-API-Key")
if api_key and api_key == os.getenv("API_KEY", "test-admin-key-123"):
return {
"username": "api_user",
"role": "system",
"token_type": "api_key"
}
raise HTTPException(status_code=401, detail="Authentication required")
# Audit logging
class AuditLogger:
@staticmethod
def log_login(username: str, success: bool, ip_address: str = None, user_agent: str = None):
"""Log login attempt"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": "login_attempt",
"username": username,
"success": success,
"ip_address": ip_address,
"user_agent": user_agent
}
print(f"AUDIT: {log_entry}") # In production, use proper logging
@staticmethod
def log_admin_action(username: str, action: str, details: str = None, ip_address: str = None):
"""Log admin action"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": "admin_action",
"username": username,
"action": action,
"details": details,
"ip_address": ip_address
}
print(f"AUDIT: {log_entry}") # In production, use proper logging

View File

@@ -13,6 +13,11 @@ class Settings:
english_ratio_threshold: float = float(os.getenv("ENGLISH_RATIO_THRESHOLD", "0.65"))
embedding_model: str = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
index_path: str = os.getenv("INDEX_PATH", "data/index.jsonl")
output_dir: str = os.getenv("OUTPUT_DIR", "outputs")
# Optional export targets (e.g., Synology NAS shares)
export_html_dir: str = os.getenv("EXPORT_HTML_DIR", "")
export_upload_dir: str = os.getenv("EXPORT_UPLOAD_DIR", "")
# Paperless (user will provide API details)
paperless_base_url: str = os.getenv("PAPERLESS_BASE_URL", "")

127
server/encryption.py Normal file
View File

@@ -0,0 +1,127 @@
"""
AES-256 Encryption Module for API Keys
Phase 3: Security Enhancement
"""
import os
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from typing import Optional
import secrets
class APIKeyEncryption:
def __init__(self, master_password: Optional[str] = None):
"""
Initialize encryption with master password
If no password provided, uses environment variable or generates one
"""
self.master_password = master_password or os.getenv("ENCRYPTION_KEY") or self._generate_master_key()
self.salt = b'ai_server_salt_2025' # Fixed salt for consistency
self._fernet = self._create_fernet()
def _generate_master_key(self) -> str:
"""Generate a secure master key"""
key = secrets.token_urlsafe(32)
print(f"🔑 Generated new encryption key: {key}")
print("⚠️ IMPORTANT: Save this key in your environment variables!")
print(f" export ENCRYPTION_KEY='{key}'")
return key
def _create_fernet(self) -> Fernet:
"""Create Fernet cipher from master password"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=self.salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(self.master_password.encode()))
return Fernet(key)
def encrypt_api_key(self, api_key: str) -> str:
"""Encrypt API key using AES-256"""
try:
encrypted_bytes = self._fernet.encrypt(api_key.encode())
return base64.urlsafe_b64encode(encrypted_bytes).decode()
except Exception as e:
raise Exception(f"Encryption failed: {str(e)}")
def decrypt_api_key(self, encrypted_api_key: str) -> str:
"""Decrypt API key"""
try:
encrypted_bytes = base64.urlsafe_b64decode(encrypted_api_key.encode())
decrypted_bytes = self._fernet.decrypt(encrypted_bytes)
return decrypted_bytes.decode()
except Exception as e:
raise Exception(f"Decryption failed: {str(e)}")
def is_encrypted(self, api_key: str) -> bool:
"""Check if API key is already encrypted"""
try:
# Try to decode as base64 - encrypted keys are base64 encoded
base64.urlsafe_b64decode(api_key.encode())
# Try to decrypt - if successful, it's encrypted
self.decrypt_api_key(api_key)
return True
except:
return False
def rotate_encryption_key(self, new_master_password: str, encrypted_keys: list) -> list:
"""Rotate encryption key - decrypt with old key, encrypt with new key"""
old_fernet = self._fernet
# Create new Fernet with new password
self.master_password = new_master_password
self._fernet = self._create_fernet()
rotated_keys = []
for encrypted_key in encrypted_keys:
try:
# Decrypt with old key
decrypted = old_fernet.decrypt(base64.urlsafe_b64decode(encrypted_key.encode()))
# Encrypt with new key
new_encrypted = self.encrypt_api_key(decrypted.decode())
rotated_keys.append(new_encrypted)
except Exception as e:
print(f"Failed to rotate key: {e}")
rotated_keys.append(encrypted_key) # Keep original if rotation fails
return rotated_keys
# Global encryption instance
encryption = APIKeyEncryption()
def encrypt_api_key(api_key: str) -> str:
"""Convenience function to encrypt API key"""
return encryption.encrypt_api_key(api_key)
def decrypt_api_key(encrypted_api_key: str) -> str:
"""Convenience function to decrypt API key"""
return encryption.decrypt_api_key(encrypted_api_key)
def is_encrypted(api_key: str) -> bool:
"""Convenience function to check if API key is encrypted"""
return encryption.is_encrypted(api_key)
# Test the encryption system
if __name__ == "__main__":
# Test encryption/decryption
test_key = "test-api-key-12345"
print("🧪 Testing API Key Encryption...")
print(f"Original: {test_key}")
# Encrypt
encrypted = encrypt_api_key(test_key)
print(f"Encrypted: {encrypted}")
# Decrypt
decrypted = decrypt_api_key(encrypted)
print(f"Decrypted: {decrypted}")
# Verify
print(f"✅ Match: {test_key == decrypted}")
print(f"🔒 Is Encrypted: {is_encrypted(encrypted)}")
print(f"🔓 Is Plain: {not is_encrypted(test_key)}")

View File

@@ -1,9 +1,16 @@
from __future__ import annotations
from fastapi import FastAPI, HTTPException, Depends
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from typing import List, Dict, Any
import shutil
from pathlib import Path
import os
from datetime import datetime
from .config import settings
from .ollama_client import OllamaClient
@@ -11,10 +18,19 @@ from .index_store import JsonlIndex
from .security import require_api_key
from .paperless_client import PaperlessClient
from .utils import chunk_text
from .pipeline import DocumentPipeline
app = FastAPI(title="Local AI Server", version="0.2.1")
# 템플릿과 정적 파일 설정
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
# HTML 출력 디렉토리도 정적 파일로 서빙
if Path("outputs/html").exists():
app.mount("/html", StaticFiles(directory="outputs/html"), name="html")
# CORS
import os
cors_origins = os.getenv("CORS_ORIGINS", "*")
@@ -28,6 +44,7 @@ app.add_middleware(
)
ollama = OllamaClient(settings.ollama_host)
index = JsonlIndex(settings.index_path)
pipeline = DocumentPipeline(ollama, settings.embedding_model, settings.boost_model, output_dir=settings.output_dir)
class ChatRequest(BaseModel):
@@ -55,6 +72,18 @@ class UpsertRequest(BaseModel):
batch: int = 16
class PipelineIngestRequest(BaseModel):
doc_id: str
text: str
generate_html: bool = True
translate: bool = True
target_language: str = "ko"
summarize: bool = False
summary_sentences: int = 5
summary_language: str | None = None
html_basename: str | None = None
@app.get("/health")
def health() -> Dict[str, Any]:
return {
@@ -152,6 +181,89 @@ def index_reload() -> Dict[str, Any]:
return {"total": total}
@app.post("/pipeline/ingest")
def pipeline_ingest(req: PipelineIngestRequest, _: None = Depends(require_api_key)) -> Dict[str, Any]:
result = pipeline.process(
doc_id=req.doc_id,
text=req.text,
index=index,
generate_html=req.generate_html,
translate=req.translate,
target_language=req.target_language,
summarize=req.summarize,
summary_sentences=req.summary_sentences,
summary_language=req.summary_language,
html_basename=req.html_basename,
)
exported_html: str | None = None
if result.html_path and settings.export_html_dir:
Path(settings.export_html_dir).mkdir(parents=True, exist_ok=True)
dst = str(Path(settings.export_html_dir) / Path(result.html_path).name)
shutil.copyfile(result.html_path, dst)
exported_html = dst
return {"status": "ok", "doc_id": result.doc_id, "added": result.added_chunks, "chunks": result.chunks, "html_path": result.html_path, "exported_html": exported_html}
@app.post("/pipeline/ingest_file")
async def pipeline_ingest_file(
_: None = Depends(require_api_key),
file: UploadFile = File(...),
doc_id: str = Form(...),
generate_html: bool = Form(True),
translate: bool = Form(True),
target_language: str = Form("ko"),
) -> Dict[str, Any]:
content_type = (file.content_type or "").lower()
raw = await file.read()
text = ""
if "text/plain" in content_type or file.filename.endswith(".txt"):
try:
text = raw.decode("utf-8")
except Exception:
text = raw.decode("latin-1", errors="ignore")
elif "pdf" in content_type or file.filename.endswith(".pdf"):
try:
from pypdf import PdfReader
from io import BytesIO
reader = PdfReader(BytesIO(raw))
parts: List[str] = []
for p in reader.pages:
try:
parts.append(p.extract_text() or "")
except Exception:
parts.append("")
text = "\n\n".join(parts)
except Exception as e:
raise HTTPException(status_code=400, detail=f"pdf_extract_error: {e}")
else:
raise HTTPException(status_code=400, detail="unsupported_file_type (only .txt/.pdf)")
if not text.strip():
raise HTTPException(status_code=400, detail="empty_text_after_extraction")
result = pipeline.process(
doc_id=doc_id,
text=text,
index=index,
generate_html=generate_html,
translate=translate,
target_language=target_language,
html_basename=file.filename,
)
exported_html: str | None = None
if result.html_path and settings.export_html_dir:
Path(settings.export_html_dir).mkdir(parents=True, exist_ok=True)
dst = str(Path(settings.export_html_dir) / Path(result.html_path).name)
shutil.copyfile(result.html_path, dst)
exported_html = dst
if settings.export_upload_dir:
Path(settings.export_upload_dir).mkdir(parents=True, exist_ok=True)
orig_name = f"{doc_id}__{file.filename}"
with open(str(Path(settings.export_upload_dir) / orig_name), "wb") as f:
f.write(raw)
return {"status": "ok", "doc_id": result.doc_id, "added": result.added_chunks, "chunks": result.chunks, "html_path": result.html_path, "exported_html": exported_html}
# Paperless webhook placeholder (to be wired with user-provided details)
class PaperlessHook(BaseModel):
document_id: int
@@ -188,6 +300,7 @@ def paperless_sync(req: PaperlessSyncRequest, _: None = Depends(require_api_key)
client = PaperlessClient(settings.paperless_base_url, settings.paperless_token)
from .index_store import IndexRow
added_total = 0
skipped = 0
next_url: str | None = None
fetched = 0
@@ -205,13 +318,18 @@ def paperless_sync(req: PaperlessSyncRequest, _: None = Depends(require_api_key)
doc_id = doc.get("id")
if not doc_id:
continue
text = client.get_document_text(int(doc_id))
if not text:
try:
text = client.get_document_text(int(doc_id))
if not text:
skipped += 1
continue
parts = chunk_text(text)
for i, t in enumerate(parts):
vec = ollama.embeddings(settings.embedding_model, t)
to_append.append(IndexRow(id=f"paperless:{doc_id}:{i}", text=t, vector=vec, source="paperless"))
except Exception:
skipped += 1
continue
parts = chunk_text(text)
for i, t in enumerate(parts):
vec = ollama.embeddings(settings.embedding_model, t)
to_append.append(IndexRow(id=f"paperless:{doc_id}:{i}", text=t, vector=vec, source="paperless"))
if to_append:
added_total += index.append(to_append)
fetched += len(results)
@@ -221,7 +339,7 @@ def paperless_sync(req: PaperlessSyncRequest, _: None = Depends(require_api_key)
if not next_url:
break
return {"status": "synced", "added": added_total}
return {"status": "synced", "added": added_total, "skipped": skipped}
# OpenAI-compatible chat completions (minimal)
@@ -254,3 +372,470 @@ def chat_completions(req: ChatCompletionsRequest, _: None = Depends(require_api_
],
}
# =============================================================================
# UI 라우트들
# =============================================================================
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""메인 대시보드 페이지"""
# 서버 상태 가져오기
status = {
"base_model": settings.base_model,
"boost_model": settings.boost_model,
"embedding_model": settings.embedding_model,
"index_loaded": len(index.rows) if index else 0,
}
# 최근 문서 (임시 데이터 - 실제로는 DB나 파일에서 가져올 것)
recent_documents = []
# 통계 (임시 데이터)
stats = {
"total_documents": len(index.rows) if index else 0,
"total_chunks": len(index.rows) if index else 0,
"today_processed": 0,
}
return templates.TemplateResponse("index.html", {
"request": request,
"status": status,
"recent_documents": recent_documents,
"stats": stats,
})
@app.get("/upload", response_class=HTMLResponse)
async def upload_page(request: Request):
"""파일 업로드 페이지"""
return templates.TemplateResponse("upload.html", {
"request": request,
"api_key": os.getenv("API_KEY", "")
})
def format_file_size(bytes_size):
"""파일 크기 포맷팅 헬퍼 함수"""
if bytes_size == 0:
return "0 Bytes"
k = 1024
sizes = ["Bytes", "KB", "MB", "GB"]
i = int(bytes_size / k)
if i >= len(sizes):
i = len(sizes) - 1
return f"{bytes_size / (k ** i):.2f} {sizes[i]}"
@app.get("/documents", response_class=HTMLResponse)
async def documents_page(request: Request):
"""문서 관리 페이지"""
# HTML 파일 목록 가져오기
html_dir = Path("outputs/html")
html_files = []
if html_dir.exists():
for file in html_dir.glob("*.html"):
stat = file.stat()
html_files.append({
"name": file.name,
"size": stat.st_size,
"created": datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M"),
"url": f"/html/{file.name}"
})
# 날짜순 정렬 (최신순)
html_files.sort(key=lambda x: x["created"], reverse=True)
return templates.TemplateResponse("documents.html", {
"request": request,
"documents": html_files,
"formatFileSize": format_file_size,
})
@app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request):
"""AI 챗봇 페이지"""
# 서버 상태 정보
status = {
"base_model": settings.base_model,
"boost_model": settings.boost_model,
"embedding_model": settings.embedding_model,
"index_loaded": len(index.rows) if index else 0,
}
return templates.TemplateResponse("chat.html", {
"request": request,
"status": status,
"current_time": datetime.now().strftime("%H:%M"),
"api_key": os.getenv("API_KEY", "")
})
# Admin Dashboard Routes
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, api_key: str = Depends(require_api_key)):
"""관리자 대시보드 페이지"""
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": settings.ai_server_port,
"ollama_host": settings.ollama_host,
})
@app.get("/admin/ollama/status")
async def admin_ollama_status(api_key: str = Depends(require_api_key)):
"""Ollama 서버 상태 확인"""
try:
# Ollama 서버에 ping 요청
response = await ollama.client.get(f"{settings.ollama_host}/api/tags")
if response.status_code == 200:
return {"status": "online", "models_count": len(response.json().get("models", []))}
else:
return {"status": "offline", "error": f"HTTP {response.status_code}"}
except Exception as e:
return {"status": "offline", "error": str(e)}
@app.get("/admin/models")
async def admin_get_models(api_key: str = Depends(require_api_key)):
"""설치된 모델 목록 조회"""
try:
models_data = await ollama.list_models()
models = []
for model in models_data.get("models", []):
models.append({
"name": model.get("name", "Unknown"),
"size": model.get("size", 0),
"status": "ready",
"is_active": model.get("name") == settings.base_model,
"last_used": model.get("modified_at"),
})
return {"models": models}
except Exception as e:
return {"models": [], "error": str(e)}
@app.get("/admin/models/active")
async def admin_get_active_model(api_key: str = Depends(require_api_key)):
"""현재 활성 모델 조회"""
return {"model": settings.base_model}
@app.post("/admin/models/test")
async def admin_test_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 테스트"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
try:
# 간단한 테스트 메시지 전송
test_response = await ollama.generate(
model=model_name,
prompt="Hello, this is a test. Please respond with 'Test successful'.",
stream=False
)
return {
"result": f"Test successful. Model responded: {test_response.get('response', 'No response')[:100]}..."
}
except Exception as e:
return {"result": f"Test failed: {str(e)}"}
# API Key Management (Placeholder - 실제 구현은 데이터베이스 필요)
api_keys_store = {} # 임시 저장소
@app.get("/admin/api-keys")
async def admin_get_api_keys(api_key: str = Depends(require_api_key)):
"""API 키 목록 조회"""
keys = []
for key_id, key_data in api_keys_store.items():
keys.append({
"id": key_id,
"name": key_data.get("name", "Unnamed"),
"key": key_data.get("key", ""),
"created_at": key_data.get("created_at", datetime.now().isoformat()),
"usage_count": key_data.get("usage_count", 0),
})
return {"api_keys": keys}
@app.post("/admin/api-keys")
async def admin_create_api_key(request: dict, api_key: str = Depends(require_api_key)):
"""새 API 키 생성"""
import secrets
import uuid
name = request.get("name", "Unnamed Key")
new_key = secrets.token_urlsafe(32)
key_id = str(uuid.uuid4())
api_keys_store[key_id] = {
"name": name,
"key": new_key,
"created_at": datetime.now().isoformat(),
"usage_count": 0,
}
return {"api_key": new_key, "key_id": key_id}
@app.delete("/admin/api-keys/{key_id}")
async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_key)):
"""API 키 삭제"""
if key_id in api_keys_store:
del api_keys_store[key_id]
return {"message": "API key deleted successfully"}
else:
raise HTTPException(status_code=404, detail="API key not found")
# Phase 2: Advanced Model Management
@app.post("/admin/models/download")
async def admin_download_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 다운로드"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
try:
# Ollama pull 명령 실행
result = await ollama.pull_model(model_name)
return {
"success": True,
"message": f"Model '{model_name}' download started",
"details": result
}
except Exception as e:
return {
"success": False,
"error": f"Failed to download model: {str(e)}"
}
@app.delete("/admin/models/{model_name}")
async def admin_delete_model(model_name: str, api_key: str = Depends(require_api_key)):
"""모델 삭제"""
try:
# Ollama 모델 삭제
result = await ollama.delete_model(model_name)
return {
"success": True,
"message": f"Model '{model_name}' deleted successfully",
"details": result
}
except Exception as e:
return {
"success": False,
"error": f"Failed to delete model: {str(e)}"
}
@app.get("/admin/models/available")
async def admin_get_available_models(api_key: str = Depends(require_api_key)):
"""다운로드 가능한 모델 목록"""
# 인기 있는 모델들 목록 (실제로는 Ollama 레지스트리에서 가져와야 함)
available_models = [
{
"name": "llama3.2:1b",
"description": "Meta의 Llama 3.2 1B 모델 - 가벼운 작업용",
"size": "1.3GB",
"tags": ["chat", "lightweight"]
},
{
"name": "llama3.2:3b",
"description": "Meta의 Llama 3.2 3B 모델 - 균형잡힌 성능",
"size": "2.0GB",
"tags": ["chat", "recommended"]
},
{
"name": "qwen2.5:7b",
"description": "Alibaba의 Qwen 2.5 7B 모델 - 다국어 지원",
"size": "4.1GB",
"tags": ["chat", "multilingual"]
},
{
"name": "gemma2:2b",
"description": "Google의 Gemma 2 2B 모델 - 효율적인 추론",
"size": "1.6GB",
"tags": ["chat", "efficient"]
},
{
"name": "codellama:7b",
"description": "Meta의 Code Llama 7B - 코드 생성 특화",
"size": "3.8GB",
"tags": ["code", "programming"]
},
{
"name": "mistral:7b",
"description": "Mistral AI의 7B 모델 - 고성능 추론",
"size": "4.1GB",
"tags": ["chat", "performance"]
}
]
return {"available_models": available_models}
# Phase 2: System Monitoring
@app.get("/admin/system/stats")
async def admin_get_system_stats(api_key: str = Depends(require_api_key)):
"""시스템 리소스 사용률 조회"""
import psutil
import GPUtil
try:
# CPU 사용률
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# 메모리 사용률
memory = psutil.virtual_memory()
memory_percent = memory.percent
memory_used = memory.used // (1024**3) # GB
memory_total = memory.total // (1024**3) # GB
# 디스크 사용률
disk = psutil.disk_usage('/')
disk_percent = (disk.used / disk.total) * 100
disk_used = disk.used // (1024**3) # GB
disk_total = disk.total // (1024**3) # GB
# GPU 사용률 (NVIDIA GPU가 있는 경우)
gpu_stats = []
try:
gpus = GPUtil.getGPUs()
for gpu in gpus:
gpu_stats.append({
"name": gpu.name,
"load": gpu.load * 100,
"memory_used": gpu.memoryUsed,
"memory_total": gpu.memoryTotal,
"temperature": gpu.temperature
})
except:
gpu_stats = []
return {
"cpu": {
"usage_percent": cpu_percent,
"core_count": cpu_count
},
"memory": {
"usage_percent": memory_percent,
"used_gb": memory_used,
"total_gb": memory_total
},
"disk": {
"usage_percent": disk_percent,
"used_gb": disk_used,
"total_gb": disk_total
},
"gpu": gpu_stats,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
return {
"error": f"Failed to get system stats: {str(e)}",
"timestamp": datetime.now().isoformat()
}
# Phase 3: JWT Authentication System
from .auth import AuthManager, AuditLogger, get_current_user, require_admin_role, require_system_role
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""로그인 페이지"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/admin/login")
async def admin_login(request: Request):
"""JWT 기반 로그인"""
try:
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return {"success": False, "message": "Username and password are required"}
# Get client IP
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "")
try:
# Authenticate user
user = AuthManager.authenticate_user(username, password)
if user:
# Create JWT token
token = AuthManager.create_jwt_token(user, remember_me)
# Record successful login
AuthManager.record_login_attempt(username, True, client_ip)
AuditLogger.log_login(username, True, client_ip, user_agent)
return {
"success": True,
"message": "Login successful",
"token": token,
"user": {
"username": user["username"],
"role": user["role"]
}
}
else:
# Record failed login
AuthManager.record_login_attempt(username, False, client_ip)
AuditLogger.log_login(username, False, client_ip, user_agent)
return {"success": False, "message": "Invalid username or password"}
except HTTPException as e:
# Account locked
AuditLogger.log_login(username, False, client_ip, user_agent)
return {"success": False, "message": e.detail}
except Exception as e:
return {"success": False, "message": "Login error occurred"}
@app.get("/admin/verify-token")
async def verify_token(current_user: dict = Depends(get_current_user)):
"""JWT 토큰 검증"""
return {
"valid": True,
"user": current_user
}
@app.post("/admin/logout")
async def admin_logout(request: Request, current_user: dict = Depends(get_current_user)):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
client_ip = request.client.host
AuditLogger.log_admin_action(
current_user["username"],
"logout",
"User logged out",
client_ip
)
return {"success": True, "message": "Logged out successfully"}
# Update existing admin routes to use JWT authentication
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, current_user: dict = Depends(require_admin_role)):
"""관리자 대시보드 페이지 (JWT 인증 필요)"""
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": settings.ai_server_port,
"ollama_host": settings.ollama_host,
"current_user": current_user
})

View File

@@ -9,6 +9,14 @@ class PaperlessClient:
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
self.base_url = (base_url or os.getenv("PAPERLESS_BASE_URL", "")).rstrip("/")
self.token = token or os.getenv("PAPERLESS_TOKEN", "")
verify_env = os.getenv("PAPERLESS_VERIFY_SSL", "true").lower().strip()
ca_bundle = os.getenv("PAPERLESS_CA_BUNDLE", "").strip()
if ca_bundle:
self.verify: Any = ca_bundle
elif verify_env in ("0", "false", "no"):
self.verify = False
else:
self.verify = True
def _headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Accept": "application/json"}
@@ -20,7 +28,7 @@ class PaperlessClient:
if not self.base_url:
raise RuntimeError("PAPERLESS_BASE_URL not configured")
url = f"{self.base_url}/api/documents/{doc_id}/"
resp = requests.get(url, headers=self._headers(), timeout=60)
resp = requests.get(url, headers=self._headers(), timeout=60, verify=self.verify)
resp.raise_for_status()
return resp.json()
@@ -30,7 +38,7 @@ class PaperlessClient:
# Try content endpoint
url_content = f"{self.base_url}/api/documents/{doc_id}/content/"
try:
r = requests.get(url_content, headers=self._headers(), timeout=60)
r = requests.get(url_content, headers=self._headers(), timeout=60, verify=self.verify)
if r.status_code == 200 and r.text:
return r.text
except Exception:
@@ -38,7 +46,7 @@ class PaperlessClient:
# Try txt download
url_txt = f"{self.base_url}/api/documents/{doc_id}/download/?format=txt"
try:
r = requests.get(url_txt, headers=self._headers(), timeout=60)
r = requests.get(url_txt, headers=self._headers(), timeout=60, verify=self.verify)
if r.status_code == 200 and r.text:
return r.text
except Exception:
@@ -56,7 +64,7 @@ class PaperlessClient:
if query:
params["query"] = query
url = f"{self.base_url}/api/documents/"
resp = requests.get(url, headers=self._headers(), params=params, timeout=60)
resp = requests.get(url, headers=self._headers(), params=params, timeout=60, verify=self.verify)
resp.raise_for_status()
return resp.json()

134
server/pipeline.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List, Dict, Any
from .utils import chunk_text
from .ollama_client import OllamaClient
from .index_store import IndexRow
@dataclass
class PipelineResult:
doc_id: str
html_path: str | None
added_chunks: int
chunks: int
class DocumentPipeline:
def __init__(self, ollama: OllamaClient, embedding_model: str, boost_model: str, output_dir: str = "outputs") -> None:
self.ollama = ollama
self.embedding_model = embedding_model
self.boost_model = boost_model
self.output_dir = Path(output_dir)
(self.output_dir / "html").mkdir(parents=True, exist_ok=True)
def summarize(self, parts: List[str], target_language: str = "ko", sentences: int = 5) -> List[str]:
summarized: List[str] = []
sys_prompt = (
"당신은 전문 요약가입니다. 핵심 내용만 간결하게 요약하세요."
)
for p in parts:
if not p.strip():
summarized.append("")
continue
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": (
f"다음 텍스트를 {target_language}{sentences}문장 이내로 핵심만 요약하세요. 불필요한 수식어는 제거하고, 중요한 수치/용어는 보존하세요.\n\n{p}"
)},
]
resp = self.ollama.chat(self.boost_model, messages, stream=False, options={"temperature": 0.2, "num_ctx": 32768})
content = resp.get("message", {}).get("content") or resp.get("response", "")
summarized.append(content.strip())
# 최종 통합 요약(선택): 각 청크 요약을 다시 결합해 더 짧게
joined = "\n\n".join(s for s in summarized if s)
if not joined.strip():
return summarized
messages2 = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": (
f"아래 부분 요약들을 {target_language}{max(3, sentences)}문장 이내로 다시 한번 통합 요약하세요.\n\n{joined}"
)},
]
resp2 = self.ollama.chat(self.boost_model, messages2, stream=False, options={"temperature": 0.2, "num_ctx": 32768})
content2 = resp2.get("message", {}).get("content") or resp2.get("response", "")
return [content2.strip()]
def translate(self, parts: List[str], target_language: str = "ko") -> List[str]:
translated: List[str] = []
sys_prompt = (
"당신은 전문 번역가입니다. 입력 텍스트를 대상 언어로 자연스럽고 충실하게 번역하세요. "
"의미를 임의로 축약하거나 추가하지 마세요. 코드/수식/표기는 가능한 유지하세요."
)
for p in parts:
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"아래 텍스트를 {target_language}로 번역하세요.\n\n{p}"},
]
resp = self.ollama.chat(self.boost_model, messages, stream=False, options={"temperature": 0.2, "num_ctx": 32768})
content = resp.get("message", {}).get("content") or resp.get("response", "")
translated.append(content.strip())
return translated
def build_html(self, basename: str, title: str, ko_text: str) -> str:
# Ensure .html suffix and sanitize basename
safe_base = Path(basename).stem + ".html"
html_path = self.output_dir / "html" / safe_base
html = f"""
<!doctype html>
<html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\"/>\n<title>{title}</title>\n<style>
body{{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}}
article{{max-width: 900px; margin: auto;}}
h1{{font-size: 1.6rem; margin-bottom: 1rem;}}
.chunk{{white-space: pre-wrap; margin: 1rem 0;}}
</style>\n</head>\n<body>\n<article>\n<h1>{title}</h1>\n"""
for idx, para in enumerate(ko_text.split("\n\n")):
if para.strip():
html += f"<div class=\"chunk\" id=\"c{idx}\">{para}</div>\n"
html += "</article>\n</body>\n</html>\n"
html_path.write_text(html, encoding="utf-8")
return str(html_path)
def process(
self,
*,
doc_id: str,
text: str,
index,
generate_html: bool = True,
translate: bool = True,
target_language: str = "ko",
summarize: bool = False,
summary_sentences: int = 5,
summary_language: str | None = None,
html_basename: str | None = None,
) -> PipelineResult:
parts = chunk_text(text, max_chars=1200, overlap=200)
if summarize:
# 요약 언어 기본값: 번역 언어와 동일, 번역 off면 ko로 요약(설정 없을 때)
sum_lang = summary_language or (target_language if translate else "ko")
summarized_parts = self.summarize(parts, target_language=sum_lang, sentences=summary_sentences)
working_parts = summarized_parts
else:
working_parts = parts
translated = self.translate(working_parts, target_language=target_language) if translate else working_parts
to_append: List[IndexRow] = []
for i, t in enumerate(translated):
vec = self.ollama.embeddings(self.embedding_model, t)
to_append.append(IndexRow(id=f"pipeline:{doc_id}:{i}", text=t, vector=vec, source=f"pipeline/{doc_id}"))
added = index.append(to_append) if to_append else 0
html_path: str | None = None
if generate_html:
title_suffix = "요약+번역본" if (summarize and translate) else ("요약본" if summarize else ("번역본" if translate else "원문"))
basename = html_basename or f"{doc_id}.html"
html_path = self.build_html(basename, title=f"문서 {doc_id} ({title_suffix})", ko_text="\n\n".join(translated))
return PipelineResult(doc_id=doc_id, html_path=html_path, added_chunks=added, chunks=len(translated))

697
static/admin.css Normal file
View File

@@ -0,0 +1,697 @@
/* AI Server Admin Dashboard CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #2c3e50;
line-height: 1.6;
}
.admin-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.admin-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
.header-content h1 {
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-info {
display: flex;
align-items: center;
gap: 1rem;
}
.server-status {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.server-status.online {
background: rgba(46, 204, 113, 0.2);
border: 1px solid rgba(46, 204, 113, 0.3);
}
.server-status.offline {
background: rgba(231, 76, 60, 0.2);
border: 1px solid rgba(231, 76, 60, 0.3);
}
.current-time {
font-size: 0.9rem;
opacity: 0.9;
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.3rem;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
}
/* Main Content */
.admin-main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.dashboard-section {
margin-bottom: 3rem;
}
.dashboard-section h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
font-size: 1.4rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 2px solid #ecf0f1;
padding-bottom: 0.5rem;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.status-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e1e8ed;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.status-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
}
.card-header i {
font-size: 1.5rem;
color: #667eea;
}
.card-header h3 {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
}
.card-content {
text-align: center;
}
.status-value {
font-size: 1.8rem;
font-weight: 700;
color: #27ae60;
margin-bottom: 0.5rem;
}
.status-value.error {
color: #e74c3c;
}
.status-value.warning {
color: #f39c12;
}
.status-detail {
font-size: 0.9rem;
color: #7f8c8d;
}
/* Tables */
.models-container, .api-keys-container {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e1e8ed;
}
.models-header, .api-keys-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.models-table table {
width: 100%;
border-collapse: collapse;
}
.models-table th,
.models-table td {
text-align: left;
padding: 1rem;
border-bottom: 1px solid #ecf0f1;
}
.models-table th {
background: #f8f9fa;
font-weight: 600;
color: #2c3e50;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.models-table tr:hover {
background: #f8f9fa;
}
.loading {
text-align: center;
color: #7f8c8d;
font-style: italic;
padding: 2rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
transform: translateY(-1px);
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #229954;
transform: translateY(-1px);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-1px);
}
.btn-small {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
/* API Keys */
.api-key-item {
background: #f8f9fa;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.api-key-info {
flex: 1;
}
.api-key-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.3rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.encryption-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #27ae60;
color: white;
}
.encryption-badge.plain {
background: #e74c3c;
}
.encryption-badge i {
font-size: 0.6rem;
}
.api-key-value {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.8rem;
color: #7f8c8d;
background: white;
padding: 0.3rem 0.6rem;
border-radius: 4px;
border: 1px solid #ddd;
margin-bottom: 0.3rem;
}
.api-key-meta {
font-size: 0.8rem;
color: #95a5a6;
}
.api-key-actions {
display: flex;
gap: 0.5rem;
}
/* Status Badges */
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.active {
background: #d5f4e6;
color: #27ae60;
}
.status-badge.inactive {
background: #fadbd8;
color: #e74c3c;
}
.status-badge.loading {
background: #fef9e7;
color: #f39c12;
}
/* Phase 2: System Monitoring Styles */
.monitoring-container {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e1e8ed;
}
.monitoring-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.monitor-card {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
border: 1px solid #e9ecef;
}
.monitor-card .card-header {
margin-bottom: 1rem;
}
.progress-circle {
position: relative;
width: 80px;
height: 80px;
margin: 0 auto 1rem;
border-radius: 50%;
background: conic-gradient(#667eea 0deg, #e9ecef 0deg);
display: flex;
align-items: center;
justify-content: center;
}
.progress-circle::before {
content: '';
position: absolute;
width: 60px;
height: 60px;
border-radius: 50%;
background: white;
}
.progress-text {
position: relative;
z-index: 1;
font-size: 0.9rem;
font-weight: 600;
color: #2c3e50;
}
.monitor-details {
font-size: 0.8rem;
color: #7f8c8d;
}
/* Progress circle colors */
.progress-circle.low {
background: conic-gradient(#27ae60 var(--progress, 0deg), #e9ecef var(--progress, 0deg));
}
.progress-circle.medium {
background: conic-gradient(#f39c12 var(--progress, 0deg), #e9ecef var(--progress, 0deg));
}
.progress-circle.high {
background: conic-gradient(#e74c3c var(--progress, 0deg), #e9ecef var(--progress, 0deg));
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 600px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e1e8ed;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px 12px 0 0;
}
.modal-header h3 {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
color: white;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.close-btn:hover {
background-color: rgba(255,255,255,0.2);
}
.modal-body {
padding: 1.5rem;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e1e8ed;
}
/* Available Models List */
.available-model-item {
background: #f8f9fa;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s ease;
}
.available-model-item:hover {
border-color: #667eea;
}
.model-info {
flex: 1;
}
.model-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.3rem;
}
.model-description {
font-size: 0.9rem;
color: #7f8c8d;
margin-bottom: 0.5rem;
}
.model-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.model-tag {
background: #667eea;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.model-tag.code {
background: #e74c3c;
}
.model-tag.lightweight {
background: #27ae60;
}
.model-tag.recommended {
background: #f39c12;
}
.model-size {
font-size: 0.9rem;
color: #95a5a6;
margin-top: 0.5rem;
}
.model-delete-info {
background: #fadbd8;
border: 1px solid #f1948a;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.model-delete-info strong {
color: #e74c3c;
}
/* Enhanced Models Table */
.models-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.admin-main {
padding: 1rem;
}
.status-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.monitoring-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.header-content {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.models-table {
overflow-x: auto;
}
.models-header {
flex-direction: column;
align-items: stretch;
}
.api-key-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.api-key-actions {
width: 100%;
justify-content: flex-end;
}
.available-model-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.modal-content {
width: 95%;
margin: 2% auto;
}
.modal-actions {
flex-direction: column;
}
}

508
static/admin.js Normal file
View File

@@ -0,0 +1,508 @@
// AI Server Admin Dashboard JavaScript
class AdminDashboard {
constructor() {
this.apiKey = this.getApiKey();
this.baseUrl = window.location.origin;
this.init();
}
getApiKey() {
// JWT 토큰 사용
const token = localStorage.getItem('ai_admin_token');
console.log('Getting token:', token ? token.substring(0, 20) + '...' : 'No token found');
if (!token) {
// 토큰이 없으면 로그인 페이지로 리다이렉트
console.log('No token, redirecting to login...');
window.location.href = '/login';
return null;
}
return token;
}
async init() {
// 먼저 토큰 검증
if (!this.apiKey) {
return; // getApiKey()에서 이미 리다이렉트됨
}
// 토큰 유효성 검증
try {
await this.apiRequest('/admin/verify-token');
console.log('Token verification successful');
} catch (error) {
console.log('Token verification failed, redirecting to login');
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
return;
}
this.updateCurrentTime();
setInterval(() => this.updateCurrentTime(), 1000);
await this.loadUserInfo(); // Phase 3: Load user info
await this.loadSystemStatus();
await this.loadModels();
await this.loadApiKeys();
await this.loadSystemStats(); // Phase 2
// Auto-refresh every 30 seconds
setInterval(() => {
this.loadSystemStatus();
this.loadModels();
this.loadSystemStats(); // Phase 2
}, 30000);
}
// Phase 3: User Management
async loadUserInfo() {
try {
const userInfo = localStorage.getItem('ai_admin_user');
if (userInfo) {
const user = JSON.parse(userInfo);
document.getElementById('username').textContent = user.username;
} else {
// Verify token and get user info
const response = await this.apiRequest('/admin/verify-token');
if (response.valid) {
document.getElementById('username').textContent = response.user.username;
localStorage.setItem('ai_admin_user', JSON.stringify(response.user));
}
}
} catch (error) {
console.error('Failed to load user info:', error);
// Token might be invalid, redirect to login
window.location.href = '/login';
}
}
updateCurrentTime() {
const now = new Date();
document.getElementById('current-time').textContent =
now.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
async apiRequest(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
};
console.log('API Request:', endpoint, 'with token:', this.apiKey ? this.apiKey.substring(0, 20) + '...' : 'No token');
try {
const response = await fetch(url, { ...defaultOptions, ...options });
console.log('API Response:', response.status, response.statusText);
if (!response.ok) {
if (response.status === 401) {
console.log('401 Unauthorized - clearing tokens and redirecting');
// JWT 토큰이 만료되었거나 유효하지 않음
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
return;
}
const errorText = await response.text();
console.log('Error response:', errorText);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API Request failed:', error);
throw error;
}
}
async loadSystemStatus() {
try {
// Check AI Server status
const healthResponse = await this.apiRequest('/health');
document.getElementById('server-status').textContent = 'Online';
document.getElementById('server-status').className = 'status-value';
// Check Ollama status
try {
const ollamaResponse = await this.apiRequest('/admin/ollama/status');
document.getElementById('ollama-status').textContent =
ollamaResponse.status === 'online' ? 'Online' : 'Offline';
document.getElementById('ollama-status').className =
`status-value ${ollamaResponse.status === 'online' ? '' : 'error'}`;
} catch (error) {
document.getElementById('ollama-status').textContent = 'Offline';
document.getElementById('ollama-status').className = 'status-value error';
}
// Load active model
try {
const modelResponse = await this.apiRequest('/admin/models/active');
document.getElementById('active-model').textContent =
modelResponse.model || 'None';
} catch (error) {
document.getElementById('active-model').textContent = 'Unknown';
}
// Load API call stats (placeholder)
document.getElementById('api-calls').textContent = '0';
} catch (error) {
console.error('Failed to load system status:', error);
document.getElementById('server-status').textContent = 'Error';
document.getElementById('server-status').className = 'status-value error';
}
}
async loadModels() {
try {
const response = await this.apiRequest('/admin/models');
const models = response.models || [];
const tbody = document.getElementById('models-tbody');
if (models.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="loading">No models found</td></tr>';
return;
}
tbody.innerHTML = models.map(model => `
<tr>
<td>
<strong>${model.name}</strong>
${model.is_active ? '<span class="status-badge active">Active</span>' : ''}
</td>
<td>${this.formatSize(model.size)}</td>
<td>
<span class="status-badge ${model.status === 'ready' ? 'active' : 'inactive'}">
${model.status || 'Unknown'}
</span>
</td>
<td>${model.last_used ? new Date(model.last_used).toLocaleString('ko-KR') : 'Never'}</td>
<td>
<button class="btn btn-small btn-primary" onclick="admin.testModel('${model.name}')">
<i class="fas fa-play"></i> Test
</button>
<button class="btn btn-small btn-danger" onclick="admin.confirmDeleteModel('${model.name}')">
<i class="fas fa-trash"></i> Delete
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Failed to load models:', error);
document.getElementById('models-tbody').innerHTML =
'<tr><td colspan="5" class="loading">Error loading models</td></tr>';
}
}
async loadApiKeys() {
try {
const response = await this.apiRequest('/admin/api-keys');
const apiKeys = response.api_keys || [];
const container = document.getElementById('api-keys-list');
if (apiKeys.length === 0) {
container.innerHTML = '<div class="loading">No API keys found</div>';
return;
}
container.innerHTML = apiKeys.map(key => `
<div class="api-key-item">
<div class="api-key-info">
<div class="api-key-name">
${key.name || 'Unnamed Key'}
${key.encrypted ? '<span class="encryption-badge"><i class="fas fa-lock"></i> Encrypted</span>' : '<span class="encryption-badge plain"><i class="fas fa-unlock"></i> Plain</span>'}
</div>
<div class="api-key-value">${this.maskApiKey(key.key)}</div>
<div class="api-key-meta">
Created: ${new Date(key.created_at).toLocaleString('ko-KR')} |
Uses: ${key.usage_count || 0}
${key.encrypted ? ' | 🔒 AES-256 Encrypted' : ' | ⚠️ Plain Text'}
</div>
</div>
<div class="api-key-actions">
<button class="btn btn-small btn-danger" onclick="admin.deleteApiKey('${key.id}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load API keys:', error);
document.getElementById('api-keys-list').innerHTML =
'<div class="loading">Error loading API keys</div>';
}
}
formatSize(bytes) {
if (!bytes) return 'Unknown';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
maskApiKey(key) {
if (!key) return 'Unknown';
if (key.length <= 8) return key;
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
}
async refreshModels() {
document.getElementById('models-tbody').innerHTML =
'<tr><td colspan="5" class="loading">Refreshing models...</td></tr>';
await this.loadModels();
}
async testModel(modelName) {
try {
const response = await this.apiRequest('/admin/models/test', {
method: 'POST',
body: JSON.stringify({ model: modelName })
});
alert(`Model test result:\n${response.result || 'Test completed successfully'}`);
} catch (error) {
alert(`Model test failed: ${error.message}`);
}
}
async generateApiKey() {
const name = prompt('Enter a name for the new API key:');
if (!name) return;
try {
const response = await this.apiRequest('/admin/api-keys', {
method: 'POST',
body: JSON.stringify({ name })
});
alert(`New API key created:\n${response.api_key}\n\nPlease save this key securely. It will not be shown again.`);
await this.loadApiKeys();
} catch (error) {
alert(`Failed to generate API key: ${error.message}`);
}
}
async deleteApiKey(keyId) {
if (!confirm('Are you sure you want to delete this API key?')) return;
try {
await this.apiRequest(`/admin/api-keys/${keyId}`, {
method: 'DELETE'
});
await this.loadApiKeys();
} catch (error) {
alert(`Failed to delete API key: ${error.message}`);
}
}
// Phase 2: System Monitoring
async loadSystemStats() {
try {
const response = await this.apiRequest('/admin/system/stats');
// Update CPU
this.updateProgressCircle('cpu-progress', response.cpu.usage_percent);
document.getElementById('cpu-text').textContent = `${response.cpu.usage_percent}%`;
document.getElementById('cpu-cores').textContent = `${response.cpu.core_count} cores`;
// Update Memory
this.updateProgressCircle('memory-progress', response.memory.usage_percent);
document.getElementById('memory-text').textContent = `${response.memory.usage_percent}%`;
document.getElementById('memory-details').textContent =
`${response.memory.used_gb} / ${response.memory.total_gb} GB`;
// Update Disk
this.updateProgressCircle('disk-progress', response.disk.usage_percent);
document.getElementById('disk-text').textContent = `${response.disk.usage_percent}%`;
document.getElementById('disk-details').textContent =
`${response.disk.used_gb} / ${response.disk.total_gb} GB`;
// Update GPU
if (response.gpu && response.gpu.length > 0) {
const gpu = response.gpu[0];
this.updateProgressCircle('gpu-progress', gpu.load);
document.getElementById('gpu-text').textContent = `${gpu.load}%`;
document.getElementById('gpu-details').textContent =
`${gpu.name} - ${gpu.temperature}°C`;
} else {
document.getElementById('gpu-text').textContent = '--';
document.getElementById('gpu-details').textContent = 'No GPU detected';
}
} catch (error) {
console.error('Failed to load system stats:', error);
}
}
updateProgressCircle(elementId, percentage) {
const element = document.getElementById(elementId);
const degrees = (percentage / 100) * 360;
// Remove existing color classes
element.classList.remove('low', 'medium', 'high');
// Add appropriate color class
if (percentage < 50) {
element.classList.add('low');
} else if (percentage < 80) {
element.classList.add('medium');
} else {
element.classList.add('high');
}
// Update CSS custom property for progress
element.style.setProperty('--progress', `${degrees}deg`);
}
// Phase 2: Model Download
async openModelDownload() {
try {
const response = await this.apiRequest('/admin/models/available');
const models = response.available_models || [];
const container = document.getElementById('available-models-list');
if (models.length === 0) {
container.innerHTML = '<div class="loading">No models available</div>';
} else {
container.innerHTML = models.map(model => `
<div class="available-model-item">
<div class="model-info">
<div class="model-name">${model.name}</div>
<div class="model-description">${model.description}</div>
<div class="model-tags">
${model.tags.map(tag => `<span class="model-tag ${tag}">${tag}</span>`).join('')}
</div>
<div class="model-size">Size: ${model.size}</div>
</div>
<button class="btn btn-success" onclick="admin.downloadModel('${model.name}')">
<i class="fas fa-download"></i> Download
</button>
</div>
`).join('');
}
this.openModal('model-download-modal');
} catch (error) {
console.error('Failed to load available models:', error);
alert('Failed to load available models');
}
}
async downloadModel(modelName) {
try {
const response = await this.apiRequest('/admin/models/download', {
method: 'POST',
body: JSON.stringify({ model: modelName })
});
if (response.success) {
alert(`Download started: ${response.message}`);
this.closeModal('model-download-modal');
// Refresh models list after a short delay
setTimeout(() => this.loadModels(), 2000);
} else {
alert(`Download failed: ${response.error}`);
}
} catch (error) {
alert(`Download failed: ${error.message}`);
}
}
// Phase 2: Model Delete
confirmDeleteModel(modelName) {
document.getElementById('delete-model-name').textContent = modelName;
// Set up delete confirmation
const confirmBtn = document.getElementById('confirm-delete-btn');
confirmBtn.onclick = () => this.deleteModel(modelName);
this.openModal('model-delete-modal');
}
async deleteModel(modelName) {
try {
const response = await this.apiRequest(`/admin/models/${modelName}`, {
method: 'DELETE'
});
if (response.success) {
alert(`Model deleted: ${response.message}`);
this.closeModal('model-delete-modal');
await this.loadModels();
} else {
alert(`Delete failed: ${response.error}`);
}
} catch (error) {
alert(`Delete failed: ${error.message}`);
}
}
// Modal management
openModal(modalId) {
document.getElementById(modalId).style.display = 'block';
}
closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
}
// Global functions for HTML onclick handlers
let admin;
function refreshModels() {
admin.refreshModels();
}
function generateApiKey() {
admin.generateApiKey();
}
function openModelDownload() {
admin.openModelDownload();
}
function closeModal(modalId) {
admin.closeModal(modalId);
}
// Phase 3: Logout function
async function logout() {
if (!confirm('Are you sure you want to logout?')) return;
try {
// Call logout API
await admin.apiRequest('/admin/logout', { method: 'POST' });
} catch (error) {
console.error('Logout API call failed:', error);
} finally {
// Clear local storage and redirect
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
}
}
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', () => {
admin = new AdminDashboard();
});

394
static/css/style.css Normal file
View File

@@ -0,0 +1,394 @@
/* 기본 스타일 리셋 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 네비게이션 */
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
}
.nav-logo {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.nav-logo i {
margin-right: 0.5rem;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: rgba(255,255,255,0.2);
}
/* 메인 콘텐츠 */
.main-content {
flex: 1;
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
width: 100%;
}
/* 카드 스타일 */
.card {
background: white;
border-radius: 10px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
border: 1px solid #e9ecef;
}
.card-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f8f9fa;
}
.card-title {
font-size: 1.5rem;
color: #495057;
margin-bottom: 0.5rem;
}
.card-subtitle {
color: #6c757d;
font-size: 0.9rem;
}
/* 그리드 레이아웃 */
.grid {
display: grid;
gap: 2rem;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
/* 버튼 */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 5px;
text-decoration: none;
text-align: center;
cursor: pointer;
transition: all 0.3s;
font-size: 1rem;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(86, 171, 47, 0.4);
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
/* 폼 요소 */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 1rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 드래그 앤 드롭 영역 */
.drop-zone {
border: 2px dashed #ced4da;
border-radius: 10px;
padding: 3rem;
text-align: center;
transition: all 0.3s;
background-color: #f8f9fa;
cursor: pointer;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.05);
}
.drop-zone i {
font-size: 3rem;
color: #6c757d;
margin-bottom: 1rem;
}
.drop-zone.dragover i {
color: #667eea;
}
/* 상태 표시 */
.status {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
display: inline-block;
}
.status-success {
background-color: #d4edda;
color: #155724;
}
.status-processing {
background-color: #fff3cd;
color: #856404;
}
.status-error {
background-color: #f8d7da;
color: #721c24;
}
/* 테이블 */
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* 진행률 바 */
.progress {
width: 100%;
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s;
border-radius: 4px;
}
/* 알림 */
.alert {
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
border-left: 4px solid;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-left-color: #28a745;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-left-color: #dc3545;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-left-color: #17a2b8;
}
/* 푸터 */
.footer {
background-color: #343a40;
color: white;
text-align: center;
padding: 1rem 0;
margin-top: auto;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
gap: 1rem;
}
.nav-menu {
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
.main-content {
padding: 0 1rem;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
/* 로딩 스피너 */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 채팅 인터페이스 */
.chat-container {
height: 500px;
display: flex;
flex-direction: column;
border: 1px solid #e9ecef;
border-radius: 10px;
overflow: hidden;
}
.chat-messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
background-color: #f8f9fa;
}
.chat-input-area {
padding: 1rem;
background-color: white;
border-top: 1px solid #e9ecef;
display: flex;
gap: 0.5rem;
}
.chat-input {
flex: 1;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 10px;
max-width: 70%;
}
.message.user {
background-color: #667eea;
color: white;
margin-left: auto;
}
.message.assistant {
background-color: white;
border: 1px solid #e9ecef;
}

87
static/js/main.js Normal file
View File

@@ -0,0 +1,87 @@
// 공통 JavaScript 기능들
// API 호출 헬퍼
async function apiCall(url, options = {}) {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const response = await fetch(url, { ...defaultOptions, ...options });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 날짜 포맷팅
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('ko-KR');
}
// 토스트 알림
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} toast`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// 로딩 상태 관리
function setLoading(element, loading = true) {
if (loading) {
element.disabled = true;
element.innerHTML = '<span class="spinner"></span> 처리 중...';
} else {
element.disabled = false;
element.innerHTML = element.getAttribute('data-original-text') || '완료';
}
}
// 애니메이션 CSS 추가
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.toast {
animation: slideIn 0.3s ease;
}
`;
document.head.appendChild(style);

390
static/login.css Normal file
View File

@@ -0,0 +1,390 @@
/* AI Server Admin Login Page CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* Background Animation */
.bg-animation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.floating-icon {
position: absolute;
font-size: 2rem;
color: rgba(255, 255, 255, 0.1);
animation: float 8s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
.floating-icon:nth-child(1) {
top: 20%;
left: 10%;
}
.floating-icon:nth-child(2) {
top: 60%;
right: 15%;
}
.floating-icon:nth-child(3) {
bottom: 30%;
left: 20%;
}
.floating-icon:nth-child(4) {
top: 40%;
right: 30%;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
opacity: 0.1;
}
50% {
transform: translateY(-20px) rotate(180deg);
opacity: 0.3;
}
}
/* Login Container */
.login-container {
position: relative;
z-index: 1;
width: 100%;
max-width: 400px;
padding: 2rem;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 2.5rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Header */
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.logo i {
font-size: 2rem;
color: #667eea;
}
.logo h1 {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
}
.subtitle {
color: #7f8c8d;
font-size: 0.9rem;
font-weight: 500;
}
/* Form Styles */
.login-form {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
font-size: 0.9rem;
}
.form-group label i {
color: #667eea;
width: 16px;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e1e8ed;
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.password-input {
position: relative;
}
.password-toggle {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #7f8c8d;
cursor: pointer;
padding: 0;
font-size: 1rem;
transition: color 0.2s ease;
}
.password-toggle:hover {
color: #667eea;
}
/* Checkbox */
.checkbox-label {
display: flex !important;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.9rem;
color: #7f8c8d;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid #e1e8ed;
border-radius: 4px;
position: relative;
transition: all 0.3s ease;
}
.checkbox-label input:checked + .checkmark {
background: #667eea;
border-color: #667eea;
}
.checkbox-label input:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 0.7rem;
}
/* Login Button */
.login-btn {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Loading State */
.login-btn.loading {
pointer-events: none;
}
.login-btn.loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Error Message */
.error-message {
background: #fadbd8;
border: 1px solid #f1948a;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #e74c3c;
font-size: 0.9rem;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Security Info */
.security-info {
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(102, 126, 234, 0.05);
border-radius: 8px;
border: 1px solid rgba(102, 126, 234, 0.1);
}
.security-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #7f8c8d;
text-align: center;
}
.security-item i {
color: #667eea;
font-size: 1rem;
}
/* Footer */
.login-footer {
text-align: center;
color: #95a5a6;
font-size: 0.8rem;
}
.version-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #bdc3c7;
}
/* Responsive */
@media (max-width: 480px) {
.login-container {
padding: 1rem;
}
.login-card {
padding: 2rem;
}
.security-info {
flex-direction: column;
gap: 1rem;
}
.security-item {
flex-direction: row;
justify-content: center;
}
.floating-icon {
display: none;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.login-card {
background: rgba(44, 62, 80, 0.95);
color: #ecf0f1;
}
.logo h1 {
color: #ecf0f1;
}
.form-group label {
color: #ecf0f1;
}
.form-group input {
background: rgba(52, 73, 94, 0.8);
border-color: #34495e;
color: #ecf0f1;
}
.form-group input::placeholder {
color: #95a5a6;
}
}

235
static/login.js Normal file
View File

@@ -0,0 +1,235 @@
// AI Server Admin Login JavaScript
class LoginManager {
constructor() {
this.baseUrl = window.location.origin;
this.init();
}
init() {
// Check if already logged in
this.checkExistingAuth();
// Setup form submission
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
// Setup enter key handling
document.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
// Auto-focus username field
document.getElementById('username').focus();
}
async checkExistingAuth() {
const token = localStorage.getItem('ai_admin_token');
if (token) {
try {
console.log('Checking existing token...');
// Verify token is still valid
const response = await fetch(`${this.baseUrl}/admin/verify-token`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
console.log('Token is valid, redirecting to admin...');
// Token is valid, redirect to admin
window.location.href = '/admin';
return;
} else {
console.log('Token verification failed with status:', response.status);
}
} catch (error) {
console.log('Token verification failed:', error);
}
// Token is invalid, remove it
console.log('Removing invalid token...');
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
}
}
async handleLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
// Validation
if (!username || !password) {
this.showError('Please enter both username and password');
return;
}
// Show loading state
this.setLoading(true);
this.hideError();
try {
const response = await fetch(`${this.baseUrl}/admin/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe
})
});
const data = await response.json();
if (response.ok && data.success) {
// Store JWT token
localStorage.setItem('ai_admin_token', data.token);
// Store user info
localStorage.setItem('ai_admin_user', JSON.stringify(data.user));
console.log('Token stored:', data.token.substring(0, 20) + '...');
console.log('User stored:', data.user);
// Show success message
this.showSuccess('Login successful! Redirecting...');
// Redirect after short delay
setTimeout(() => {
window.location.href = '/admin';
}, 1000);
} else {
this.showError(data.message || 'Login failed. Please check your credentials.');
}
} catch (error) {
console.error('Login error:', error);
this.showError('Connection error. Please try again.');
} finally {
this.setLoading(false);
}
}
setLoading(loading) {
const btn = document.getElementById('login-btn');
const icon = btn.querySelector('i');
if (loading) {
btn.disabled = true;
btn.classList.add('loading');
icon.className = 'fas fa-spinner';
btn.querySelector('span') ?
btn.querySelector('span').textContent = 'Signing In...' :
btn.innerHTML = '<i class="fas fa-spinner"></i> Signing In...';
} else {
btn.disabled = false;
btn.classList.remove('loading');
icon.className = 'fas fa-sign-in-alt';
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
}
}
showError(message) {
const errorDiv = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
errorText.textContent = message;
errorDiv.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
this.hideError();
}, 5000);
}
hideError() {
document.getElementById('error-message').style.display = 'none';
}
showSuccess(message) {
// Create success message element if it doesn't exist
let successDiv = document.getElementById('success-message');
if (!successDiv) {
successDiv = document.createElement('div');
successDiv.id = 'success-message';
successDiv.className = 'success-message';
successDiv.innerHTML = `
<i class="fas fa-check-circle"></i>
<span id="success-text">${message}</span>
`;
// Add CSS for success message
const style = document.createElement('style');
style.textContent = `
.success-message {
background: #d5f4e6;
border: 1px solid #27ae60;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #27ae60;
font-size: 0.9rem;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
document.head.appendChild(style);
// Insert before error message
const errorDiv = document.getElementById('error-message');
errorDiv.parentNode.insertBefore(successDiv, errorDiv);
} else {
document.getElementById('success-text').textContent = message;
successDiv.style.display = 'flex';
}
}
}
// Password toggle functionality
function togglePassword() {
const passwordInput = document.getElementById('password');
const passwordEye = document.getElementById('password-eye');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
passwordEye.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
passwordEye.className = 'fas fa-eye';
}
}
// Initialize login manager when page loads
document.addEventListener('DOMContentLoaded', () => {
new LoginManager();
});
// Security: Clear sensitive data on page unload
window.addEventListener('beforeunload', () => {
// Clear password field
const passwordField = document.getElementById('password');
if (passwordField) {
passwordField.value = '';
}
});

248
templates/admin.html Normal file
View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Server Admin Dashboard</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/admin.css" rel="stylesheet">
</head>
<body>
<div class="admin-layout">
<!-- Header -->
<header class="admin-header">
<div class="header-content">
<h1><i class="fas fa-robot"></i> AI Server Admin</h1>
<div class="header-info">
<span class="server-status online">
<i class="fas fa-circle"></i> Online
</span>
<span class="current-time" id="current-time"></span>
<div class="user-menu">
<span class="user-info" id="user-info">
<i class="fas fa-user"></i>
<span id="username">Loading...</span>
</span>
<button class="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i>
Logout
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="admin-main">
<!-- System Status Dashboard -->
<section class="dashboard-section">
<h2><i class="fas fa-tachometer-alt"></i> System Status</h2>
<div class="status-grid">
<div class="status-card">
<div class="card-header">
<i class="fas fa-server"></i>
<h3>AI Server</h3>
</div>
<div class="card-content">
<div class="status-value" id="server-status">Loading...</div>
<div class="status-detail">Port: {{ server_port }}</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-brain"></i>
<h3>Ollama</h3>
</div>
<div class="card-content">
<div class="status-value" id="ollama-status">Loading...</div>
<div class="status-detail" id="ollama-host">{{ ollama_host }}</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-microchip"></i>
<h3>Active Model</h3>
</div>
<div class="card-content">
<div class="status-value" id="active-model">Loading...</div>
<div class="status-detail">Base Model</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-chart-line"></i>
<h3>API Calls</h3>
</div>
<div class="card-content">
<div class="status-value" id="api-calls">Loading...</div>
<div class="status-detail">Today</div>
</div>
</div>
</div>
</section>
<!-- Model Management -->
<section class="dashboard-section">
<h2><i class="fas fa-cogs"></i> Model Management</h2>
<div class="models-container">
<div class="models-header">
<button class="btn btn-primary" onclick="refreshModels()">
<i class="fas fa-sync"></i> Refresh
</button>
<button class="btn btn-success" onclick="openModelDownload()">
<i class="fas fa-download"></i> Download Model
</button>
</div>
<div class="models-table">
<table>
<thead>
<tr>
<th>Model Name</th>
<th>Size</th>
<th>Status</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="models-tbody">
<tr>
<td colspan="5" class="loading">Loading models...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- System Monitoring (Phase 2) -->
<section class="dashboard-section">
<h2><i class="fas fa-chart-line"></i> System Monitoring</h2>
<div class="monitoring-container">
<div class="monitoring-grid">
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-microchip"></i>
<h3>CPU Usage</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="cpu-progress">
<span class="progress-text" id="cpu-text">--</span>
</div>
<div class="monitor-details">
<span id="cpu-cores">-- cores</span>
</div>
</div>
</div>
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-memory"></i>
<h3>Memory Usage</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="memory-progress">
<span class="progress-text" id="memory-text">--</span>
</div>
<div class="monitor-details">
<span id="memory-details">-- / -- GB</span>
</div>
</div>
</div>
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-hdd"></i>
<h3>Disk Usage</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="disk-progress">
<span class="progress-text" id="disk-text">--</span>
</div>
<div class="monitor-details">
<span id="disk-details">-- / -- GB</span>
</div>
</div>
</div>
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-thermometer-half"></i>
<h3>GPU Status</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="gpu-progress">
<span class="progress-text" id="gpu-text">--</span>
</div>
<div class="monitor-details">
<span id="gpu-details">No GPU detected</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- API Key Management -->
<section class="dashboard-section">
<h2><i class="fas fa-key"></i> API Key Management</h2>
<div class="api-keys-container">
<div class="api-keys-header">
<button class="btn btn-success" onclick="generateApiKey()">
<i class="fas fa-plus"></i> Generate New Key
</button>
</div>
<div class="api-keys-list" id="api-keys-list">
<div class="loading">Loading API keys...</div>
</div>
</div>
</section>
</main>
</div>
<!-- Model Download Modal -->
<div id="model-download-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-download"></i> Download Model</h3>
<button class="close-btn" onclick="closeModal('model-download-modal')">&times;</button>
</div>
<div class="modal-body">
<div id="available-models-list">
<div class="loading">Loading available models...</div>
</div>
</div>
</div>
</div>
<!-- Model Delete Confirmation Modal -->
<div id="model-delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-trash"></i> Delete Model</h3>
<button class="close-btn" onclick="closeModal('model-delete-modal')">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this model?</p>
<div class="model-delete-info">
<strong id="delete-model-name">Model Name</strong>
<p>This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button class="btn btn-danger" id="confirm-delete-btn">
<i class="fas fa-trash"></i> Delete
</button>
<button class="btn btn-secondary" onclick="closeModal('model-delete-modal')">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/admin.js"></script>
</body>
</html>

39
templates/base.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI 문서 처리 서버{% endblock %}</title>
<link href="/static/css/style.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="nav-logo">
<i class="fas fa-robot"></i> AI 문서 서버
</a>
<ul class="nav-menu">
<li><a href="/" class="nav-link">대시보드</a></li>
<li><a href="/upload" class="nav-link">업로드</a></li>
<li><a href="/documents" class="nav-link">문서관리</a></li>
<li><a href="/chat" class="nav-link">AI 챗봇</a></li>
<li><a href="/docs" class="nav-link" target="_blank">API 문서</a></li>
</ul>
</div>
</nav>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="footer-content">
<p>&copy; 2025 AI 문서 처리 서버 | Mac Mini M4 Pro 64GB</p>
</div>
</footer>
<script src="/static/js/main.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

631
templates/chat.html Normal file
View File

@@ -0,0 +1,631 @@
{% extends "base.html" %}
{% block title %}AI 챗봇 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="grid grid-2">
<!-- 채팅 영역 -->
<div class="card chat-card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-robot"></i> AI 문서 챗봇
</h2>
<div class="chat-status">
<span class="status status-success">
<i class="fas fa-circle"></i> 온라인
</span>
</div>
</div>
<div class="chat-container">
<div class="chat-messages" id="chatMessages">
<div class="message assistant">
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="message-text">
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
</div>
</div>
<div class="message-time">{{ current_time }}</div>
</div>
</div>
<div class="chat-input-area">
<div class="input-group">
<input type="text" class="form-control chat-input" id="messageInput"
placeholder="메시지를 입력하세요..." maxlength="1000">
<button type="button" class="btn btn-primary" id="sendBtn" onclick="sendMessage()">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div class="input-options">
<label class="checkbox-label">
<input type="checkbox" id="useRAG" checked>
<i class="fas fa-search"></i> 문서 검색 사용
</label>
<label class="checkbox-label">
<input type="checkbox" id="forceBoost">
<i class="fas fa-rocket"></i> 고성능 모델 사용
</label>
</div>
</div>
</div>
</div>
<!-- 설정 및 정보 패널 -->
<div class="settings-panel">
<!-- 빠른 질문 -->
<div class="card">
<h3><i class="fas fa-lightning-bolt"></i> 빠른 질문</h3>
<div class="quick-questions">
<button onclick="askQuickQuestion('전체 문서를 요약해주세요')" class="btn btn-sm btn-outline">
문서 요약
</button>
<button onclick="askQuickQuestion('주요 키워드를 추출해주세요')" class="btn btn-sm btn-outline">
키워드 추출
</button>
<button onclick="askQuickQuestion('이 문서의 핵심 내용은 무엇인가요?')" class="btn btn-sm btn-outline">
핵심 내용
</button>
<button onclick="askQuickQuestion('관련된 다른 문서가 있나요?')" class="btn btn-sm btn-outline">
관련 문서
</button>
</div>
</div>
<!-- 검색 결과 -->
<div class="card">
<h3><i class="fas fa-search"></i> 관련 문서 검색</h3>
<input type="text" class="form-control mb-2" id="searchInput"
placeholder="문서 내용 검색..." onkeyup="searchDocuments(event)">
<div class="search-results" id="searchResults">
<p class="text-muted">검색어를 입력하세요</p>
</div>
</div>
<!-- 모델 정보 -->
<div class="card">
<h3><i class="fas fa-cog"></i> 모델 설정</h3>
<div class="model-info">
<div class="info-item">
<strong>기본 모델:</strong> {{ status.base_model }}
</div>
<div class="info-item">
<strong>부스트 모델:</strong> {{ status.boost_model }}
</div>
<div class="info-item">
<strong>임베딩 모델:</strong> {{ status.embedding_model }}
</div>
<div class="info-item">
<strong>인덱스 문서:</strong> {{ status.index_loaded }}개
</div>
</div>
</div>
<!-- 채팅 기록 관리 -->
<div class="card">
<h3><i class="fas fa-history"></i> 대화 관리</h3>
<div class="chat-controls">
<button onclick="clearChat()" class="btn btn-sm btn-secondary w-100 mb-2">
<i class="fas fa-trash"></i> 대화 기록 삭제
</button>
<button onclick="exportChat()" class="btn btn-sm btn-outline w-100">
<i class="fas fa-download"></i> 대화 내용 내보내기
</button>
</div>
</div>
</div>
</div>
<style>
.chat-card {
height: 600px;
display: flex;
flex-direction: column;
}
.chat-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.chat-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.message {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.message-content {
display: flex;
align-items: flex-start;
gap: 0.5rem;
max-width: 80%;
}
.message.user .message-content {
flex-direction: row-reverse;
}
.message-icon {
font-size: 1.2rem;
padding: 0.5rem;
border-radius: 50%;
min-width: 40px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.message.user .message-icon {
background-color: #667eea;
color: white;
}
.message.assistant .message-icon {
background-color: #28a745;
color: white;
}
.message-text {
background: white;
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid #e9ecef;
line-height: 1.5;
}
.message.user .message-text {
background: #667eea;
color: white;
border-color: #667eea;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
margin-top: 0.25rem;
margin-left: 3rem;
}
.message.user .message-time {
margin-left: 0;
margin-right: 3rem;
text-align: right;
}
.chat-input-area {
flex-shrink: 0;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.input-group .form-control {
flex: 1;
}
.input-options {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
color: #6c757d;
}
.settings-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-questions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.btn-outline {
background: transparent;
border: 1px solid #667eea;
color: #667eea;
text-align: left;
padding: 0.5rem;
}
.btn-outline:hover {
background: #667eea;
color: white;
}
.search-results {
max-height: 200px;
overflow-y: auto;
}
.search-result-item {
padding: 0.5rem;
border: 1px solid #e9ecef;
border-radius: 5px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.search-result-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.search-result-snippet {
font-size: 0.85rem;
color: #6c757d;
line-height: 1.4;
}
.info-item {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f8f9fa;
}
.info-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.chat-controls .btn {
justify-content: center;
display: flex;
align-items: center;
gap: 0.5rem;
}
.w-100 { width: 100%; }
.mb-2 { margin-bottom: 0.5rem; }
.text-muted { color: #6c757d; }
/* 로딩 애니메이션 */
.typing-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: white;
border-radius: 10px;
border: 1px solid #e9ecef;
}
.typing-dot {
width: 8px;
height: 8px;
background-color: #6c757d;
border-radius: 50%;
animation: typing 1.5s infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
</style>
{% endblock %}
{% block scripts %}
<script>
let chatHistory = [];
let isWaitingForResponse = false;
// DOM 요소들
const chatMessages = document.getElementById('chatMessages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
const useRAGCheckbox = document.getElementById('useRAG');
const forceBoostCheckbox = document.getElementById('forceBoost');
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// URL 파라미터 확인 (특정 문서로 대화하기)
const urlParams = new URLSearchParams(window.location.search);
const doc = urlParams.get('doc');
if (doc) {
addMessage('assistant', `"${doc}" 문서에 대해 질문해주세요. 어떤 내용이 궁금하신가요?`);
}
});
// 메시지 전송
async function sendMessage() {
const message = messageInput.value.trim();
if (!message || isWaitingForResponse) return;
// 사용자 메시지 추가
addMessage('user', message);
messageInput.value = '';
isWaitingForResponse = true;
sendBtn.disabled = true;
// 타이핑 인디케이터 표시
const typingId = addTypingIndicator();
try {
const response = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': '{{ api_key }}'
},
body: JSON.stringify({
messages: chatHistory,
use_rag: useRAGCheckbox.checked,
force_boost: forceBoostCheckbox.checked,
top_k: 5
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// 타이핑 인디케이터 제거
removeTypingIndicator(typingId);
// AI 응답 추가
addMessage('assistant', data.response.message?.content || data.response.response || '응답을 받지 못했습니다.');
} catch (error) {
console.error('Chat error:', error);
removeTypingIndicator(typingId);
addMessage('assistant', '죄송합니다. 응답 중 오류가 발생했습니다. 다시 시도해주세요.');
} finally {
isWaitingForResponse = false;
sendBtn.disabled = false;
}
}
// 메시지 추가
function addMessage(sender, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}`;
const now = new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
});
const icon = sender === 'user' ? 'fas fa-user' : 'fas fa-robot';
messageDiv.innerHTML = `
<div class="message-content">
<i class="${icon} message-icon"></i>
<div class="message-text">${content}</div>
</div>
<div class="message-time">${now}</div>
`;
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// 채팅 히스토리에 추가
chatHistory.push({
role: sender === 'user' ? 'user' : 'assistant',
content: content
});
}
// 타이핑 인디케이터 추가
function addTypingIndicator() {
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.id = 'typing-' + Date.now();
typingDiv.innerHTML = `
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
`;
chatMessages.appendChild(typingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
return typingDiv.id;
}
// 타이핑 인디케이터 제거
function removeTypingIndicator(typingId) {
const typingDiv = document.getElementById(typingId);
if (typingDiv) {
typingDiv.remove();
}
}
// 빠른 질문
function askQuickQuestion(question) {
messageInput.value = question;
sendMessage();
}
// 문서 검색
async function searchDocuments(event) {
if (event.key !== 'Enter') return;
const query = searchInput.value.trim();
if (!query) {
searchResults.innerHTML = '<p class="text-muted">검색어를 입력하세요</p>';
return;
}
try {
const response = await fetch('/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
top_k: 5
})
});
const data = await response.json();
displaySearchResults(data.results);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<p class="text-muted">검색 중 오류가 발생했습니다</p>';
}
}
// 검색 결과 표시
function displaySearchResults(results) {
if (!results || results.length === 0) {
searchResults.innerHTML = '<p class="text-muted">검색 결과가 없습니다</p>';
return;
}
const resultsHtml = results.map(result => `
<div class="search-result-item" onclick="useSearchResult('${result.text.replace(/'/g, "\\'")}')">
<div class="search-result-title">문서 ID: ${result.id}</div>
<div class="search-result-snippet">${result.text.substring(0, 100)}...</div>
<small class="text-muted">유사도: ${(result.score * 100).toFixed(1)}%</small>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
}
// 검색 결과 활용
function useSearchResult(text) {
messageInput.value = `다음 내용에 대해 설명해주세요: "${text.substring(0, 50)}..."`;
}
// 대화 기록 삭제
function clearChat() {
if (!confirm('정말로 대화 기록을 모두 삭제하시겠습니까?')) return;
chatHistory = [];
chatMessages.innerHTML = `
<div class="message assistant">
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="message-text">
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
</div>
</div>
<div class="message-time">${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}</div>
</div>
`;
}
// 대화 내용 내보내기
function exportChat() {
if (chatHistory.length === 0) {
showToast('내보낼 대화 내용이 없습니다.', 'info');
return;
}
const chatText = chatHistory.map(msg =>
`${msg.role === 'user' ? '사용자' : 'AI'}: ${msg.content}`
).join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// 토스트 알림
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
</script>
{% endblock %}

348
templates/documents.html Normal file
View File

@@ -0,0 +1,348 @@
{% extends "base.html" %}
{% block title %}문서 관리 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-folder-open"></i> 문서 관리
</h1>
<p class="card-subtitle">처리된 문서들을 확인하고 관리하세요</p>
</div>
<!-- 통계 요약 -->
<div class="grid grid-3 mb-4">
<div class="card">
<h3><i class="fas fa-file-alt"></i> 총 문서 수</h3>
<div class="stat-number">{{ documents|length }}</div>
</div>
<div class="card">
<h3><i class="fas fa-database"></i> 총 용량</h3>
<div class="stat-number" id="totalSize">계산 중...</div>
</div>
<div class="card">
<h3><i class="fas fa-calendar"></i> 최근 업로드</h3>
<div class="stat-number">
{% if documents %}
{{ documents[0].created.split(' ')[0] }}
{% else %}
없음
{% endif %}
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="search-controls mb-4">
<div class="grid grid-2">
<div>
<input type="text" class="form-control" id="searchInput"
placeholder="파일명으로 검색..." onkeyup="filterDocuments()">
</div>
<div>
<select class="form-control" id="sortSelect" onchange="sortDocuments()">
<option value="name">이름순</option>
<option value="date" selected>날짜순 (최신)</option>
<option value="size">크기순</option>
</select>
</div>
</div>
</div>
<!-- 문서 목록 -->
{% if documents %}
<div class="table-responsive">
<table class="table" id="documentsTable">
<thead>
<tr>
<th><i class="fas fa-file"></i> 파일명</th>
<th><i class="fas fa-calendar"></i> 생성일시</th>
<th><i class="fas fa-weight"></i> 크기</th>
<th><i class="fas fa-cogs"></i> 액션</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr class="document-row" data-name="{{ doc.name|lower }}" data-date="{{ doc.created }}" data-size="{{ doc.size }}">
<td>
<div class="file-info">
<i class="fas fa-file-alt text-primary"></i>
<strong>{{ doc.name }}</strong>
<br>
<small class="text-muted">{{ formatFileSize(doc.size) }}</small>
</div>
</td>
<td>{{ doc.created }}</td>
<td>
<span class="file-size" data-bytes="{{ doc.size }}">{{ formatFileSize(doc.size) }}</span>
</td>
<td>
<div class="action-buttons">
<a href="/html/{{ doc.name }}" class="btn btn-sm btn-primary" target="_blank" title="HTML 보기">
<i class="fas fa-eye"></i>
</a>
<button onclick="openChatWithDoc('{{ doc.name }}')" class="btn btn-sm btn-success" title="이 문서로 대화하기">
<i class="fas fa-comments"></i>
</button>
<button onclick="downloadDoc('{{ doc.name }}')" class="btn btn-sm btn-secondary" title="다운로드">
<i class="fas fa-download"></i>
</button>
<button onclick="deleteDoc('{{ doc.name }}')" class="btn btn-sm btn-danger" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
<h3>처리된 문서가 없습니다</h3>
<p class="text-muted mb-4">파일을 업로드하여 AI 처리를 시작하세요.</p>
<a href="/upload" class="btn btn-primary btn-lg">
<i class="fas fa-cloud-upload-alt"></i> 첫 문서 업로드하기
</a>
</div>
{% endif %}
</div>
<!-- 파일 미리보기 모달 -->
<div class="modal" id="previewModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="previewTitle">문서 미리보기</h3>
<button onclick="closePreview()" class="btn btn-sm btn-secondary">
<i class="fas fa-times"></i> 닫기
</button>
</div>
<div class="modal-body">
<iframe id="previewFrame" style="width: 100%; height: 500px; border: none;"></iframe>
</div>
</div>
</div>
<style>
.search-controls {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-info i {
font-size: 1.2rem;
}
.action-buttons {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 10px;
width: 90%;
max-width: 900px;
max-height: 90%;
overflow: hidden;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 1.5rem;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin-top: 0.5rem;
}
.mb-4 { margin-bottom: 2rem; }
.text-primary { color: #667eea; }
</style>
{% endblock %}
{% block scripts %}
<script>
let allDocuments = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
// 문서 데이터 수집
const rows = document.querySelectorAll('.document-row');
allDocuments = Array.from(rows).map(row => ({
element: row,
name: row.dataset.name,
date: new Date(row.dataset.date),
size: parseInt(row.dataset.size)
}));
// 총 용량 계산
const totalBytes = allDocuments.reduce((sum, doc) => sum + doc.size, 0);
document.getElementById('totalSize').textContent = formatFileSize(totalBytes);
// 기본 정렬 (날짜순)
sortDocuments();
});
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 문서 검색 필터링
function filterDocuments() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('.document-row');
rows.forEach(row => {
const fileName = row.dataset.name;
if (fileName.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
// 문서 정렬
function sortDocuments() {
const sortBy = document.getElementById('sortSelect').value;
const tbody = document.querySelector('#documentsTable tbody');
allDocuments.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'date':
return b.date - a.date; // 최신순
case 'size':
return b.size - a.size; // 큰 것부터
default:
return 0;
}
});
// DOM 재정렬
allDocuments.forEach(doc => {
tbody.appendChild(doc.element);
});
}
// 문서로 채팅 시작
function openChatWithDoc(fileName) {
const docName = fileName.replace('.html', '');
window.location.href = `/chat?doc=${encodeURIComponent(docName)}`;
}
// 문서 다운로드
function downloadDoc(fileName) {
const link = document.createElement('a');
link.href = `/html/${fileName}`;
link.download = fileName;
link.click();
}
// 문서 삭제
async function deleteDoc(fileName) {
if (!confirm(`정말로 "${fileName}" 문서를 삭제하시겠습니까?`)) {
return;
}
try {
// 실제로는 DELETE API를 구현해야 함
showToast('삭제 기능은 아직 구현되지 않았습니다.', 'info');
// TODO: DELETE /api/documents/{fileName} 구현
} catch (error) {
console.error('Delete error:', error);
showToast('삭제 중 오류가 발생했습니다.', 'danger');
}
}
// 미리보기 열기
function openPreview(fileName) {
document.getElementById('previewTitle').textContent = `${fileName} 미리보기`;
document.getElementById('previewFrame').src = `/html/${fileName}`;
document.getElementById('previewModal').style.display = 'flex';
}
// 미리보기 닫기
function closePreview() {
document.getElementById('previewModal').style.display = 'none';
document.getElementById('previewFrame').src = '';
}
// 토스트 알림 (main.js에서 가져옴)
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
</script>
{% endblock %}

229
templates/index.html Normal file
View File

@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% block title %}AI 문서 처리 서버 - 대시보드{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-tachometer-alt"></i> 대시보드
</h1>
<p class="card-subtitle">AI 문서 처리 서버 현황을 확인하세요</p>
</div>
<!-- 시스템 상태 -->
<div class="grid grid-3">
<div class="card">
<h3><i class="fas fa-server"></i> 서버 상태</h3>
<div class="status status-success">
<i class="fas fa-check-circle"></i> 정상 운영
</div>
<p class="mt-2">
<strong>모델:</strong> {{ status.base_model }}<br>
<strong>임베딩:</strong> {{ status.embedding_model }}<br>
<strong>인덱스:</strong> {{ status.index_loaded }}개 문서
</p>
</div>
<div class="card">
<h3><i class="fas fa-upload"></i> 빠른 업로드</h3>
<div class="drop-zone-mini" onclick="window.location.href='/upload'">
<i class="fas fa-cloud-upload-alt"></i>
<p>파일을 업로드하려면 클릭하세요</p>
</div>
</div>
<div class="card">
<h3><i class="fas fa-search"></i> 빠른 검색</h3>
<form onsubmit="quickSearch(event)">
<input type="text" class="form-control" placeholder="문서 내용 검색..." id="quickSearchInput">
<button type="submit" class="btn btn-primary mt-2 w-100">
<i class="fas fa-search"></i> 검색
</button>
</form>
</div>
</div>
</div>
<!-- 최근 문서 -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-file-alt"></i> 최근 처리된 문서
</h2>
<a href="/documents" class="btn btn-secondary">모든 문서 보기</a>
</div>
{% if recent_documents %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>문서 ID</th>
<th>처리 시간</th>
<th>청크 수</th>
<th>상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for doc in recent_documents %}
<tr>
<td>{{ doc.id }}</td>
<td>{{ doc.created_at }}</td>
<td>{{ doc.chunks }}</td>
<td>
<span class="status status-success">
<i class="fas fa-check"></i> 완료
</span>
</td>
<td>
<a href="/html/{{ doc.html_file }}" class="btn btn-sm btn-primary" target="_blank">
<i class="fas fa-eye"></i> 보기
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">아직 처리된 문서가 없습니다.</p>
<a href="/upload" class="btn btn-primary">첫 문서 업로드하기</a>
</div>
{% endif %}
</div>
<!-- 통계 -->
<div class="grid grid-2">
<div class="card">
<h3><i class="fas fa-chart-bar"></i> 처리 통계</h3>
<div class="stats">
<div class="stat-item">
<span class="stat-number">{{ stats.total_documents }}</span>
<span class="stat-label">총 문서 수</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ stats.total_chunks }}</span>
<span class="stat-label">총 청크 수</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ stats.today_processed }}</span>
<span class="stat-label">오늘 처리</span>
</div>
</div>
</div>
<div class="card">
<h3><i class="fas fa-robot"></i> AI 챗봇</h3>
<p>문서 기반 질의응답을 시작하세요.</p>
<div class="quick-chat">
<input type="text" class="form-control mb-2" placeholder="질문을 입력하세요..." id="quickChatInput">
<button onclick="quickChat()" class="btn btn-success w-100">
<i class="fas fa-paper-plane"></i> 질문하기
</button>
</div>
<a href="/chat" class="btn btn-secondary w-100 mt-2">전체 채팅 화면으로</a>
</div>
</div>
<style>
.drop-zone-mini {
border: 2px dashed #ced4da;
border-radius: 10px;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background-color: #f8f9fa;
}
.drop-zone-mini:hover {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.05);
}
.drop-zone-mini i {
font-size: 2rem;
color: #6c757d;
margin-bottom: 0.5rem;
display: block;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
}
.stat-number {
display: block;
font-size: 2rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.85rem;
color: #6c757d;
}
.mt-2 { margin-top: 0.5rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.py-4 { padding: 2rem 0; }
.w-100 { width: 100%; }
.text-center { text-align: center; }
.text-muted { color: #6c757d; }
.table-responsive { overflow-x: auto; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
</style>
{% endblock %}
{% block scripts %}
<script>
function quickSearch(event) {
event.preventDefault();
const query = document.getElementById('quickSearchInput').value;
if (query.trim()) {
window.location.href = `/search?q=${encodeURIComponent(query)}`;
}
}
function quickChat() {
const question = document.getElementById('quickChatInput').value;
if (question.trim()) {
// 간단한 챗봇 미리보기 (실제 구현은 /chat 페이지에서)
alert('채팅 기능은 "AI 챗봇" 페이지에서 이용하실 수 있습니다.');
window.location.href = '/chat';
}
}
// 페이지 로드 시 서버 상태 업데이트
document.addEventListener('DOMContentLoaded', function() {
// 실시간 상태 업데이트 (선택사항)
updateStatus();
});
async function updateStatus() {
try {
const response = await fetch('/health');
const data = await response.json();
// 상태 업데이트 로직
console.log('서버 상태:', data);
} catch (error) {
console.error('상태 확인 실패:', error);
}
}
</script>
{% endblock %}

124
templates/login.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Server Admin - Login</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/login.css" rel="stylesheet">
</head>
<body>
<div class="login-container">
<div class="login-card">
<!-- Header -->
<div class="login-header">
<div class="logo">
<i class="fas fa-robot"></i>
<h1>AI Server Admin</h1>
</div>
<p class="subtitle">Secure Access Portal</p>
</div>
<!-- Login Form -->
<form id="login-form" class="login-form">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
Username
</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="Enter your username"
>
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
Password
</label>
<div class="password-input">
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
>
<button type="button" class="password-toggle" onclick="togglePassword()">
<i class="fas fa-eye" id="password-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="remember">
<span class="checkmark"></span>
Remember me for 30 days
</label>
</div>
<button type="submit" class="login-btn" id="login-btn">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<!-- Error Message -->
<div id="error-message" class="error-message" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="error-text">Invalid credentials</span>
</div>
<!-- Security Info -->
<div class="security-info">
<div class="security-item">
<i class="fas fa-shield-alt"></i>
<span>Secure Connection</span>
</div>
<div class="security-item">
<i class="fas fa-clock"></i>
<span>Session Timeout: 24h</span>
</div>
<div class="security-item">
<i class="fas fa-key"></i>
<span>JWT Authentication</span>
</div>
</div>
<!-- Footer -->
<div class="login-footer">
<p>&copy; 2025 AI Server Admin. All rights reserved.</p>
<div class="version-info">
<span>Version 2.0 (Phase 3)</span>
</div>
</div>
</div>
<!-- Background Animation -->
<div class="bg-animation">
<div class="floating-icon" style="--delay: 0s;">
<i class="fas fa-robot"></i>
</div>
<div class="floating-icon" style="--delay: 2s;">
<i class="fas fa-brain"></i>
</div>
<div class="floating-icon" style="--delay: 4s;">
<i class="fas fa-microchip"></i>
</div>
<div class="floating-icon" style="--delay: 6s;">
<i class="fas fa-network-wired"></i>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/login.js"></script>
</body>
</html>

395
templates/upload.html Normal file
View File

@@ -0,0 +1,395 @@
{% extends "base.html" %}
{% block title %}파일 업로드 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-cloud-upload-alt"></i> 파일 업로드
</h1>
<p class="card-subtitle">PDF 또는 TXT 파일을 업로드하여 AI 처리를 시작하세요</p>
</div>
<form id="uploadForm" enctype="multipart/form-data">
<div class="grid grid-2">
<!-- 파일 선택 영역 -->
<div>
<h3><i class="fas fa-file"></i> 파일 선택</h3>
<div class="drop-zone" id="dropZone">
<i class="fas fa-cloud-upload-alt"></i>
<h4>파일을 드래그하거나 클릭하여 선택</h4>
<p>PDF, TXT 파일 지원 (최대 200MB)</p>
<input type="file" id="fileInput" name="file" accept=".pdf,.txt" style="display: none;">
<button type="button" class="btn btn-primary mt-2" onclick="document.getElementById('fileInput').click()">
<i class="fas fa-folder-open"></i> 파일 선택
</button>
</div>
<div id="fileInfo" class="file-info" style="display: none;">
<h4><i class="fas fa-file-check"></i> 선택된 파일</h4>
<div id="fileDetails"></div>
<button type="button" class="btn btn-secondary btn-sm mt-2" onclick="clearFile()">
<i class="fas fa-times"></i> 다른 파일 선택
</button>
</div>
</div>
<!-- 옵션 설정 -->
<div>
<h3><i class="fas fa-cogs"></i> 처리 옵션</h3>
<div class="form-group">
<label class="form-label" for="docId">문서 ID</label>
<input type="text" class="form-control" id="docId" name="doc_id"
placeholder="예: report-2025-001" required>
<small class="text-muted">고유한 문서 식별자를 입력하세요</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="generateHtml" name="generate_html" checked>
<i class="fas fa-code"></i> HTML 파일 생성
</label>
<small class="text-muted">읽기 쉬운 HTML 형태로 변환합니다</small>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="translate" name="translate">
<i class="fas fa-language"></i> 한국어로 번역
</label>
<small class="text-muted">영어 문서를 한국어로 번역합니다</small>
</div>
</div>
<div class="form-group" id="targetLangGroup" style="display: none;">
<label class="form-label" for="targetLanguage">번역 언어</label>
<select class="form-control" id="targetLanguage" name="target_language">
<option value="ko">한국어</option>
<option value="en">영어</option>
<option value="ja">일본어</option>
<option value="zh">중국어</option>
</select>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="summarize" name="summarize">
<i class="fas fa-compress-alt"></i> 요약 생성
</label>
<small class="text-muted">긴 문서를 요약하여 처리합니다</small>
</div>
</div>
<div class="form-group" id="summaryGroup" style="display: none;">
<label class="form-label" for="summarySentences">요약 문장 수</label>
<input type="number" class="form-control" id="summarySentences"
name="summary_sentences" value="5" min="3" max="10">
</div>
</div>
</div>
<div class="upload-actions">
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn" disabled>
<i class="fas fa-rocket"></i> 업로드 및 처리 시작
</button>
<button type="button" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-redo"></i> 초기화
</button>
</div>
</form>
<!-- 진행 상황 -->
<div id="progressSection" style="display: none;">
<h3><i class="fas fa-tasks"></i> 처리 진행 상황</h3>
<div class="progress">
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
</div>
<div id="progressText">준비 중...</div>
</div>
<!-- 결과 -->
<div id="resultSection" style="display: none;">
<h3><i class="fas fa-check-circle"></i> 처리 완료</h3>
<div id="resultContent"></div>
</div>
</div>
<style>
.checkbox-group {
margin-bottom: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
margin: 0;
}
.file-info {
margin-top: 1rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.file-details {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.upload-actions {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e9ecef;
display: flex;
gap: 1rem;
justify-content: center;
}
#progressSection {
margin-top: 2rem;
padding: 1.5rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
#resultSection {
margin-top: 2rem;
padding: 1.5rem;
background-color: #d4edda;
border-radius: 8px;
border: 1px solid #c3e6cb;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.1rem;
}
</style>
{% endblock %}
{% block scripts %}
<script>
let selectedFile = null;
// DOM 요소들
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileDetails = document.getElementById('fileDetails');
const uploadBtn = document.getElementById('uploadBtn');
const uploadForm = document.getElementById('uploadForm');
const progressSection = document.getElementById('progressSection');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultSection = document.getElementById('resultSection');
const resultContent = document.getElementById('resultContent');
// 체크박스 이벤트
document.getElementById('translate').addEventListener('change', function() {
const targetLangGroup = document.getElementById('targetLangGroup');
targetLangGroup.style.display = this.checked ? 'block' : 'none';
});
document.getElementById('summarize').addEventListener('change', function() {
const summaryGroup = document.getElementById('summaryGroup');
summaryGroup.style.display = this.checked ? 'block' : 'none';
});
// 드래그 앤 드롭 이벤트
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelect(files[0]);
}
});
// 파일 선택 이벤트
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
// 파일 처리 함수
function handleFileSelect(file) {
// 파일 타입 검증
const allowedTypes = ['application/pdf', 'text/plain'];
if (!allowedTypes.includes(file.type) &&
!file.name.toLowerCase().endsWith('.pdf') &&
!file.name.toLowerCase().endsWith('.txt')) {
showToast('PDF 또는 TXT 파일만 업로드할 수 있습니다.', 'danger');
return;
}
// 파일 크기 검증 (200MB)
if (file.size > 200 * 1024 * 1024) {
showToast('파일 크기는 200MB를 초과할 수 없습니다.', 'danger');
return;
}
selectedFile = file;
// 파일 정보 표시
fileDetails.innerHTML = `
<div class="file-details">
<div><strong>파일명:</strong> ${file.name}</div>
<div><strong>크기:</strong> ${formatFileSize(file.size)}</div>
<div><strong>타입:</strong> ${file.type || '알 수 없음'}</div>
</div>
`;
dropZone.style.display = 'none';
fileInfo.style.display = 'block';
uploadBtn.disabled = false;
// 문서 ID 자동 생성
const timestamp = new Date().toISOString().slice(0, 10);
const baseName = file.name.replace(/\.[^/.]+$/, "");
document.getElementById('docId').value = `${baseName}-${timestamp}`;
}
// 파일 선택 취소
function clearFile() {
selectedFile = null;
fileInput.value = '';
dropZone.style.display = 'block';
fileInfo.style.display = 'none';
uploadBtn.disabled = true;
progressSection.style.display = 'none';
resultSection.style.display = 'none';
}
// 폼 초기화
function resetForm() {
clearFile();
uploadForm.reset();
document.getElementById('generateHtml').checked = true;
document.getElementById('targetLangGroup').style.display = 'none';
document.getElementById('summaryGroup').style.display = 'none';
}
// 폼 제출
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile) {
showToast('파일을 선택해주세요.', 'danger');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('doc_id', document.getElementById('docId').value);
formData.append('generate_html', document.getElementById('generateHtml').checked);
formData.append('translate', document.getElementById('translate').checked);
formData.append('target_language', document.getElementById('targetLanguage').value);
// 진행 상황 표시
progressSection.style.display = 'block';
resultSection.style.display = 'none';
setLoading(uploadBtn);
try {
updateProgress(20, '파일 업로드 중...');
const response = await fetch('/pipeline/ingest_file', {
method: 'POST',
headers: {
'X-API-Key': '{{ api_key }}'
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
updateProgress(80, '처리 중...');
const result = await response.json();
updateProgress(100, '완료!');
// 결과 표시
setTimeout(() => {
showResult(result);
}, 500);
showToast('파일이 성공적으로 처리되었습니다!', 'success');
} catch (error) {
console.error('Upload error:', error);
showToast('업로드 중 오류가 발생했습니다: ' + error.message, 'danger');
progressSection.style.display = 'none';
} finally {
uploadBtn.disabled = false;
uploadBtn.innerHTML = '<i class="fas fa-rocket"></i> 업로드 및 처리 시작';
}
});
function updateProgress(percent, text) {
progressBar.style.width = percent + '%';
progressText.textContent = text;
}
function showResult(result) {
resultContent.innerHTML = `
<div class="result-details">
<h4><i class="fas fa-info-circle"></i> 처리 결과</h4>
<div class="grid grid-2" style="margin-top: 1rem;">
<div>
<strong>문서 ID:</strong> ${result.doc_id}<br>
<strong>처리된 청크:</strong> ${result.added}개<br>
<strong>전체 청크:</strong> ${result.chunks}
</div>
<div class="result-actions">
${result.html_path ? `
<a href="/html/${result.html_path.split('/').pop()}"
class="btn btn-primary" target="_blank">
<i class="fas fa-eye"></i> HTML 보기
</a>
` : ''}
<a href="/documents" class="btn btn-secondary">
<i class="fas fa-list"></i> 문서 목록
</a>
<a href="/chat" class="btn btn-success">
<i class="fas fa-comments"></i> 문서로 대화하기
</a>
</div>
</div>
</div>
`;
resultSection.style.display = 'block';
progressSection.style.display = 'none';
}
</script>
{% endblock %}

536
test_admin.py Normal file
View File

@@ -0,0 +1,536 @@
#!/usr/bin/env python3
"""
AI Server Admin Dashboard Test Server
맥북프로에서 관리 페이지만 테스트하기 위한 간단한 서버
"""
import os
import secrets
import uuid
import jwt
import bcrypt
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, Depends, Header
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import uvicorn
# Import encryption module
try:
from server.encryption import encrypt_api_key, decrypt_api_key, is_encrypted
ENCRYPTION_AVAILABLE = True
except ImportError:
print("⚠️ Encryption module not available, using plain text storage")
ENCRYPTION_AVAILABLE = False
def encrypt_api_key(key): return key
def decrypt_api_key(key): return key
def is_encrypted(key): return False
# FastAPI 앱 초기화
app = FastAPI(title="AI Server Admin Dashboard (Test Mode)")
# 정적 파일 및 템플릿 설정
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 테스트용 설정
TEST_API_KEY = os.getenv("API_KEY", "test-admin-key-123")
TEST_SERVER_PORT = 28080
TEST_OLLAMA_HOST = "http://localhost:11434"
# JWT 설정
JWT_SECRET_KEY = "test-jwt-secret-key-for-development"
JWT_ALGORITHM = "HS256"
security = HTTPBearer(auto_error=False)
# 테스트용 사용자 데이터
TEST_USERS = {
"admin": {
"username": "admin",
"password_hash": bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "admin"
},
"hyungi": {
"username": "hyungi",
"password_hash": bcrypt.hashpw("hyungi123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "system"
}
}
# 임시 데이터 저장소 (암호화된 API 키)
def initialize_api_keys():
"""Initialize API keys with encryption"""
keys = {
"test-key-1": {
"name": "Test Key 1",
"key": "test-api-key-abcd1234",
"created_at": datetime.now().isoformat(),
"usage_count": 42,
},
"test-key-2": {
"name": "Development Key",
"key": "dev-api-key-efgh5678",
"created_at": datetime.now().isoformat(),
"usage_count": 128,
}
}
# Encrypt API keys if encryption is available
if ENCRYPTION_AVAILABLE:
for key_id, key_data in keys.items():
if not is_encrypted(key_data["key"]):
try:
key_data["key"] = encrypt_api_key(key_data["key"])
key_data["encrypted"] = True
print(f"🔒 Encrypted API key: {key_data['name']}")
except Exception as e:
print(f"❌ Failed to encrypt key {key_data['name']}: {e}")
key_data["encrypted"] = False
else:
key_data["encrypted"] = True
else:
for key_data in keys.values():
key_data["encrypted"] = False
return keys
api_keys_store = initialize_api_keys()
# 테스트용 모델 데이터
test_models = [
{
"name": "llama3.2:3b",
"size": 2048000000, # 2GB
"status": "ready",
"is_active": True,
"last_used": datetime.now().isoformat(),
},
{
"name": "qwen2.5:7b",
"size": 4096000000, # 4GB
"status": "ready",
"is_active": False,
"last_used": "2024-12-20T10:30:00",
},
{
"name": "gemma2:2b",
"size": 1536000000, # 1.5GB
"status": "inactive",
"is_active": False,
"last_used": "2024-12-19T15:45:00",
}
]
# JWT 인증 함수들
def create_jwt_token(user_data: dict, remember_me: bool = False) -> str:
"""JWT 토큰 생성"""
expiration = datetime.utcnow() + timedelta(
days=30 if remember_me else 0,
hours=24 if not remember_me else 0
)
payload = {
"username": user_data["username"],
"role": user_data["role"],
"exp": expiration,
"iat": datetime.utcnow()
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def verify_jwt_token(token: str) -> dict:
"""JWT 토큰 검증"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
def verify_password(password: str, password_hash: str) -> bool:
"""비밀번호 검증"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
"""현재 인증된 사용자 가져오기"""
if not credentials:
raise HTTPException(status_code=401, detail="Authentication required")
try:
payload = verify_jwt_token(credentials.credentials)
username = payload.get("username")
user = TEST_USERS.get(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"username": user["username"],
"role": user["role"]
}
except HTTPException:
raise
except Exception as e:
print(f"JWT verification error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
def require_api_key(x_api_key: Optional[str] = Header(None), api_key: Optional[str] = None):
"""API 키 검증 (테스트 모드에서는 URL 파라미터도 허용)"""
# URL 파라미터로 API 키가 전달된 경우
if api_key and api_key == TEST_API_KEY:
return api_key
# 헤더로 API 키가 전달된 경우
if x_api_key and x_api_key == TEST_API_KEY:
return x_api_key
# 테스트 모드에서는 기본 허용
return "test-mode"
async def require_admin_role(current_user: dict = Depends(get_current_user)):
"""관리자 권한 필요"""
if current_user["role"] not in ["admin", "system"]:
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
# 유연한 인증 (JWT 또는 API 키)
async def flexible_auth(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
x_api_key: Optional[str] = Header(None)
):
"""JWT 또는 API 키 인증"""
# JWT 토큰 시도
if credentials:
try:
return await get_current_user(credentials)
except HTTPException:
pass
# API 키 시도 (테스트 모드)
if x_api_key == TEST_API_KEY:
return {"username": "api_user", "role": "system"}
# 둘 다 실패하면 로그인 페이지로 리다이렉트
raise HTTPException(status_code=401, detail="Authentication required")
@app.get("/", response_class=HTMLResponse)
async def root():
"""루트 페이지 - 관리 페이지로 리다이렉트"""
return HTMLResponse("""
<html>
<head><title>AI Server Test</title></head>
<body>
<h1>AI Server Admin Dashboard (Test Mode)</h1>
<p>이것은 맥북프로에서 관리 페이지를 테스트하기 위한 서버입니다.</p>
<p><a href="/admin">관리 페이지로 이동</a></p>
<p><strong>API Key:</strong> <code>test-admin-key-123</code></p>
</body>
</html>
""")
@app.get("/health")
async def health_check():
"""헬스 체크"""
return {"status": "ok", "mode": "test", "timestamp": datetime.now().isoformat()}
# JWT 인증 엔드포인트들
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""로그인 페이지"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/admin/login")
async def admin_login(request: Request):
"""JWT 기반 로그인 (테스트 모드)"""
try:
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return {"success": False, "message": "Username and password are required"}
# 사용자 인증
user = TEST_USERS.get(username)
if user and verify_password(password, user["password_hash"]):
# JWT 토큰 생성
token = create_jwt_token(user, remember_me)
return {
"success": True,
"message": "Login successful",
"token": token,
"user": {
"username": user["username"],
"role": user["role"]
}
}
else:
return {"success": False, "message": "Invalid username or password"}
except Exception as e:
return {"success": False, "message": "Login error occurred"}
@app.get("/admin/verify-token")
async def verify_token(current_user: dict = Depends(get_current_user)):
"""JWT 토큰 검증"""
return {
"valid": True,
"user": current_user
}
@app.post("/admin/logout")
async def admin_logout(current_user: dict = Depends(get_current_user)):
"""로그아웃"""
return {"success": True, "message": "Logged out successfully"}
# Admin Dashboard Routes
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
"""관리자 대시보드 페이지 (클라이언트에서 JWT 검증)"""
# HTML 페이지를 먼저 반환하고, JavaScript에서 토큰 검증
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": TEST_SERVER_PORT,
"ollama_host": TEST_OLLAMA_HOST,
})
@app.get("/admin/ollama/status")
async def admin_ollama_status(api_key: str = Depends(require_api_key)):
"""Ollama 서버 상태 확인 (테스트 모드)"""
return {"status": "offline", "error": "Test mode - Ollama not available"}
@app.get("/admin/models")
async def admin_get_models(api_key: str = Depends(require_api_key)):
"""설치된 모델 목록 조회 (테스트 데이터)"""
return {"models": test_models}
@app.get("/admin/models/active")
async def admin_get_active_model(api_key: str = Depends(require_api_key)):
"""현재 활성 모델 조회"""
active_model = next((m for m in test_models if m["is_active"]), None)
return {"model": active_model["name"] if active_model else "None"}
@app.post("/admin/models/test")
async def admin_test_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 테스트 (시뮬레이션)"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
# 테스트 모드에서는 시뮬레이션 결과 반환
return {
"result": f"Test mode simulation: Model '{model_name}' would respond with 'Hello! This is a test response from {model_name}.'"
}
@app.get("/admin/api-keys")
async def admin_get_api_keys(api_key: str = Depends(require_api_key)):
"""API 키 목록 조회 (복호화된 키 반환)"""
keys = []
for key_id, key_data in api_keys_store.items():
# Decrypt key for display
display_key = key_data.get("key", "")
if ENCRYPTION_AVAILABLE and key_data.get("encrypted", False):
try:
display_key = decrypt_api_key(display_key)
except Exception as e:
print(f"❌ Failed to decrypt key {key_data.get('name')}: {e}")
display_key = "DECRYPTION_FAILED"
keys.append({
"id": key_id,
"name": key_data.get("name", "Unnamed"),
"key": display_key,
"created_at": key_data.get("created_at", datetime.now().isoformat()),
"usage_count": key_data.get("usage_count", 0),
"encrypted": key_data.get("encrypted", False),
})
return {"api_keys": keys}
@app.post("/admin/api-keys")
async def admin_create_api_key(request: dict, api_key: str = Depends(require_api_key)):
"""새 API 키 생성 (암호화 저장)"""
name = request.get("name", "Unnamed Key")
new_key = secrets.token_urlsafe(32)
key_id = str(uuid.uuid4())
# Encrypt the key before storing
stored_key = new_key
encrypted = False
if ENCRYPTION_AVAILABLE:
try:
stored_key = encrypt_api_key(new_key)
encrypted = True
print(f"🔒 Created encrypted API key: {name}")
except Exception as e:
print(f"❌ Failed to encrypt new key {name}: {e}")
encrypted = False
api_keys_store[key_id] = {
"name": name,
"key": stored_key,
"created_at": datetime.now().isoformat(),
"usage_count": 0,
"encrypted": encrypted,
}
return {
"api_key": new_key, # Return plain key to user (only time they'll see it)
"key_id": key_id,
"encrypted": encrypted
}
@app.delete("/admin/api-keys/{key_id}")
async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_key)):
"""API 키 삭제"""
if key_id in api_keys_store:
del api_keys_store[key_id]
return {"message": "API key deleted successfully"}
else:
raise HTTPException(status_code=404, detail="API key not found")
# Phase 2: Advanced Model Management (Test Mode)
@app.post("/admin/models/download")
async def admin_download_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 다운로드 (테스트 모드)"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
# 테스트 모드에서는 시뮬레이션
return {
"success": True,
"message": f"Test mode: Model '{model_name}' download simulation started",
"details": f"In real mode, this would download {model_name} from Ollama registry"
}
@app.delete("/admin/models/{model_name}")
async def admin_delete_model(model_name: str, api_key: str = Depends(require_api_key)):
"""모델 삭제 (테스트 모드)"""
# 테스트 데이터에서 모델 제거
global test_models
test_models = [m for m in test_models if m["name"] != model_name]
return {
"success": True,
"message": f"Test mode: Model '{model_name}' deleted from test data",
"details": f"In real mode, this would delete {model_name} from Ollama"
}
@app.get("/admin/models/available")
async def admin_get_available_models(api_key: str = Depends(require_api_key)):
"""다운로드 가능한 모델 목록"""
available_models = [
{
"name": "llama3.2:1b",
"description": "Meta의 Llama 3.2 1B 모델 - 가벼운 작업용",
"size": "1.3GB",
"tags": ["chat", "lightweight"]
},
{
"name": "llama3.2:3b",
"description": "Meta의 Llama 3.2 3B 모델 - 균형잡힌 성능",
"size": "2.0GB",
"tags": ["chat", "recommended"]
},
{
"name": "qwen2.5:7b",
"description": "Alibaba의 Qwen 2.5 7B 모델 - 다국어 지원",
"size": "4.1GB",
"tags": ["chat", "multilingual"]
},
{
"name": "gemma2:2b",
"description": "Google의 Gemma 2 2B 모델 - 효율적인 추론",
"size": "1.6GB",
"tags": ["chat", "efficient"]
},
{
"name": "codellama:7b",
"description": "Meta의 Code Llama 7B - 코드 생성 특화",
"size": "3.8GB",
"tags": ["code", "programming"]
},
{
"name": "mistral:7b",
"description": "Mistral AI의 7B 모델 - 고성능 추론",
"size": "4.1GB",
"tags": ["chat", "performance"]
}
]
return {"available_models": available_models}
# Phase 2: System Monitoring (Test Mode)
@app.get("/admin/system/stats")
async def admin_get_system_stats(api_key: str = Depends(require_api_key)):
"""시스템 리소스 사용률 조회 (테스트 데이터)"""
import random
# 테스트용 랜덤 데이터 생성
return {
"cpu": {
"usage_percent": round(random.uniform(10, 80), 1),
"core_count": 8
},
"memory": {
"usage_percent": round(random.uniform(30, 90), 1),
"used_gb": round(random.uniform(4, 12), 1),
"total_gb": 16
},
"disk": {
"usage_percent": round(random.uniform(20, 70), 1),
"used_gb": round(random.uniform(50, 200), 1),
"total_gb": 500
},
"gpu": [
{
"name": "Test GPU (Simulated)",
"load": round(random.uniform(0, 100), 1),
"memory_used": round(random.uniform(1000, 8000)),
"memory_total": 8192,
"temperature": round(random.uniform(45, 75))
}
],
"timestamp": datetime.now().isoformat()
}
if __name__ == "__main__":
print("🚀 AI Server Admin Dashboard (Test Mode)")
print(f"📍 Server: http://localhost:{TEST_SERVER_PORT}")
print(f"🔧 Admin: http://localhost:{TEST_SERVER_PORT}/admin")
print(f"🔑 API Key: {TEST_API_KEY}")
print("=" * 50)
uvicorn.run(
app,
host="0.0.0.0",
port=TEST_SERVER_PORT,
log_level="info"
)