Compare commits
13 Commits
4c81686657
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841178ed7e | ||
|
|
1e098999c1 | ||
|
|
b752e56b94 | ||
|
|
e102ce6db9 | ||
|
|
cb009f7393 | ||
|
|
ef64aaec84 | ||
|
|
8d87b1f46b | ||
|
|
6346635ac1 | ||
|
|
6e7cf8eafa | ||
|
|
a280304adc | ||
|
|
b430a27215 | ||
|
|
397efb86dc | ||
|
|
9c70d3e8a1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ build/
|
||||
data/
|
||||
*.pdf
|
||||
|
||||
# Local env
|
||||
.env
|
||||
|
||||
|
||||
12
HYUNGI-HOME-CA.crt
Normal file
12
HYUNGI-HOME-CA.crt
Normal 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-----
|
||||
93
README.md
93
README.md
@@ -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
13
ca/ca-bundle.pem
Normal 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
13
ca/intermediate_ca.crt
Normal 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
32
ca/standard-cert.crt
Normal 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
47
integrations/document-ai/.gitignore
vendored
Normal 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/
|
||||
102
integrations/document-ai/CODING_CONVENTIONS.md
Normal file
102
integrations/document-ai/CODING_CONVENTIONS.md
Normal 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`를 실행해야 포트 충돌이 발생하지 않습니다.
|
||||
88
integrations/document-ai/check_installation.py
Normal file
88
integrations/document-ai/check_installation.py
Normal 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("패키지 재설치 필요")
|
||||
45
integrations/document-ai/config/settings.json
Normal file
45
integrations/document-ai/config/settings.json
Normal 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-!@#$%"
|
||||
}
|
||||
}
|
||||
11
integrations/document-ai/logs/service.log
Normal file
11
integrations/document-ai/logs/service.log
Normal 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
|
||||
23
integrations/document-ai/logs/service_error.log
Normal file
23
integrations/document-ai/logs/service_error.log
Normal 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.
|
||||
35
integrations/document-ai/requirements.txt
Normal file
35
integrations/document-ai/requirements.txt
Normal 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
|
||||
283
integrations/document-ai/src/background_ai_service.py
Normal file
283
integrations/document-ai/src/background_ai_service.py
Normal 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()
|
||||
67
integrations/document-ai/src/config_loader.py
Normal file
67
integrations/document-ai/src/config_loader.py
Normal 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("설정 로드 완료!")
|
||||
181
integrations/document-ai/src/download_kobart.py
Executable file
181
integrations/document-ai/src/download_kobart.py
Executable 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 모델 다운로드 실패")
|
||||
167
integrations/document-ai/src/download_korean_summarizer.py
Executable file
167
integrations/document-ai/src/download_korean_summarizer.py
Executable 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("📝 요약 기능 없이 번역만으로 진행 가능")
|
||||
174
integrations/document-ai/src/download_nllb.py
Executable file
174
integrations/document-ai/src/download_nllb.py
Executable 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("네트워크 연결을 확인하고 다시 시도해주세요.")
|
||||
462
integrations/document-ai/src/fastapi_final.py
Normal file
462
integrations/document-ai/src/fastapi_final.py
Normal 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)
|
||||
575
integrations/document-ai/src/fastapi_media_mount.py
Normal file
575
integrations/document-ai/src/fastapi_media_mount.py
Normal 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)
|
||||
848
integrations/document-ai/src/fastapi_port_20080.py
Normal file
848
integrations/document-ai/src/fastapi_port_20080.py
Normal 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)
|
||||
408
integrations/document-ai/src/fastapi_with_dashboard.py
Normal file
408
integrations/document-ai/src/fastapi_with_dashboard.py
Normal 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)
|
||||
597
integrations/document-ai/src/html_generator.py
Executable file
597
integrations/document-ai/src/html_generator.py
Executable 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('<', '<').replace('>', '>')
|
||||
|
||||
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()
|
||||
364
integrations/document-ai/src/integrated_translation_system.py
Executable file
364
integrations/document-ai/src/integrated_translation_system.py
Executable 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()
|
||||
237
integrations/document-ai/src/nas_mount_setup.py
Executable file
237
integrations/document-ai/src/nas_mount_setup.py
Executable 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()
|
||||
73
integrations/document-ai/src/security.py
Normal file
73
integrations/document-ai/src/security.py
Normal 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)
|
||||
96
integrations/document-ai/src/test_nllb_fixed.py
Executable file
96
integrations/document-ai/src/test_nllb_fixed.py
Executable 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❌ 테스트 실패")
|
||||
103
integrations/document-ai/src/test_summarizer_fixed.py
Executable file
103
integrations/document-ai/src/test_summarizer_fixed.py
Executable 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("📝 요약 없이 번역만으로 진행 고려")
|
||||
585
integrations/document-ai/templates/dashboard.html
Normal file
585
integrations/document-ai/templates/dashboard.html
Normal 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>
|
||||
468
integrations/document-ai/templates/index.html
Normal file
468
integrations/document-ai/templates/index.html
Normal 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>
|
||||
373
integrations/document-ai/templates/library_organized.html
Normal file
373
integrations/document-ai/templates/library_organized.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
.com∕permissions.
|
||||
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.4∕08--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 PEL’S, 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
20
outputs/html/test.html
Normal 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
20
outputs/html/ui-test.html
Normal 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>
|
||||
@@ -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
54
scripts/install_launchd.sh
Executable 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
229
server/auth.py
Normal 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
|
||||
@@ -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
127
server/encryption.py
Normal 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)}")
|
||||
601
server/main.py
601
server/main.py
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
134
server/pipeline.py
Normal 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
697
static/admin.css
Normal 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
508
static/admin.js
Normal 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
394
static/css/style.css
Normal 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
87
static/js/main.js
Normal 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
390
static/login.css
Normal 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
235
static/login.js
Normal 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
248
templates/admin.html
Normal 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')">×</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')">×</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
39
templates/base.html
Normal 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>© 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
631
templates/chat.html
Normal 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
348
templates/documents.html
Normal 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
229
templates/index.html
Normal 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
124
templates/login.html
Normal 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>© 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
395
templates/upload.html
Normal 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
536
test_admin.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user