deliberation_coach / admin.html
jonghhhh's picture
deploy: AI μˆ™μ˜μ½”μΉ˜
445616d verified
Raw History Blame Contribute Delete
12.8 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI μˆ™μ˜μ½”μΉ˜ β€” κ΄€λ¦¬μž</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<style>
:root{
--ink:#1d2433; --muted:#5b6472; --paper:#f5f6f8; --card:#fff; --line:#e3e6ea;
--accent:#2f7d78; --danger:#b5832c; --radius:12px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);
font-family:Pretendard,system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.55;}
.wrap{max-width:1100px;margin:0 auto;padding:18px 18px 80px;}
h1{font-size:20px;margin:6px 0 2px;}
h2{font-size:15px;margin:0 0 10px;color:var(--muted);font-weight:600;}
.sub{color:var(--muted);font-size:13px;margin:0 0 18px;}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:16px 18px;margin-bottom:16px;}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
label{font-size:13px;color:var(--muted);display:block;margin:8px 0 3px;}
input[type=text],input[type=password]{width:100%;padding:9px 11px;border:1px solid var(--line);
border-radius:9px;font:inherit;background:#fff;}
.btn{border:0;background:var(--accent);color:#fff;padding:9px 14px;border-radius:9px;
font:inherit;cursor:pointer;font-weight:600;}
.btn.ghost{background:#fff;color:var(--ink);border:1px solid var(--line);}
.btn:disabled{opacity:.5;cursor:default;}
.btn.small{padding:5px 10px;font-size:13px;}
table{width:100%;border-collapse:collapse;font-size:13.5px;}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}
th{color:var(--muted);font-weight:600;cursor:pointer;user-select:none;}
tbody tr{cursor:pointer;}
tbody tr:hover{background:#f0f4f4;}
.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;border:1px solid var(--line);}
.pill.completed{background:#e6f0ef;color:#1f5d58;border-color:#cfe3e1;}
.pill.aborted{background:#f7e7e7;color:#9a3b3b;border-color:#eccaca;}
.pill.in_progress{background:#eef0f3;color:#5b6472;}
.muted{color:var(--muted);}
.err{color:#9a3b3b;font-size:13px;margin-top:8px;}
.ok{color:#1f5d58;font-size:13px;margin-top:8px;}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
@media(max-width:640px){.grid2{grid-template-columns:1fr;}}
/* 상세 λͺ¨λ‹¬ */
.modal-bg{position:fixed;inset:0;background:rgba(20,24,34,.45);display:flex;
align-items:flex-start;justify-content:center;padding:24px;overflow:auto;z-index:50;}
.modal{background:#fff;border-radius:var(--radius);max-width:820px;width:100%;padding:20px 22px;}
.kv{display:grid;grid-template-columns:140px 1fr;gap:4px 12px;font-size:13.5px;margin-bottom:14px;}
.kv div:nth-child(odd){color:var(--muted);}
.turn{border-left:3px solid var(--line);padding:4px 0 4px 10px;margin:6px 0;font-size:13.5px;}
.turn.ai{border-color:var(--accent);}
.turn.participant{border-color:var(--danger);}
.turn .who{font-size:12px;color:var(--muted);}
pre{background:#f5f6f8;border:1px solid var(--line);border-radius:9px;padding:10px;
overflow:auto;font-size:12.5px;max-height:280px;}
.sec{font-weight:600;margin:14px 0 6px;font-size:14px;}
</style>
</head>
<body>
<div class="wrap">
<h1>AI μˆ™μ˜μ½”μΉ˜ β€” κ΄€λ¦¬μž</h1>
<p class="sub">μ‘λ‹΅μž ν˜„ν™© Β· 응닡 데이터 확인/λ‹€μš΄λ‘œλ“œ Β· 운영 μ„€μ • 관리</p>
<!-- 둜그인 -->
<div class="card" id="loginCard">
<h2>κ΄€λ¦¬μž 토큰</h2>
<div class="row" style="align-items:flex-end;">
<div style="flex:1;min-width:220px;">
<label>ADMIN_TOKEN (.env 에 μ„€μ •ν•œ κ°’)</label>
<input type="password" id="tokenInput" placeholder="토큰 μž…λ ₯" />
</div>
<button class="btn" id="loginBtn">접속</button>
</div>
<div class="err" id="loginErr"></div>
</div>
<!-- 메인(둜그인 ν›„) -->
<div id="main" style="display:none;">
<!-- μ„€μ • -->
<div class="card">
<h2>운영 μ„€μ •</h2>
<div class="grid2">
<div>
<label>MODEL (Gemini λͺ¨λΈλͺ…)</label>
<input type="text" id="model" />
</div>
<div>
<label>RETURN_URL (μ’…λ£Œ ν›„ 볡귀 URL Β· λΉ„μš°λ©΄ μ™„λ£Œν™”λ©΄λ§Œ)</label>
<input type="text" id="returnUrl" />
</div>
</div>
<div class="row" style="margin-top:12px;">
<button class="btn" id="saveSettings">μ„€μ • μ €μž₯</button>
<span class="muted" id="keyStatus"></span>
</div>
<div class="ok" id="settingsMsg"></div>
</div>
<!-- ν˜„ν™© -->
<div class="card">
<div class="row" style="justify-content:space-between;">
<h2 style="margin:0;">μ‘λ‹΅μž ν˜„ν™© (<span id="count">0</span>λͺ…)</h2>
<div class="row">
<button class="btn ghost small" id="refresh">μƒˆλ‘œκ³ μΉ¨</button>
<button class="btn small" id="dlCsv">μš”μ•½ CSV λ‹€μš΄λ‘œλ“œ</button>
</div>
</div>
<div style="overflow:auto;margin-top:12px;">
<table id="tbl">
<thead><tr>
<th data-k="pid">pid</th>
<th data-k="completion">μƒνƒœ</th>
<th data-k="criteria_done">κΈ°μ€€μ™„λ£Œ</th>
<th data-k="final_choice">μ΅œμ’…μ„ νƒ</th>
<th data-k="confidence">확신도</th>
<th data-k="turns">λŒ€ν™”μˆ˜</th>
<th data-k="duration_sec">μ†Œμš”(초)</th>
<th data-k="saved_at">μ €μž₯μ‹œκ°</th>
<th>λ‹€μš΄λ‘œλ“œ</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="err" id="listErr"></div>
</div>
</div>
</div>
<div id="modalRoot"></div>
<script>
const $ = (id) => document.getElementById(id);
let TOKEN = "";
let ROWS = [];
let sortKey = "saved_at", sortDir = -1;
const esc = (s) => String(s == null ? "" : s)
.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
async function api(path, opts = {}) {
const headers = Object.assign({ "X-Admin-Token": TOKEN }, opts.headers || {});
const r = await fetch(path, Object.assign({}, opts, { headers }));
if (!r.ok) {
let msg = "HTTP " + r.status;
try { const j = await r.json(); if (j.detail) msg = j.detail; } catch (e) {}
throw new Error(msg);
}
return r;
}
async function login() {
TOKEN = $("tokenInput").value.trim();
$("loginErr").textContent = "";
if (!TOKEN) { $("loginErr").textContent = "토큰을 μž…λ ₯ν•˜μ„Έμš”."; return; }
try {
const s = await (await api("/api/admin/settings")).json();
sessionStorage.setItem("admin_token", TOKEN);
$("loginCard").style.display = "none";
$("main").style.display = "block";
fillSettings(s);
await loadList();
} catch (e) {
$("loginErr").textContent = "접속 μ‹€νŒ¨: " + e.message;
}
}
function fillSettings(s) {
$("model").value = s.model || "";
$("returnUrl").value = s.return_url || "";
$("keyStatus").textContent =
(s.gemini_key_set ? "βœ“ GEMINI_API_KEY 섀정됨" : "⚠ GEMINI_API_KEY μ—†μŒ(폴백 λ™μž‘)")
+ " Β· μ €μž₯폴더: " + (s.data_dir || "");
}
async function saveSettings() {
$("settingsMsg").textContent = "";
try {
const body = { model: $("model").value.trim(), return_url: $("returnUrl").value.trim() };
await api("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
$("settingsMsg").textContent = "μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.";
setTimeout(() => ($("settingsMsg").textContent = ""), 2500);
} catch (e) {
$("settingsMsg").textContent = "";
$("settingsMsg").className = "err";
$("settingsMsg").textContent = "μ €μž₯ μ‹€νŒ¨: " + e.message;
}
}
async function loadList() {
$("listErr").textContent = "";
try {
const j = await (await api("/api/admin/list")).json();
ROWS = j.rows || [];
$("count").textContent = j.count || 0;
renderTable();
} catch (e) {
$("listErr").textContent = "λͺ©λ‘ λ‘œλ“œ μ‹€νŒ¨: " + e.message;
}
}
function renderTable() {
const rows = ROWS.slice().sort((a, b) => {
const x = a[sortKey], y = b[sortKey];
if (x === y) return 0;
return (x > y ? 1 : -1) * sortDir;
});
const tb = $("tbody");
tb.innerHTML = "";
for (const r of rows) {
const tr = document.createElement("tr");
const st = esc(r.completion || "in_progress");
tr.innerHTML =
`<td>${esc(r.pid)}</td>` +
`<td><span class="pill ${st}">${st}</span></td>` +
`<td>${r.criteria_done}/${r.criteria_total}</td>` +
`<td>${esc(r.final_choice)}</td>` +
`<td>${esc(r.confidence)}</td>` +
`<td>${r.turns}</td>` +
`<td>${r.duration_sec}</td>` +
`<td class="muted">${esc(r.saved_at)}</td>` +
`<td><button class="btn ghost small" data-dl="${esc(r.pid)}">JSON</button></td>`;
tr.addEventListener("click", (ev) => {
if (ev.target.dataset.dl) return; // λ‹€μš΄λ‘œλ“œ λ²„νŠΌμ€ ν–‰ 클릭과 뢄리
showDetail(r.pid);
});
tb.appendChild(tr);
}
tb.querySelectorAll("[data-dl]").forEach((b) =>
b.addEventListener("click", () => downloadPid(b.dataset.dl)));
}
function downloadPid(pid) {
// 토큰을 쿼리둜 μ‹€μ–΄ μƒˆ νƒ­μ—μ„œ λ°›κΈ°(헀더λ₯Ό λͺ» μ“°λŠ” 직접 λ‹€μš΄λ‘œλ“œμš©)
const url = "/api/admin/download/" + encodeURIComponent(pid) + "?token=" + encodeURIComponent(TOKEN);
window.open(url, "_blank");
}
function downloadCsv() {
window.open("/api/admin/summary.csv?token=" + encodeURIComponent(TOKEN), "_blank");
}
async function showDetail(pid) {
let rec;
try {
rec = await (await api("/api/admin/record/" + encodeURIComponent(pid))).json();
} catch (e) { alert("상세 λ‘œλ“œ μ‹€νŒ¨: " + e.message); return; }
const sess = rec.session || {}, out = rec.output || {};
const crit = (rec.criteria || []).map((c) =>
`<tr><td>${esc(c.name)}</td><td>${esc(c.judgment_initial)}</td>` +
`<td>${esc(c.judgment_final)}</td><td>${esc(c.reason)}</td><td>${esc(c.weighing)}</td></tr>`
).join("");
const turns = (rec.transcript || []).map((t) =>
`<div class="turn ${esc(t.role)}"><div class="who">#${t.turn} Β· ${esc(t.role)}` +
`${t.criterion ? " Β· " + esc(t.criterion) : ""} Β· ${esc(t.ts)}</div>${esc(t.text)}</div>`
).join("");
$("modalRoot").innerHTML =
`<div class="modal-bg" id="mbg"><div class="modal">
<div class="row" style="justify-content:space-between;">
<h2 style="margin:0;">μ‘λ‹΅μž 상세 β€” ${esc(rec.pid)}</h2>
<div class="row">
<button class="btn ghost small" id="mDl">JSON λ‹€μš΄λ‘œλ“œ</button>
<button class="btn small" id="mClose">λ‹«κΈ°</button>
</div>
</div>
<div class="kv" style="margin-top:14px;">
<div>μƒνƒœ</div><div>${esc(sess.completion)}</div>
<div>μ‹œμž‘</div><div>${esc(sess.started_at)}</div>
<div>μ’…λ£Œ</div><div>${esc(sess.ended_at)}</div>
<div>μ†Œμš”(초)</div><div>${esc(sess.duration_sec)}</div>
<div>μ΅œμ’…μ„ νƒ</div><div>${esc(out.final_choice)} (확신도 ${esc(out.confidence)})</div>
</div>
<div class="sec">기쀀별 νŒλ‹¨</div>
<table><thead><tr><th>κΈ°μ€€</th><th>초기</th><th>μž¬ν‰κ°€</th><th>이유</th><th>μ €μšΈμ§ˆ</th></tr></thead>
<tbody>${crit || '<tr><td colspan="5" class="muted">μ—†μŒ</td></tr>'}</tbody></table>
<div class="sec">λŒ€ν™” λ©”μ‹œμ§€ (${(rec.transcript || []).length}ν„΄)</div>
<div>${turns || '<span class="muted">μ—†μŒ</span>'}</div>
<div class="sec">전체 원본 JSON</div>
<pre>${esc(JSON.stringify(rec, null, 2))}</pre>
</div></div>`;
$("mClose").addEventListener("click", () => ($("modalRoot").innerHTML = ""));
$("mbg").addEventListener("click", (e) => { if (e.target.id === "mbg") $("modalRoot").innerHTML = ""; });
$("mDl").addEventListener("click", () => downloadPid(rec.pid));
}
// 이벀트 바인딩
$("loginBtn").addEventListener("click", login);
$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); });
$("saveSettings").addEventListener("click", saveSettings);
$("refresh").addEventListener("click", loadList);
$("dlCsv").addEventListener("click", downloadCsv);
document.querySelectorAll("th[data-k]").forEach((th) =>
th.addEventListener("click", () => {
const k = th.dataset.k;
if (sortKey === k) sortDir *= -1; else { sortKey = k; sortDir = 1; }
renderTable();
}));
// μ„Έμ…˜μ— μ €μž₯된 토큰이 있으면 μžλ™ 둜그인 μ‹œλ„
(function () {
const t = sessionStorage.getItem("admin_token");
if (t) { $("tokenInput").value = t; login(); }
})();
</script>
</body>
</html>