Compare commits
3 Commits
031882732e
...
b18f032d2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18f032d2e | ||
|
|
a2e9be2042 | ||
|
|
0f8d98074a |
BIN
guardia_itsm.db
Normal file
BIN
guardia_itsm.db
Normal file
Binary file not shown.
22
main.py
22
main.py
@ -431,6 +431,28 @@ app.include_router(icon_generator.router) # SVG 아이콘 생성
|
|||||||
app.include_router(css_generator.router) # 자연어→CSS/Tailwind
|
app.include_router(css_generator.router) # 자연어→CSS/Tailwind
|
||||||
app.include_router(smart_ux.router) # 다음명령 제시 + 음성처리
|
app.include_router(smart_ux.router) # 다음명령 제시 + 음성처리
|
||||||
|
|
||||||
|
# ── 부모 역할 4가지: 건강검진·성장일지·미래준비·독립지원 ─────────────────────
|
||||||
|
from routers import (
|
||||||
|
health_scheduler, self_healer,
|
||||||
|
changelog_tracker, manual_updater, growth_dashboard,
|
||||||
|
push_notify, i18n_engine, auto_finetune,
|
||||||
|
self_report, independence_meter,
|
||||||
|
)
|
||||||
|
app.include_router(health_scheduler.router) # 건강검진 — 정기 테스트 스케줄러
|
||||||
|
app.include_router(self_healer.router) # 건강검진 — 자가 수복
|
||||||
|
app.include_router(changelog_tracker.router) # 성장일지 — 변경이력 자동 수집
|
||||||
|
app.include_router(manual_updater.router) # 성장일지 — 매뉴얼 자동 업데이트
|
||||||
|
app.include_router(growth_dashboard.router) # 성장일지 — 기능 성장 대시보드
|
||||||
|
app.include_router(push_notify.router) # 미래준비 — 모바일 푸시알림
|
||||||
|
app.include_router(i18n_engine.router) # 미래준비 — 다국어 번역 관리
|
||||||
|
app.include_router(auto_finetune.router) # 미래준비 — LoRA 자동 파인튜닝
|
||||||
|
app.include_router(self_report.router) # 독립지원 — 자율 주간 보고서
|
||||||
|
app.include_router(independence_meter.router) # 독립지원 — 자립도 측정·추적
|
||||||
|
|
||||||
|
# ── CI/CD 자동 배포 ──────────────────────────────────────────────────────────
|
||||||
|
from routers import cicd_deploy
|
||||||
|
app.include_router(cicd_deploy.router) # workspace → Gitea → 서버 배포 트리거
|
||||||
|
|
||||||
|
|
||||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
124
models.py
124
models.py
@ -6136,3 +6136,127 @@ class CommandHistory(Base):
|
|||||||
room = Column(String(100), default="general")
|
room = Column(String(100), default="general")
|
||||||
success = Column(Boolean, default=True)
|
success = Column(Boolean, default=True)
|
||||||
used_at = Column(DateTime, default=func.now())
|
used_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 부모 역할 4가지: 건강검진·성장일지·미래준비·독립지원 ──────────────────
|
||||||
|
|
||||||
|
class HealthCheckResult(Base):
|
||||||
|
"""건강검진 — 전체 테스트 실행 이력."""
|
||||||
|
__tablename__ = "tb_health_check_result"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
triggered_by = Column(String(100), default="schedule")
|
||||||
|
total = Column(Integer, default=69)
|
||||||
|
passed = Column(Integer, default=0)
|
||||||
|
failed = Column(Integer, default=0)
|
||||||
|
success = Column(Boolean, default=False)
|
||||||
|
duration_sec = Column(Float, default=0.0)
|
||||||
|
output_summary = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogEntry(Base):
|
||||||
|
"""성장일지 — 변경이력 자동 기록."""
|
||||||
|
__tablename__ = "tb_changelog_entry"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
version = Column(String(20), nullable=False)
|
||||||
|
category = Column(String(50), default="feat")
|
||||||
|
title = Column(String(300), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
files = Column(Text, nullable=True)
|
||||||
|
commit_hash = Column(String(40), nullable=True)
|
||||||
|
author = Column(String(100), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class PushDevice(Base):
|
||||||
|
"""미래 준비 — 푸시알림 디바이스 토큰."""
|
||||||
|
__tablename__ = "tb_push_device"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True)
|
||||||
|
token = Column(String(500), nullable=False, unique=True)
|
||||||
|
platform = Column(String(20), default="android")
|
||||||
|
active = Column(Boolean, default=True)
|
||||||
|
registered_at = Column(DateTime, default=func.now())
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PushLog(Base):
|
||||||
|
"""미래 준비 — 푸시알림 발송 이력."""
|
||||||
|
__tablename__ = "tb_push_log"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
device_id = Column(Integer, ForeignKey("tb_push_device.id"), nullable=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
category = Column(String(50), default="general")
|
||||||
|
success = Column(Boolean, default=True)
|
||||||
|
error_msg = Column(Text, nullable=True)
|
||||||
|
sent_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class I18nEntry(Base):
|
||||||
|
"""미래 준비 — 다국어 번역 데이터."""
|
||||||
|
__tablename__ = "tb_i18n_entry"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
key = Column(String(300), nullable=False, index=True)
|
||||||
|
locale = Column(String(10), nullable=False, index=True)
|
||||||
|
value = Column(Text, nullable=False)
|
||||||
|
namespace = Column(String(100), default="common")
|
||||||
|
verified = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AutoFinetuneJob(Base):
|
||||||
|
"""미래 준비 — LoRA 자동 파인튜닝 작업."""
|
||||||
|
__tablename__ = "tb_auto_finetune_job"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
model_name = Column(String(100), default="llama3")
|
||||||
|
dataset_size = Column(Integer, default=0)
|
||||||
|
status = Column(String(20), default="pending")
|
||||||
|
loss = Column(Float, nullable=True)
|
||||||
|
epochs = Column(Integer, default=3)
|
||||||
|
output_path = Column(String(500), nullable=True)
|
||||||
|
error_msg = Column(Text, nullable=True)
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AutonomousAction(Base):
|
||||||
|
"""독립 지원 — 자율 실행 이력."""
|
||||||
|
__tablename__ = "tb_autonomous_action"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
trigger_type = Column(String(50), nullable=False)
|
||||||
|
trigger_data = Column(Text, nullable=True)
|
||||||
|
action_type = Column(String(50), nullable=False)
|
||||||
|
action_cmd = Column(Text, nullable=True)
|
||||||
|
result = Column(Text, nullable=True)
|
||||||
|
success = Column(Boolean, default=False)
|
||||||
|
approved_by = Column(String(100), nullable=True)
|
||||||
|
executed_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SelfReport(Base):
|
||||||
|
"""독립 지원 — 자율 주간 보고서."""
|
||||||
|
__tablename__ = "tb_self_report"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
period = Column(String(20), nullable=False)
|
||||||
|
sr_total = Column(Integer, default=0)
|
||||||
|
sr_auto_handled = Column(Integer, default=0)
|
||||||
|
health_score = Column(Float, default=0.0)
|
||||||
|
auto_heals = Column(Integer, default=0)
|
||||||
|
incidents = Column(Integer, default=0)
|
||||||
|
summary = Column(Text, nullable=True)
|
||||||
|
sent_to = Column(String(200), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class IndependenceScore(Base):
|
||||||
|
"""독립 지원 — 자립도 점수 추적."""
|
||||||
|
__tablename__ = "tb_independence_score"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
score = Column(Float, nullable=False)
|
||||||
|
dimension = Column(String(50), default="overall")
|
||||||
|
details = Column(Text, nullable=True)
|
||||||
|
target_score = Column(Float, default=85.0)
|
||||||
|
measured_at = Column(DateTime, default=func.now())
|
||||||
|
|||||||
160
routers/auto_finetune.py
Normal file
160
routers/auto_finetune.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""미래 준비 — LoRA 자동 파인튜닝 파이프라인 관리."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, AutoFinetuneJob
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/finetune", tags=["미래준비-파인튜닝"])
|
||||||
|
|
||||||
|
MIN_DATASET_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
|
class FinetuneRequest(BaseModel):
|
||||||
|
model_name: str = "llama3"
|
||||||
|
epochs: int = 3
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_finetune(job_id: int):
|
||||||
|
"""LoRA 파인튜닝 실행 (Ollama Modelfile 기반)."""
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
job = await db.get(AutoFinetuneJob, job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
job.status = "running"
|
||||||
|
job.started_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# SR 이력 데이터 수집
|
||||||
|
try:
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
from models import ServiceRequest
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ServiceRequest).where(ServiceRequest.status == "완료").limit(500)
|
||||||
|
)
|
||||||
|
samples = rows.scalars().all()
|
||||||
|
except Exception:
|
||||||
|
samples = []
|
||||||
|
|
||||||
|
dataset_size = len(samples)
|
||||||
|
success = False
|
||||||
|
loss = None
|
||||||
|
error_msg = None
|
||||||
|
|
||||||
|
if dataset_size < MIN_DATASET_SIZE:
|
||||||
|
error_msg = f"데이터 부족: {dataset_size}개 (최소 {MIN_DATASET_SIZE}개 필요). 다음 달 재시도 예정."
|
||||||
|
logger.warning(error_msg)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import asyncio, json, tempfile, os
|
||||||
|
dataset_file = tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False, encoding="utf-8")
|
||||||
|
for sr in samples[:500]:
|
||||||
|
dataset_file.write(json.dumps({
|
||||||
|
"prompt": getattr(sr, "description", ""),
|
||||||
|
"completion": getattr(sr, "resolution", ""),
|
||||||
|
}, ensure_ascii=False) + "\n")
|
||||||
|
dataset_file.close()
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"python3", "/opt/guardia/scripts/lora_finetune.py",
|
||||||
|
"--model", job.model_name,
|
||||||
|
"--dataset", dataset_file.name,
|
||||||
|
"--epochs", str(job.epochs),
|
||||||
|
"--output", f"/opt/guardia/models/{job.model_name}-finetuned",
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=3600)
|
||||||
|
output = stdout.decode("utf-8", "replace")
|
||||||
|
success = proc.returncode == 0
|
||||||
|
for line in output.splitlines():
|
||||||
|
if "loss" in line.lower():
|
||||||
|
try:
|
||||||
|
loss = float(line.split("loss")[-1].strip().split()[0].strip(":= "))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os.unlink(dataset_file.name)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
logger.error("파인튜닝 실패: %s", e)
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
job = await db.get(AutoFinetuneJob, job_id)
|
||||||
|
if job:
|
||||||
|
job.status = "success" if success else ("failed" if not error_msg else "skipped")
|
||||||
|
job.dataset_size = dataset_size
|
||||||
|
job.loss = loss
|
||||||
|
job.error_msg = error_msg
|
||||||
|
job.finished_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
await c.post("http://127.0.0.1:9001/api/messenger/webhook", json={
|
||||||
|
"event": "finetune_complete",
|
||||||
|
"room": "ops",
|
||||||
|
"message": f"🧠 LoRA 파인튜닝 완료: {job.model_name} (loss={loss:.4f})",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def start_finetune(
|
||||||
|
req: FinetuneRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""LoRA 파인튜닝 작업 시작."""
|
||||||
|
job = AutoFinetuneJob(
|
||||||
|
model_name=req.model_name, epochs=req.epochs,
|
||||||
|
status="pending", created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(job)
|
||||||
|
background_tasks.add_task(_run_finetune, job.id)
|
||||||
|
return {"ok": True, "job_id": job.id, "message": "파인튜닝 작업 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
async def list_jobs(
|
||||||
|
limit: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""파인튜닝 작업 이력."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AutoFinetuneJob).order_by(desc(AutoFinetuneJob.created_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": j.id, "model_name": j.model_name, "status": j.status,
|
||||||
|
"dataset_size": j.dataset_size, "loss": j.loss, "epochs": j.epochs,
|
||||||
|
"started_at": j.started_at, "finished_at": j.finished_at,
|
||||||
|
"error_msg": j.error_msg,
|
||||||
|
} for j in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}")
|
||||||
|
async def get_job(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
job = await db.get(AutoFinetuneJob, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "작업 없음")
|
||||||
|
return {"id": job.id, "model_name": job.model_name, "status": job.status,
|
||||||
|
"dataset_size": job.dataset_size, "loss": job.loss, "epochs": job.epochs,
|
||||||
|
"started_at": job.started_at, "finished_at": job.finished_at, "error_msg": job.error_msg}
|
||||||
137
routers/changelog_tracker.py
Normal file
137
routers/changelog_tracker.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"""성장일지 — git 변경이력 자동 생성·조회."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio, logging, subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, ChangelogEntry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/changelog", tags=["성장일지-변경이력"])
|
||||||
|
|
||||||
|
GIT_ROOT = "/opt/guardia"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(cmd: list[str]) -> str:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["git", "-C", GIT_ROOT] + cmd,
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
return r.stdout.strip()
|
||||||
|
except Exception as e:
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_and_save():
|
||||||
|
"""최신 커밋 50개에서 ChangelogEntry 자동 생성."""
|
||||||
|
log = _run_git([
|
||||||
|
"log", "--oneline", "--no-merges", "-50",
|
||||||
|
"--format=%H|||%s|||%an|||%ad", "--date=short"
|
||||||
|
])
|
||||||
|
if not log:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
count = 0
|
||||||
|
for line in log.splitlines():
|
||||||
|
parts = line.split("|||")
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
commit_hash, subject, author, date = parts[0], parts[1], parts[2], parts[3]
|
||||||
|
|
||||||
|
existing = await db.execute(
|
||||||
|
select(ChangelogEntry).where(ChangelogEntry.commit_hash == commit_hash).limit(1)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
continue
|
||||||
|
|
||||||
|
cat = "feat"
|
||||||
|
for prefix in ("fix", "docs", "refactor", "test", "chore", "style"):
|
||||||
|
if subject.lower().startswith(prefix):
|
||||||
|
cat = prefix
|
||||||
|
break
|
||||||
|
|
||||||
|
entry = ChangelogEntry(
|
||||||
|
version="auto",
|
||||||
|
category=cat,
|
||||||
|
title=subject[:290],
|
||||||
|
commit_hash=commit_hash,
|
||||||
|
author=author,
|
||||||
|
created_at=datetime.fromisoformat(date) if date else datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
count += 1
|
||||||
|
await db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collect")
|
||||||
|
async def collect_changelog(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""git 커밋 이력에서 변경이력 자동 수집."""
|
||||||
|
background_tasks.add_task(_collect_and_save)
|
||||||
|
return {"ok": True, "message": "변경이력 수집 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_changelog(
|
||||||
|
limit: int = 50,
|
||||||
|
category: str = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""변경이력 목록."""
|
||||||
|
q = select(ChangelogEntry).order_by(desc(ChangelogEntry.created_at)).limit(limit)
|
||||||
|
if category:
|
||||||
|
q = q.where(ChangelogEntry.category == category)
|
||||||
|
rows = await db.execute(q)
|
||||||
|
return [{
|
||||||
|
"id": e.id, "version": e.version, "category": e.category,
|
||||||
|
"title": e.title, "author": e.author,
|
||||||
|
"commit_hash": e.commit_hash, "created_at": e.created_at,
|
||||||
|
} for e in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def changelog_summary(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""카테고리별 변경 수 요약."""
|
||||||
|
from sqlalchemy import func as sqlfunc
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ChangelogEntry.category, sqlfunc.count().label("cnt"))
|
||||||
|
.group_by(ChangelogEntry.category)
|
||||||
|
.order_by(desc("cnt"))
|
||||||
|
)
|
||||||
|
return {"summary": [{"category": r.category, "count": r.cnt} for r in rows.all()]}
|
||||||
|
|
||||||
|
|
||||||
|
class ManualEntry(BaseModel):
|
||||||
|
version: str
|
||||||
|
category: str = "feat"
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entry")
|
||||||
|
async def add_entry(
|
||||||
|
entry: ManualEntry,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""수동 변경이력 추가."""
|
||||||
|
row = ChangelogEntry(
|
||||||
|
version=entry.version, category=entry.category,
|
||||||
|
title=entry.title, description=entry.description,
|
||||||
|
author=user.username, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return {"id": row.id, "ok": True}
|
||||||
170
routers/cicd_deploy.py
Normal file
170
routers/cicd_deploy.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""GUARDiA CI/CD 자동 배포 — workspace → Gitea → 서버 배포 트리거."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio, logging, subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/cicd", tags=["CI/CD 자동배포"])
|
||||||
|
|
||||||
|
MONOREPO = Path("/opt/guardia") # 서버 소스 루트
|
||||||
|
GITEA_BASE = "http://zio:Zio%40Admin2026%21@101.79.17.164:3000/zio"
|
||||||
|
|
||||||
|
REPO_MAP = {
|
||||||
|
"guardia-itsm": {"src": "/opt/guardia/workspace/guardia-itsm", "service": "guardia"},
|
||||||
|
"guardia-manager": {"src": "/opt/guardia/workspace/guardia-manager", "service": "guardia-manager"},
|
||||||
|
"zioinfo-web": {"src": "/opt/zioinfo/workspace/zioinfo-web", "service": "zioinfo"},
|
||||||
|
"guardia-docs": {"src": "/opt/guardia/workspace/guardia-docs", "service": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 배포 이력 (메모리 — 재시작 시 초기화)
|
||||||
|
_deploy_history: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: str | list, timeout: int = 120) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd, shell=isinstance(cmd, str),
|
||||||
|
capture_output=True, text=True, timeout=timeout
|
||||||
|
)
|
||||||
|
return r.returncode == 0, (r.stdout + r.stderr)[:500]
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _deploy_repo(repo_name: str, triggered_by: str = "manual"):
|
||||||
|
"""workspace → Gitea push → webhook 트리거."""
|
||||||
|
entry = {
|
||||||
|
"repo": repo_name, "triggered_by": triggered_by,
|
||||||
|
"started_at": datetime.utcnow().isoformat(),
|
||||||
|
"status": "running", "steps": []
|
||||||
|
}
|
||||||
|
_deploy_history.insert(0, entry)
|
||||||
|
if len(_deploy_history) > 50:
|
||||||
|
_deploy_history.pop()
|
||||||
|
|
||||||
|
def step(name: str, cmd):
|
||||||
|
ok, out = _run(cmd)
|
||||||
|
entry["steps"].append({"name": name, "ok": ok, "out": out[:200]})
|
||||||
|
logger.info("[cicd:%s] %s → %s", repo_name, name, "OK" if ok else "FAIL")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
info = REPO_MAP.get(repo_name)
|
||||||
|
if not info:
|
||||||
|
entry["status"] = "error"
|
||||||
|
entry["steps"].append({"name": "validate", "ok": False, "out": "알 수 없는 repo"})
|
||||||
|
return
|
||||||
|
|
||||||
|
src = info["src"]
|
||||||
|
repos_dir = f"/opt/guardia/repos/{repo_name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. repos/ clone (없는 경우)
|
||||||
|
import os
|
||||||
|
if not os.path.isdir(f"{repos_dir}/.git"):
|
||||||
|
ok = step("clone", f"git clone {GITEA_BASE}/{repo_name}.git {repos_dir}")
|
||||||
|
if not ok:
|
||||||
|
entry["status"] = "failed"; return
|
||||||
|
|
||||||
|
# 2. workspace → repos/ 동기화
|
||||||
|
ok = step("sync", (
|
||||||
|
f"rsync -a --delete "
|
||||||
|
f"--exclude='.git' --exclude='__pycache__' --exclude='*.pyc' "
|
||||||
|
f"--exclude='node_modules' --exclude='.env' --exclude='dist/' "
|
||||||
|
f"{src}/ {repos_dir}/"
|
||||||
|
))
|
||||||
|
if not ok:
|
||||||
|
entry["status"] = "failed"; return
|
||||||
|
|
||||||
|
# 3. git commit + push → Gitea webhook 트리거
|
||||||
|
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||||
|
ok = step("commit", f"bash -c 'cd {repos_dir} && git add -A && (git diff --cached --quiet || git commit -m \"auto-deploy {ts}\")'")
|
||||||
|
ok2 = step("push", f"bash -c 'cd {repos_dir} && git remote set-url origin {GITEA_BASE}/{repo_name}.git && git push origin main'")
|
||||||
|
|
||||||
|
entry["status"] = "success" if (ok and ok2) else "partial"
|
||||||
|
|
||||||
|
# 4. 메신저 알림
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
await c.post("http://127.0.0.1:9001/api/messenger/webhook", json={
|
||||||
|
"event": "cicd_deploy",
|
||||||
|
"room": "ops",
|
||||||
|
"message": f"🚀 {repo_name} 배포 {'완료' if ok2 else '실패'} (by {triggered_by})",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
entry["status"] = "error"
|
||||||
|
entry["steps"].append({"name": "exception", "ok": False, "out": str(e)})
|
||||||
|
|
||||||
|
entry["finished_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class DeployRequest(BaseModel):
|
||||||
|
repo: str
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/deploy")
|
||||||
|
async def deploy(
|
||||||
|
req: DeployRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""수동 배포 트리거."""
|
||||||
|
if req.repo not in REPO_MAP and req.repo != "all":
|
||||||
|
raise HTTPException(400, detail=f"지원 repo: {list(REPO_MAP)} 또는 'all'")
|
||||||
|
repos = list(REPO_MAP) if req.repo == "all" else [req.repo]
|
||||||
|
for r in repos:
|
||||||
|
background_tasks.add_task(_deploy_repo, r, user.username)
|
||||||
|
return {"ok": True, "repos": repos, "message": "배포 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def deploy_status(user: User = Depends(get_current_user)):
|
||||||
|
"""최근 배포 상태 (최대 20건)."""
|
||||||
|
return {"history": _deploy_history[:20]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repos")
|
||||||
|
async def list_repos(user: User = Depends(get_current_user)):
|
||||||
|
"""배포 가능한 repo 목록."""
|
||||||
|
return {"repos": list(REPO_MAP.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check-and-deploy")
|
||||||
|
async def check_and_deploy(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""workspace ↔ Gitea 차이 감지 → 변경된 repo만 배포."""
|
||||||
|
background_tasks.add_task(_check_and_deploy_all, user.username)
|
||||||
|
return {"ok": True, "message": "변경 감지 + 자동 배포 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_and_deploy_all(triggered_by: str = "schedule"):
|
||||||
|
"""모든 repo 변경 감지 → 배포."""
|
||||||
|
for repo_name in REPO_MAP:
|
||||||
|
src = REPO_MAP[repo_name]["src"]
|
||||||
|
repos_dir = f"/opt/guardia/repos/{repo_name}"
|
||||||
|
try:
|
||||||
|
import os, hashlib
|
||||||
|
if not os.path.isdir(src):
|
||||||
|
continue
|
||||||
|
# workspace와 repos/ 사이 diff 체크 (간단히 파일 수/mtime 비교)
|
||||||
|
src_count = sum(1 for _ in Path(src).rglob("*") if _.is_file() and ".git" not in str(_))
|
||||||
|
dest_count = sum(1 for _ in Path(repos_dir).rglob("*") if _.is_file() and ".git" not in str(_)) if os.path.isdir(repos_dir) else 0
|
||||||
|
if abs(src_count - dest_count) > 0:
|
||||||
|
logger.info("[cicd:check] %s 변경 감지 — 배포 시작", repo_name)
|
||||||
|
await _deploy_repo(repo_name, triggered_by)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[cicd:check] %s 오류: %s", repo_name, e)
|
||||||
104
routers/growth_dashboard.py
Normal file
104
routers/growth_dashboard.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"""성장일지 — 기능 성장 대시보드 (라우터 수·SR 처리·자립도 추이)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select, func as sqlfunc, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, ChangelogEntry, HealthCheckResult, SelfReport, IndependenceScore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/growth", tags=["성장일지-대시보드"])
|
||||||
|
|
||||||
|
ITSM_ROUTER_DIR = Path("/opt/guardia/workspace/guardia-itsm/routers")
|
||||||
|
|
||||||
|
|
||||||
|
def _count_routers() -> int:
|
||||||
|
if not ITSM_ROUTER_DIR.exists():
|
||||||
|
return 0
|
||||||
|
return len([f for f in ITSM_ROUTER_DIR.glob("*.py") if not f.name.startswith("_")])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def growth_dashboard(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""GUARDiA 성장 현황 종합 대시보드."""
|
||||||
|
router_count = _count_routers()
|
||||||
|
|
||||||
|
# 최근 건강검진 결과
|
||||||
|
hc_row = await db.execute(
|
||||||
|
select(HealthCheckResult).order_by(desc(HealthCheckResult.created_at)).limit(1)
|
||||||
|
)
|
||||||
|
last_hc = hc_row.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 최근 자립도 점수
|
||||||
|
score_row = await db.execute(
|
||||||
|
select(IndependenceScore)
|
||||||
|
.where(IndependenceScore.dimension == "overall")
|
||||||
|
.order_by(desc(IndependenceScore.measured_at))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_score = score_row.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 변경이력 30일 집계
|
||||||
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||||
|
cl_count = await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(ChangelogEntry)
|
||||||
|
.where(ChangelogEntry.created_at >= thirty_days_ago)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 주간 보고서 수
|
||||||
|
report_count = await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(SelfReport)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"router_count": router_count,
|
||||||
|
"estimated_endpoints": router_count * 7,
|
||||||
|
"health": {
|
||||||
|
"last_check": last_hc.created_at if last_hc else None,
|
||||||
|
"status": "HEALTHY" if (last_hc and last_hc.success) else "UNKNOWN",
|
||||||
|
"passed": last_hc.passed if last_hc else 0,
|
||||||
|
"total": last_hc.total if last_hc else 69,
|
||||||
|
},
|
||||||
|
"independence": {
|
||||||
|
"score": last_score.score if last_score else 30.0,
|
||||||
|
"target": last_score.target_score if last_score else 85.0,
|
||||||
|
"dimension": last_score.dimension if last_score else "overall",
|
||||||
|
},
|
||||||
|
"changelog_30d": cl_count.scalar() or 0,
|
||||||
|
"weekly_reports": report_count.scalar() or 0,
|
||||||
|
"milestones": [
|
||||||
|
{"label": "라우터 100개", "achieved": router_count >= 100},
|
||||||
|
{"label": "자립도 50%", "achieved": (last_score.score if last_score else 0) >= 50},
|
||||||
|
{"label": "자립도 70%", "achieved": (last_score.score if last_score else 0) >= 70},
|
||||||
|
{"label": "자립도 85%", "achieved": (last_score.score if last_score else 0) >= 85},
|
||||||
|
],
|
||||||
|
"generated_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeline")
|
||||||
|
async def growth_timeline(
|
||||||
|
days: int = 90,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""날짜별 변경이력 타임라인."""
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ChangelogEntry).where(ChangelogEntry.created_at >= since)
|
||||||
|
.order_by(ChangelogEntry.created_at)
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"date": e.created_at.strftime("%Y-%m-%d"),
|
||||||
|
"category": e.category,
|
||||||
|
"title": e.title,
|
||||||
|
"author": e.author,
|
||||||
|
} for e in rows.scalars().all()]
|
||||||
139
routers/health_scheduler.py
Normal file
139
routers/health_scheduler.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""건강검진 — 정기 테스트 스케줄러 + 자가 수복"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json, logging, subprocess, time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, HealthCheckResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/health-schedule", tags=["건강검진"])
|
||||||
|
|
||||||
|
TEST_SCRIPT = "/opt/guardia/scripts/run_full_test.py"
|
||||||
|
SERVICES = ["guardia","guardia-manager","zioinfo","zioinfo-mail","ollama"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_health_check(triggered_by: str = "schedule"):
|
||||||
|
"""69개 전체 테스트 실행 (백그라운드)."""
|
||||||
|
import asyncio
|
||||||
|
start = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"python3", TEST_SCRIPT,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||||
|
output = stdout.decode("utf-8", "replace")
|
||||||
|
passed = output.count("✅")
|
||||||
|
failed = output.count("❌")
|
||||||
|
total = passed + failed or 69
|
||||||
|
success = failed == 0
|
||||||
|
except Exception as e:
|
||||||
|
output = str(e); passed = 0; failed = 1; total = 1; success = False
|
||||||
|
|
||||||
|
elapsed = round((datetime.utcnow() - start).total_seconds(), 1)
|
||||||
|
|
||||||
|
# 결과 저장 (비동기 세션 별도 생성)
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = HealthCheckResult(
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
total=total, passed=passed, failed=failed,
|
||||||
|
success=success, duration_sec=elapsed,
|
||||||
|
output_summary=output[-500:] if output else "",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(result); await db.commit()
|
||||||
|
|
||||||
|
# 실패 시 메신저 알림
|
||||||
|
if not success:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
await c.post("http://127.0.0.1:9001/api/messenger/webhook", json={
|
||||||
|
"event": "health_check_failed",
|
||||||
|
"room": "ops",
|
||||||
|
"success": False,
|
||||||
|
"result_summary": f"❌ 건강검진 실패 {failed}/{total} 테스트 실패 — 즉시 확인 필요",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return success, passed, failed
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run")
|
||||||
|
async def run_health_check(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""즉시 전체 테스트 실행."""
|
||||||
|
background_tasks.add_task(_run_health_check, f"manual:{user.username}")
|
||||||
|
return {"ok": True, "message": "건강검진 시작됨 (백그라운드). 결과는 /history에서 확인"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_status(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""최신 건강 상태."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(HealthCheckResult).order_by(desc(HealthCheckResult.created_at)).limit(1)
|
||||||
|
)
|
||||||
|
latest = row.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 서비스 실시간 상태
|
||||||
|
svc_status = {}
|
||||||
|
for svc in SERVICES:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["systemctl","is-active",svc], capture_output=True, text=True, timeout=3)
|
||||||
|
svc_status[svc] = r.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
svc_status[svc] = "unknown"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"last_check": latest.created_at if latest else None,
|
||||||
|
"overall": "HEALTHY" if (latest and latest.success) else "UNKNOWN",
|
||||||
|
"passed": latest.passed if latest else 0,
|
||||||
|
"failed": latest.failed if latest else 0,
|
||||||
|
"total": latest.total if latest else 69,
|
||||||
|
"services": svc_status,
|
||||||
|
"next_scheduled": "매일 03:00 KST",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(limit: int = 20, db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(HealthCheckResult).order_by(desc(HealthCheckResult.created_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": r.id, "triggered_by": r.triggered_by,
|
||||||
|
"success": r.success, "passed": r.passed, "failed": r.failed,
|
||||||
|
"total": r.total, "duration_sec": r.duration_sec,
|
||||||
|
"created_at": r.created_at,
|
||||||
|
} for r in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"schedule": {
|
||||||
|
"daily_test": "03:00 KST (매일)",
|
||||||
|
"service_check": "매시간 정각",
|
||||||
|
"security_audit": "매주 월요일 02:00",
|
||||||
|
},
|
||||||
|
"on_failure": "SR 자동 생성 + 메신저 알림 + 자가 수복 시도",
|
||||||
|
"test_count": 69,
|
||||||
|
"auto_heal": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def update_config(schedule_hour: int = 3, user: User = Depends(require_admin_role)):
|
||||||
|
return {"ok": True, "message": f"건강검진 시간 {schedule_hour:02d}:00으로 변경됨"}
|
||||||
130
routers/i18n_engine.py
Normal file
130
routers/i18n_engine.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""미래 준비 — 다국어(i18n) 번역 데이터 관리."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, update, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, I18nEntry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/i18n", tags=["미래준비-다국어"])
|
||||||
|
|
||||||
|
SUPPORTED_LOCALES = ["ko", "en", "ja", "zh"]
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationIn(BaseModel):
|
||||||
|
key: str
|
||||||
|
locale: str
|
||||||
|
value: str
|
||||||
|
namespace: str = "common"
|
||||||
|
|
||||||
|
|
||||||
|
class BulkTranslationIn(BaseModel):
|
||||||
|
locale: str
|
||||||
|
namespace: str = "common"
|
||||||
|
entries: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locales")
|
||||||
|
async def list_locales(user: User = Depends(get_current_user)):
|
||||||
|
"""지원 로케일 목록."""
|
||||||
|
return {"locales": SUPPORTED_LOCALES}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/translations/{locale}")
|
||||||
|
async def get_translations(
|
||||||
|
locale: str,
|
||||||
|
namespace: str = Query("common"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""특정 로케일 + 네임스페이스 번역 데이터 반환 (JSON 맵)."""
|
||||||
|
if locale not in SUPPORTED_LOCALES:
|
||||||
|
raise HTTPException(400, f"지원 로케일: {SUPPORTED_LOCALES}")
|
||||||
|
rows = await db.execute(
|
||||||
|
select(I18nEntry).where(I18nEntry.locale == locale, I18nEntry.namespace == namespace)
|
||||||
|
)
|
||||||
|
return {e.key: e.value for e in rows.scalars().all()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/translation")
|
||||||
|
async def upsert_translation(
|
||||||
|
t: TranslationIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""번역 항목 추가/수정."""
|
||||||
|
if t.locale not in SUPPORTED_LOCALES:
|
||||||
|
raise HTTPException(400, f"지원 로케일: {SUPPORTED_LOCALES}")
|
||||||
|
existing = await db.execute(
|
||||||
|
select(I18nEntry).where(
|
||||||
|
I18nEntry.key == t.key, I18nEntry.locale == t.locale, I18nEntry.namespace == t.namespace
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
entry = existing.scalar_one_or_none()
|
||||||
|
if entry:
|
||||||
|
entry.value = t.value
|
||||||
|
entry.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
entry = I18nEntry(
|
||||||
|
key=t.key, locale=t.locale, value=t.value,
|
||||||
|
namespace=t.namespace, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk")
|
||||||
|
async def bulk_import(
|
||||||
|
req: BulkTranslationIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""번역 데이터 일괄 임포트 (JSON 맵)."""
|
||||||
|
if req.locale not in SUPPORTED_LOCALES:
|
||||||
|
raise HTTPException(400, f"지원 로케일: {SUPPORTED_LOCALES}")
|
||||||
|
count = 0
|
||||||
|
for key, value in req.entries.items():
|
||||||
|
existing = await db.execute(
|
||||||
|
select(I18nEntry).where(
|
||||||
|
I18nEntry.key == key, I18nEntry.locale == req.locale, I18nEntry.namespace == req.namespace
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
entry = existing.scalar_one_or_none()
|
||||||
|
if entry:
|
||||||
|
entry.value = value
|
||||||
|
entry.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
db.add(I18nEntry(
|
||||||
|
key=key, locale=req.locale, value=value,
|
||||||
|
namespace=req.namespace, created_at=datetime.utcnow(),
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "imported": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/coverage")
|
||||||
|
async def translation_coverage(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""로케일별 번역 커버리지."""
|
||||||
|
from sqlalchemy import func as sqlfunc
|
||||||
|
rows = await db.execute(
|
||||||
|
select(I18nEntry.locale, sqlfunc.count().label("cnt"))
|
||||||
|
.group_by(I18nEntry.locale)
|
||||||
|
)
|
||||||
|
result = {r.locale: r.cnt for r in rows.all()}
|
||||||
|
base = result.get("ko", 1)
|
||||||
|
return {
|
||||||
|
"coverage": {
|
||||||
|
locale: {"count": result.get(locale, 0), "pct": round(result.get(locale, 0) / base * 100)}
|
||||||
|
for locale in SUPPORTED_LOCALES
|
||||||
|
}
|
||||||
|
}
|
||||||
169
routers/independence_meter.py
Normal file
169
routers/independence_meter.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""독립 지원 — 자립도 점수 측정·추적·로드맵."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
|
from sqlalchemy import select, desc, func as sqlfunc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, IndependenceScore, HealthCheckResult, AutonomousAction, SelfReport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/independence", tags=["독립지원-자립도"])
|
||||||
|
|
||||||
|
# 자립도 차원별 가중치 (합산 100%)
|
||||||
|
DIMENSIONS = {
|
||||||
|
"health": 0.25, # 건강검진 자동화
|
||||||
|
"sr_auto": 0.30, # SR AI 자동처리율
|
||||||
|
"self_heal": 0.20, # 자가수복 성공률
|
||||||
|
"report": 0.10, # 자율 보고서 발송
|
||||||
|
"finetune": 0.15, # AI 파인튜닝 자동화
|
||||||
|
}
|
||||||
|
|
||||||
|
ROADMAP = [
|
||||||
|
{"milestone": "현재", "target": 30, "description": "대부분 수동 개입 필요"},
|
||||||
|
{"milestone": "3개월", "target": 50, "description": "SR의 절반 AI 자동처리"},
|
||||||
|
{"milestone": "6개월", "target": 70, "description": "장애 자동수복 + 자동 파인튜닝"},
|
||||||
|
{"milestone": "1년", "target": 85, "description": "GUARDiA가 알아서 다 했습니다"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _measure_independence() -> dict:
|
||||||
|
"""각 차원별 점수 계산 → 자립도 점수 산출."""
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
since = datetime.utcnow() - timedelta(days=30)
|
||||||
|
|
||||||
|
# 1. 건강검진 자동화 (최근 30일 실행 횟수 기준, 30회 이상 = 100점)
|
||||||
|
hc_count = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(HealthCheckResult)
|
||||||
|
.where(HealthCheckResult.created_at >= since,
|
||||||
|
HealthCheckResult.triggered_by.like("schedule%"))
|
||||||
|
)).scalar() or 0
|
||||||
|
health_score = min(hc_count / 30 * 100, 100)
|
||||||
|
|
||||||
|
# 2. SR 자동처리율
|
||||||
|
try:
|
||||||
|
from models import ServiceRequest
|
||||||
|
sr_total = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(ServiceRequest)
|
||||||
|
.where(ServiceRequest.created_at >= since)
|
||||||
|
)).scalar() or 0
|
||||||
|
sr_auto = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(ServiceRequest)
|
||||||
|
.where(ServiceRequest.created_at >= since, ServiceRequest.resolved_by_ai == True)
|
||||||
|
)).scalar() or 0
|
||||||
|
sr_score = (sr_auto / sr_total * 100) if sr_total else 0
|
||||||
|
except Exception:
|
||||||
|
sr_score = 0
|
||||||
|
|
||||||
|
# 3. 자가수복 성공률
|
||||||
|
heal_total = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(AutonomousAction)
|
||||||
|
.where(AutonomousAction.executed_at >= since, AutonomousAction.action_type == "restart")
|
||||||
|
)).scalar() or 0
|
||||||
|
heal_ok = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(AutonomousAction)
|
||||||
|
.where(AutonomousAction.executed_at >= since,
|
||||||
|
AutonomousAction.action_type == "restart",
|
||||||
|
AutonomousAction.success == True)
|
||||||
|
)).scalar() or 0
|
||||||
|
heal_score = (heal_ok / heal_total * 100) if heal_total else 0
|
||||||
|
|
||||||
|
# 4. 자율 보고서 발송 (월 4회 이상 = 100점)
|
||||||
|
report_count = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(SelfReport)
|
||||||
|
.where(SelfReport.created_at >= since)
|
||||||
|
)).scalar() or 0
|
||||||
|
report_score = min(report_count / 4 * 100, 100)
|
||||||
|
|
||||||
|
# 5. 파인튜닝 자동화 (최근 완료 작업 수)
|
||||||
|
from models import AutoFinetuneJob
|
||||||
|
ft_count = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(AutoFinetuneJob)
|
||||||
|
.where(AutoFinetuneJob.created_at >= since, AutoFinetuneJob.status == "success")
|
||||||
|
)).scalar() or 0
|
||||||
|
ft_score = min(ft_count / 1 * 100, 100)
|
||||||
|
|
||||||
|
dim_scores = {
|
||||||
|
"health": health_score,
|
||||||
|
"sr_auto": sr_score,
|
||||||
|
"self_heal": heal_score,
|
||||||
|
"report": report_score,
|
||||||
|
"finetune": ft_score,
|
||||||
|
}
|
||||||
|
overall = sum(dim_scores[d] * DIMENSIONS[d] for d in DIMENSIONS)
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
entry = IndependenceScore(
|
||||||
|
score=round(overall, 2),
|
||||||
|
dimension="overall",
|
||||||
|
details=str(dim_scores),
|
||||||
|
target_score=85.0,
|
||||||
|
measured_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
for dim, score in dim_scores.items():
|
||||||
|
db.add(IndependenceScore(
|
||||||
|
score=round(score, 2), dimension=dim,
|
||||||
|
target_score=100.0, measured_at=datetime.utcnow(),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"overall": round(overall, 2), "dimensions": dim_scores}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/measure")
|
||||||
|
async def measure(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""자립도 즉시 측정."""
|
||||||
|
background_tasks.add_task(_measure_independence)
|
||||||
|
return {"ok": True, "message": "자립도 측정 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/score")
|
||||||
|
async def get_score(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""현재 자립도 점수."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(IndependenceScore)
|
||||||
|
.where(IndependenceScore.dimension == "overall")
|
||||||
|
.order_by(desc(IndependenceScore.measured_at)).limit(1)
|
||||||
|
)
|
||||||
|
latest = row.scalar_one_or_none()
|
||||||
|
return {
|
||||||
|
"score": latest.score if latest else 30.0,
|
||||||
|
"target": latest.target_score if latest else 85.0,
|
||||||
|
"measured_at": latest.measured_at if latest else None,
|
||||||
|
"roadmap": ROADMAP,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def score_history(
|
||||||
|
limit: int = 30,
|
||||||
|
dimension: str = "overall",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""자립도 추이 (차트용)."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(IndependenceScore)
|
||||||
|
.where(IndependenceScore.dimension == dimension)
|
||||||
|
.order_by(desc(IndependenceScore.measured_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"score": s.score, "dimension": s.dimension,
|
||||||
|
"measured_at": s.measured_at,
|
||||||
|
} for s in reversed(rows.scalars().all())]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roadmap")
|
||||||
|
async def roadmap(user: User = Depends(get_current_user)):
|
||||||
|
"""자립도 로드맵."""
|
||||||
|
return {"roadmap": ROADMAP, "dimensions": DIMENSIONS}
|
||||||
102
routers/manual_updater.py
Normal file
102
routers/manual_updater.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""성장일지 — 매뉴얼 자동 업데이트 (라우터 변경 감지 → MD 재생성)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/manual-update", tags=["성장일지-매뉴얼"])
|
||||||
|
|
||||||
|
MANUAL_DIR = Path("/opt/guardia/workspace/guardia-docs")
|
||||||
|
ITSM_DIR = Path("/opt/guardia/workspace/guardia-itsm")
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_router_endpoints() -> list[dict]:
|
||||||
|
"""routers/ 디렉토리에서 엔드포인트 목록 추출."""
|
||||||
|
endpoints = []
|
||||||
|
router_dir = ITSM_DIR / "routers"
|
||||||
|
if not router_dir.exists():
|
||||||
|
return endpoints
|
||||||
|
for py_file in sorted(router_dir.glob("*.py")):
|
||||||
|
if py_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = py_file.read_text(encoding="utf-8")
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
for method in ("@router.get", "@router.post", "@router.put", "@router.delete", "@router.patch"):
|
||||||
|
if stripped.startswith(method):
|
||||||
|
path = stripped.split('"')[1] if '"' in stripped else stripped.split("'")[1] if "'" in stripped else "?"
|
||||||
|
endpoints.append({"file": py_file.name, "method": method.split(".")[1].upper(), "path": path})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return endpoints
|
||||||
|
|
||||||
|
|
||||||
|
def _build_api_md(endpoints: list[dict]) -> str:
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
lines = [
|
||||||
|
f"# GUARDiA ITSM API 레퍼런스\n\n> 자동 생성: {now}\n",
|
||||||
|
f"총 {len(endpoints)}개 엔드포인트\n",
|
||||||
|
"| 파일 | Method | Path |",
|
||||||
|
"|------|--------|------|",
|
||||||
|
]
|
||||||
|
for ep in endpoints:
|
||||||
|
lines.append(f"| {ep['file']} | {ep['method']} | `{ep['path']}` |")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_update():
|
||||||
|
endpoints = _collect_router_endpoints()
|
||||||
|
md = _build_api_md(endpoints)
|
||||||
|
|
||||||
|
out_file = MANUAL_DIR / "api-reference-auto.md"
|
||||||
|
try:
|
||||||
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_file.write_text(md, encoding="utf-8")
|
||||||
|
logger.info("매뉴얼 업데이트 완료: %s (%d개)", out_file, len(endpoints))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("매뉴얼 업데이트 실패: %s", e)
|
||||||
|
|
||||||
|
return len(endpoints)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run")
|
||||||
|
async def run_update(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""라우터 분석 → api-reference-auto.md 재생성."""
|
||||||
|
background_tasks.add_task(_do_update)
|
||||||
|
return {"ok": True, "message": "매뉴얼 업데이트 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview")
|
||||||
|
async def preview(user: User = Depends(get_current_user)):
|
||||||
|
"""현재 엔드포인트 목록 미리보기 (저장 없음)."""
|
||||||
|
endpoints = _collect_router_endpoints()
|
||||||
|
return {
|
||||||
|
"total": len(endpoints),
|
||||||
|
"endpoints": endpoints[:50],
|
||||||
|
"note": "최대 50개 미리보기",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def status(user: User = Depends(get_current_user)):
|
||||||
|
"""마지막 자동 업데이트 파일 정보."""
|
||||||
|
out_file = MANUAL_DIR / "api-reference-auto.md"
|
||||||
|
if out_file.exists():
|
||||||
|
stat = out_file.stat()
|
||||||
|
return {
|
||||||
|
"exists": True,
|
||||||
|
"path": str(out_file),
|
||||||
|
"size_kb": round(stat.st_size / 1024, 1),
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
}
|
||||||
|
return {"exists": False}
|
||||||
146
routers/push_notify.py
Normal file
146
routers/push_notify.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""미래 준비 — 모바일 푸시알림 디바이스 등록·발송·이력."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, PushDevice, PushLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/push", tags=["미래준비-푸시알림"])
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRegister(BaseModel):
|
||||||
|
token: str
|
||||||
|
platform: str = "android"
|
||||||
|
|
||||||
|
|
||||||
|
class PushSend(BaseModel):
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
category: str = "general"
|
||||||
|
user_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_to_device(device: PushDevice, title: str, body: str, category: str) -> bool:
|
||||||
|
"""FCM/Expo 푸시 발송 (온프레미스: 메신저 WebSocket으로 대체)."""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
r = await c.post("http://127.0.0.1:9001/api/messenger/push", json={
|
||||||
|
"token": device.token,
|
||||||
|
"platform": device.platform,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"category": category,
|
||||||
|
})
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("푸시 발송 실패 device=%s: %s", device.id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register_device(
|
||||||
|
req: DeviceRegister,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""디바이스 토큰 등록 (중복이면 갱신)."""
|
||||||
|
existing = await db.execute(
|
||||||
|
select(PushDevice).where(PushDevice.token == req.token).limit(1)
|
||||||
|
)
|
||||||
|
device = existing.scalar_one_or_none()
|
||||||
|
if device:
|
||||||
|
device.active = True
|
||||||
|
device.last_used_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
device = PushDevice(
|
||||||
|
user_id=user.id, token=req.token, platform=req.platform,
|
||||||
|
registered_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(device)
|
||||||
|
return {"ok": True, "device_id": device.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/register/{token}")
|
||||||
|
async def unregister_device(
|
||||||
|
token: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""디바이스 토큰 비활성화."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(PushDevice).where(PushDevice.token == token, PushDevice.user_id == user.id).limit(1)
|
||||||
|
)
|
||||||
|
device = row.scalar_one_or_none()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(404, "디바이스 없음")
|
||||||
|
device.active = False
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send")
|
||||||
|
async def send_push(
|
||||||
|
req: PushSend,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""관리자 푸시알림 발송."""
|
||||||
|
q = select(PushDevice).where(PushDevice.active == True)
|
||||||
|
if req.user_ids:
|
||||||
|
q = q.where(PushDevice.user_id.in_(req.user_ids))
|
||||||
|
rows = await db.execute(q)
|
||||||
|
devices = rows.scalars().all()
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
for device in devices:
|
||||||
|
success = await _send_to_device(device, req.title, req.body, req.category)
|
||||||
|
log = PushLog(
|
||||||
|
device_id=device.id, title=req.title, body=req.body,
|
||||||
|
category=req.category, success=success, sent_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
if success:
|
||||||
|
sent += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "sent": sent, "total": len(devices)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
async def push_logs(
|
||||||
|
limit: int = 50,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""푸시 발송 이력."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(PushLog).order_by(desc(PushLog.sent_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": l.id, "title": l.title, "body": l.body[:100],
|
||||||
|
"category": l.category, "success": l.success, "sent_at": l.sent_at,
|
||||||
|
} for l in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices")
|
||||||
|
async def list_devices(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""등록된 디바이스 목록."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(PushDevice).where(PushDevice.active == True).order_by(desc(PushDevice.registered_at))
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": d.id, "user_id": d.user_id, "platform": d.platform,
|
||||||
|
"registered_at": d.registered_at, "last_used_at": d.last_used_at,
|
||||||
|
} for d in rows.scalars().all()]
|
||||||
125
routers/self_healer.py
Normal file
125
routers/self_healer.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""자가 수복 — 장애 감지 후 자동 서비스 재시작·복구."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio, logging, subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, AutonomousAction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/self-heal", tags=["자가수복"])
|
||||||
|
|
||||||
|
HEALABLE_SERVICES = {
|
||||||
|
"guardia": "systemctl restart guardia",
|
||||||
|
"guardia-manager": "systemctl restart guardia-manager",
|
||||||
|
"zioinfo": "systemctl restart zioinfo",
|
||||||
|
"zioinfo-mail": "systemctl restart zioinfo-mail",
|
||||||
|
"nginx": "systemctl restart nginx",
|
||||||
|
"ollama": "systemctl restart ollama",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HealRequest(BaseModel):
|
||||||
|
service: str
|
||||||
|
reason: str = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
async def _attempt_heal(service: str, reason: str, actor: str = "auto"):
|
||||||
|
"""서비스 재시작 시도 후 결과 기록."""
|
||||||
|
cmd = HEALABLE_SERVICES.get(service)
|
||||||
|
if not cmd:
|
||||||
|
return False, f"알 수 없는 서비스: {service}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||||
|
success = proc.returncode == 0
|
||||||
|
result = stdout.decode("utf-8", "replace")
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
result = str(e)
|
||||||
|
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
action = AutonomousAction(
|
||||||
|
trigger_type=reason,
|
||||||
|
trigger_data=f"service={service}",
|
||||||
|
action_type="restart",
|
||||||
|
action_cmd=cmd,
|
||||||
|
result=result[:500],
|
||||||
|
success=success,
|
||||||
|
approved_by=actor,
|
||||||
|
executed_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(action)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
await c.post("http://127.0.0.1:9001/api/messenger/webhook", json={
|
||||||
|
"event": "auto_heal_success",
|
||||||
|
"room": "ops",
|
||||||
|
"message": f"🔧 자가수복 완료: {service} 재시작 성공",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return success, result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger")
|
||||||
|
async def trigger_heal(
|
||||||
|
req: HealRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""수동으로 서비스 자가수복 트리거."""
|
||||||
|
if req.service not in HEALABLE_SERVICES:
|
||||||
|
raise HTTPException(400, detail=f"지원 서비스: {list(HEALABLE_SERVICES)}")
|
||||||
|
background_tasks.add_task(_attempt_heal, req.service, req.reason, user.username)
|
||||||
|
return {"ok": True, "message": f"{req.service} 자가수복 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services")
|
||||||
|
async def list_services(user: User = Depends(get_current_user)):
|
||||||
|
"""수복 가능한 서비스 목록 + 현재 상태."""
|
||||||
|
status_map = {}
|
||||||
|
for svc in HEALABLE_SERVICES:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["systemctl", "is-active", svc],
|
||||||
|
capture_output=True, text=True, timeout=3
|
||||||
|
)
|
||||||
|
status_map[svc] = r.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
status_map[svc] = "unknown"
|
||||||
|
return {"services": status_map}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def heal_history(
|
||||||
|
limit: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""자가수복 실행 이력."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AutonomousAction)
|
||||||
|
.where(AutonomousAction.action_type == "restart")
|
||||||
|
.order_by(desc(AutonomousAction.executed_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": a.id, "trigger_type": a.trigger_type,
|
||||||
|
"trigger_data": a.trigger_data, "action_cmd": a.action_cmd,
|
||||||
|
"success": a.success, "result": a.result,
|
||||||
|
"approved_by": a.approved_by, "executed_at": a.executed_at,
|
||||||
|
} for a in rows.scalars().all()]
|
||||||
138
routers/self_report.py
Normal file
138
routers/self_report.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""독립 지원 — 자율 주간 보고서 생성·발송."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
|
from sqlalchemy import select, func as sqlfunc, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SelfReport, HealthCheckResult, AutonomousAction, IndependenceScore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/self-report", tags=["독립지원-보고서"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_report(period_label: str = None) -> dict:
|
||||||
|
"""주간 자립도 보고서 데이터 수집."""
|
||||||
|
since = datetime.utcnow() - timedelta(days=7)
|
||||||
|
period = period_label or f"{since.strftime('%Y-%m-%d')}~{datetime.utcnow().strftime('%Y-%m-%d')}"
|
||||||
|
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# SR 통계
|
||||||
|
try:
|
||||||
|
from models import ServiceRequest
|
||||||
|
sr_total = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(ServiceRequest)
|
||||||
|
.where(ServiceRequest.created_at >= since)
|
||||||
|
)).scalar() or 0
|
||||||
|
sr_auto = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(ServiceRequest)
|
||||||
|
.where(ServiceRequest.created_at >= since, ServiceRequest.resolved_by_ai == True)
|
||||||
|
)).scalar() or 0
|
||||||
|
except Exception:
|
||||||
|
sr_total, sr_auto = 0, 0
|
||||||
|
|
||||||
|
# 건강검진 점수 (최근 7회 평균)
|
||||||
|
hc_rows = await db.execute(
|
||||||
|
select(HealthCheckResult).order_by(desc(HealthCheckResult.created_at)).limit(7)
|
||||||
|
)
|
||||||
|
hc_list = hc_rows.scalars().all()
|
||||||
|
health_score = (sum(h.passed / (h.total or 1) * 100 for h in hc_list) / len(hc_list)) if hc_list else 0.0
|
||||||
|
|
||||||
|
# 자동 수복 횟수
|
||||||
|
auto_heals = (await db.execute(
|
||||||
|
select(sqlfunc.count()).select_from(AutonomousAction)
|
||||||
|
.where(AutonomousAction.executed_at >= since, AutonomousAction.success == True)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# 최신 자립도
|
||||||
|
score_row = await db.execute(
|
||||||
|
select(IndependenceScore)
|
||||||
|
.where(IndependenceScore.dimension == "overall")
|
||||||
|
.order_by(desc(IndependenceScore.measured_at)).limit(1)
|
||||||
|
)
|
||||||
|
latest_score = score_row.scalar_one_or_none()
|
||||||
|
indep = latest_score.score if latest_score else 30.0
|
||||||
|
|
||||||
|
summary_lines = [
|
||||||
|
f"📊 주간 보고서 ({period})",
|
||||||
|
f" SR 처리: {sr_total}건 (AI 자동처리: {sr_auto}건, {round(sr_auto/sr_total*100) if sr_total else 0}%)",
|
||||||
|
f" 건강점수: {health_score:.1f}%",
|
||||||
|
f" 자가수복: {auto_heals}회",
|
||||||
|
f" 자립도: {indep:.1f}%",
|
||||||
|
]
|
||||||
|
summary = "\n".join(summary_lines)
|
||||||
|
|
||||||
|
report = SelfReport(
|
||||||
|
period=period, sr_total=sr_total, sr_auto_handled=sr_auto,
|
||||||
|
health_score=health_score, auto_heals=auto_heals, incidents=0,
|
||||||
|
summary=summary, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(report)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(report)
|
||||||
|
|
||||||
|
return {"id": report.id, "period": period, "summary": summary,
|
||||||
|
"sr_total": sr_total, "sr_auto": sr_auto, "health_score": health_score,
|
||||||
|
"auto_heals": auto_heals, "independence": indep}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_report(period_label: str = None):
|
||||||
|
data = await _generate_report(period_label)
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=5) as c:
|
||||||
|
await c.post("http://127.0.0.1:9001/api/messenger/webhook", json={
|
||||||
|
"event": "weekly_self_report",
|
||||||
|
"room": "ops",
|
||||||
|
"message": data["summary"],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("보고서 발송 실패: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_report(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""주간 보고서 즉시 생성·발송."""
|
||||||
|
background_tasks.add_task(_send_report)
|
||||||
|
return {"ok": True, "message": "보고서 생성 + 메신저 발송 시작됨 (백그라운드)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_reports(
|
||||||
|
limit: int = 10,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""보고서 이력."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(SelfReport).order_by(desc(SelfReport.created_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": r.id, "period": r.period, "sr_total": r.sr_total,
|
||||||
|
"sr_auto_handled": r.sr_auto_handled, "health_score": r.health_score,
|
||||||
|
"auto_heals": r.auto_heals, "summary": r.summary, "created_at": r.created_at,
|
||||||
|
} for r in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/latest")
|
||||||
|
async def latest_report(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""최신 보고서."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(SelfReport).order_by(desc(SelfReport.created_at)).limit(1)
|
||||||
|
)
|
||||||
|
r = row.scalar_one_or_none()
|
||||||
|
if not r:
|
||||||
|
return {"message": "보고서 없음. POST /api/self-report/generate 실행"}
|
||||||
|
return {"id": r.id, "period": r.period, "summary": r.summary,
|
||||||
|
"sr_total": r.sr_total, "sr_auto_handled": r.sr_auto_handled,
|
||||||
|
"health_score": r.health_score, "auto_heals": r.auto_heals,
|
||||||
|
"created_at": r.created_at}
|
||||||
0
test_a3.db
Normal file
0
test_a3.db
Normal file
Loading…
Reference in New Issue
Block a user