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(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
124
models.py
@ -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
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