Compare commits

...

3 Commits

Author SHA1 Message Date
DESKTOP-TKLFCPR\ython
b18f032d2e manual-deploy 2026-06-03 15:13 2026-06-03 15:13:33 +09:00
GUARDiA AutoDeploy
a2e9be2042 feat(cicd): 자동 배포 완전 자동화 — 훅+스케줄러+수동+ITSM연동 [auto-sync] 2026-06-03 15:12:09 +09:00
GUARDiA AutoDeploy
0f8d98074a feat(parent): GUARDiA 부모 역할 4가지 구현 완성 [auto-sync] 2026-06-03 15:07:28 +09:00
15 changed files with 1666 additions and 0 deletions

BIN
guardia_itsm.db Normal file

Binary file not shown.

22
main.py
View File

@ -431,6 +431,28 @@ app.include_router(icon_generator.router) # SVG 아이콘 생성
app.include_router(css_generator.router) # 자연어→CSS/Tailwind
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")

124
models.py
View File

@ -6136,3 +6136,127 @@ class CommandHistory(Base):
room = Column(String(100), default="general")
success = Column(Boolean, default=True)
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
View 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}

View 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
View 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
View 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
View 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
View 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
}
}

View 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
View 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
View 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
View 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
View 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
View File