601 lines
18 KiB
Python
601 lines
18 KiB
Python
from html import escape
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
from fastapi import FastAPI, Form, HTTPException
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
PROJECT_DIR = BASE_DIR.parent
|
|
ML_DIR = PROJECT_DIR / "ml"
|
|
|
|
if str(ML_DIR) not in sys.path:
|
|
sys.path.append(str(ML_DIR))
|
|
|
|
from database import ( # noqa: E402
|
|
delete_prediction_result,
|
|
get_database_label,
|
|
get_prediction_result,
|
|
init_db,
|
|
list_prediction_results,
|
|
save_prediction_result,
|
|
update_prediction_result,
|
|
)
|
|
|
|
|
|
app = FastAPI(title="ConfiVoice Admin Web")
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
init_db()
|
|
|
|
|
|
@app.get("/")
|
|
def read_root():
|
|
return {
|
|
"name": "ConfiVoice Admin Web",
|
|
"status": "ok",
|
|
"admin_page": "/admin",
|
|
"database": get_database_label(),
|
|
}
|
|
|
|
|
|
@app.get("/predictions")
|
|
def read_predictions(limit: int = 500):
|
|
return {
|
|
"database": get_database_label(),
|
|
"data": list_prediction_results(limit=limit),
|
|
}
|
|
|
|
|
|
@app.post("/admin/predictions")
|
|
def create_prediction_from_admin(
|
|
student_name: str = Form(...),
|
|
predicted_label: str = Form("PD"),
|
|
description: str = Form(""),
|
|
confidence_percent: float = Form(0),
|
|
probability_pd_percent: float = Form(0),
|
|
probability_tpd_percent: float = Form(0),
|
|
is_valid_audio: str = Form("1"),
|
|
error_message: str = Form(""),
|
|
audio_duration: float = Form(0),
|
|
):
|
|
student_name = student_name.strip()
|
|
if not student_name:
|
|
raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.")
|
|
|
|
save_prediction_result(
|
|
student_name,
|
|
_result_payload(
|
|
predicted_label=predicted_label,
|
|
description=description,
|
|
confidence_percent=confidence_percent,
|
|
probability_pd_percent=probability_pd_percent,
|
|
probability_tpd_percent=probability_tpd_percent,
|
|
is_valid_audio=is_valid_audio,
|
|
error_message=error_message,
|
|
audio_duration=audio_duration,
|
|
),
|
|
)
|
|
return _redirect_admin()
|
|
|
|
|
|
@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse)
|
|
def read_edit_prediction_page(prediction_id: int):
|
|
row = get_prediction_result(prediction_id)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Data tidak ditemukan.")
|
|
|
|
return _html_page(
|
|
title=f"Edit Data #{prediction_id}",
|
|
content=f"""
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2>Edit Data #{prediction_id}</h2>
|
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
|
</div>
|
|
<a class="button secondary" href="/admin">Kembali</a>
|
|
</div>
|
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
|
</section>
|
|
""",
|
|
)
|
|
|
|
|
|
@app.post("/admin/predictions/{prediction_id}/edit")
|
|
def update_prediction_from_admin(
|
|
prediction_id: int,
|
|
student_name: str = Form(...),
|
|
predicted_label: str = Form("PD"),
|
|
description: str = Form(""),
|
|
confidence_percent: float = Form(0),
|
|
probability_pd_percent: float = Form(0),
|
|
probability_tpd_percent: float = Form(0),
|
|
is_valid_audio: str = Form("1"),
|
|
error_message: str = Form(""),
|
|
audio_duration: float = Form(0),
|
|
):
|
|
student_name = student_name.strip()
|
|
if not student_name:
|
|
raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.")
|
|
|
|
updated = update_prediction_result(
|
|
prediction_id,
|
|
_db_values(
|
|
student_name=student_name,
|
|
predicted_label=predicted_label,
|
|
description=description,
|
|
confidence_percent=confidence_percent,
|
|
probability_pd_percent=probability_pd_percent,
|
|
probability_tpd_percent=probability_tpd_percent,
|
|
is_valid_audio=is_valid_audio,
|
|
error_message=error_message,
|
|
audio_duration=audio_duration,
|
|
),
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="Data tidak ditemukan.")
|
|
|
|
return _redirect_admin()
|
|
|
|
|
|
@app.post("/admin/predictions/{prediction_id}/delete")
|
|
def delete_prediction_from_admin(prediction_id: int):
|
|
delete_prediction_result(prediction_id)
|
|
return _redirect_admin()
|
|
|
|
|
|
@app.get("/admin", response_class=HTMLResponse)
|
|
def read_admin_page():
|
|
rows = list_prediction_results(limit=500)
|
|
total = len(rows)
|
|
pd_count = sum(1 for row in rows if row["predicted_label"] == "PD")
|
|
tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD")
|
|
avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0
|
|
|
|
return _html_page(
|
|
title="Admin ConfiVoice",
|
|
content=f"""
|
|
<header>
|
|
<div>
|
|
<h1>Admin ConfiVoice</h1>
|
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
|
</div>
|
|
</header>
|
|
<section class="stats">
|
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
|
<div class="stat"><span>Tidak ercaya diri</span><strong>{tpd_count}</strong></div>
|
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2>Tambah Data</h2>
|
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
|
</div>
|
|
</div>
|
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div>
|
|
<h2>Data Analisis</h2>
|
|
<p>Kelola data yang sudah tersimpan.</p>
|
|
</div>
|
|
</div>
|
|
{_prediction_table(rows)}
|
|
</section>
|
|
""",
|
|
)
|
|
|
|
|
|
def _redirect_admin():
|
|
return RedirectResponse(url="/admin", status_code=303)
|
|
|
|
|
|
def _result_payload(
|
|
*,
|
|
predicted_label,
|
|
description,
|
|
confidence_percent,
|
|
probability_pd_percent,
|
|
probability_tpd_percent,
|
|
is_valid_audio,
|
|
error_message,
|
|
audio_duration,
|
|
):
|
|
return {
|
|
"predicted_label": predicted_label,
|
|
"description": description,
|
|
"confidence": _percent_to_ratio(confidence_percent),
|
|
"probability_pd": _percent_to_ratio(probability_pd_percent),
|
|
"probability_tpd": _percent_to_ratio(probability_tpd_percent),
|
|
"is_valid_audio": is_valid_audio == "1",
|
|
"error_message": error_message.strip() or None,
|
|
"audio_quality": {"duration": audio_duration},
|
|
"voice_indicators": {},
|
|
}
|
|
|
|
|
|
def _db_values(
|
|
*,
|
|
student_name,
|
|
predicted_label,
|
|
description,
|
|
confidence_percent,
|
|
probability_pd_percent,
|
|
probability_tpd_percent,
|
|
is_valid_audio,
|
|
error_message,
|
|
audio_duration,
|
|
):
|
|
return (
|
|
student_name,
|
|
predicted_label.strip() or None,
|
|
description.strip() or None,
|
|
_percent_to_ratio(confidence_percent),
|
|
_percent_to_ratio(probability_pd_percent),
|
|
_percent_to_ratio(probability_tpd_percent),
|
|
1 if is_valid_audio == "1" else 0,
|
|
error_message.strip() or None,
|
|
float(audio_duration or 0),
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
)
|
|
|
|
|
|
def _percent_to_ratio(value):
|
|
return max(0, min(float(value or 0), 100)) / 100
|
|
|
|
|
|
def _ratio_to_percent(value):
|
|
return f"{float(value or 0) * 100:.2f}"
|
|
|
|
|
|
def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"):
|
|
row = row or {}
|
|
student_name = escape(str(row.get("student_name") or ""))
|
|
predicted_label = str(row.get("predicted_label") or "PD")
|
|
description = escape(str(row.get("description") or ""))
|
|
confidence = _ratio_to_percent(row.get("confidence"))
|
|
probability_pd = _ratio_to_percent(row.get("probability_pd"))
|
|
probability_tpd = _ratio_to_percent(row.get("probability_tpd"))
|
|
audio_duration = float(row.get("audio_duration") or 0)
|
|
error_message = escape(str(row.get("error_message") or ""))
|
|
valid_audio = str(int(row.get("is_valid_audio", 1))) == "1"
|
|
pd_selected = "selected" if predicted_label == "PD" else ""
|
|
tpd_selected = "selected" if predicted_label == "TPD" else ""
|
|
valid_selected = "selected" if valid_audio else ""
|
|
invalid_selected = "selected" if not valid_audio else ""
|
|
|
|
return f"""
|
|
<form class="form-grid" method="post" action="{escape(action)}">
|
|
<label>
|
|
<span>Nama siswa/i</span>
|
|
<input name="student_name" value="{student_name}" required>
|
|
</label>
|
|
<label>
|
|
<span>Label</span>
|
|
<select name="predicted_label">
|
|
<option value="PD" {pd_selected}>PD</option>
|
|
<option value="TPD" {tpd_selected}>TPD</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Confidence (%)</span>
|
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
|
</label>
|
|
<label>
|
|
<span>PD (%)</span>
|
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
|
</label>
|
|
<label>
|
|
<span>TPD (%)</span>
|
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
|
</label>
|
|
<label>
|
|
<span>Durasi audio (detik)</span>
|
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
|
</label>
|
|
<label>
|
|
<span>Status audio</span>
|
|
<select name="is_valid_audio">
|
|
<option value="1" {valid_selected}>Valid</option>
|
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
|
</select>
|
|
</label>
|
|
<label class="wide">
|
|
<span>Keterangan</span>
|
|
<input name="description" value="{description}">
|
|
</label>
|
|
<label class="wide">
|
|
<span>Pesan error audio</span>
|
|
<input name="error_message" value="{error_message}">
|
|
</label>
|
|
<div class="form-actions">
|
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
|
</div>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def _prediction_table(rows):
|
|
table_rows = []
|
|
for row in rows:
|
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
|
table_rows.append(
|
|
"<tr>"
|
|
f"<td>{row['id']}</td>"
|
|
f"<td>{escape(str(row['created_at']))}</td>"
|
|
f"<td>{escape(row['student_name'])}</td>"
|
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
|
f"<td>{escape(row['description'] or '-')}</td>"
|
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
|
f"<td>{valid_text}</td>"
|
|
"<td class='row-actions'>"
|
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
|
"</form>"
|
|
"</td>"
|
|
"</tr>"
|
|
)
|
|
|
|
body = "\n".join(table_rows) or (
|
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
|
)
|
|
|
|
return f"""
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Waktu</th>
|
|
<th>Nama siswa/i</th>
|
|
<th>Label</th>
|
|
<th>Keterangan</th>
|
|
<th>Confidence</th>
|
|
<th>PD</th>
|
|
<th>TPD</th>
|
|
<th>Durasi</th>
|
|
<th>Status audio</th>
|
|
<th>Aksi</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{body}</tbody>
|
|
</table>
|
|
</div>
|
|
"""
|
|
|
|
|
|
def _html_page(title, content):
|
|
return f"""
|
|
<!doctype html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{escape(title)}</title>
|
|
<style>
|
|
:root {{
|
|
font-family: Arial, sans-serif;
|
|
background: #f6f8f8;
|
|
color: #17211f;
|
|
}}
|
|
* {{
|
|
box-sizing: border-box;
|
|
}}
|
|
body {{
|
|
margin: 0;
|
|
padding: 24px;
|
|
}}
|
|
header {{
|
|
margin-bottom: 18px;
|
|
}}
|
|
h1, h2 {{
|
|
margin: 0 0 6px;
|
|
}}
|
|
h1 {{
|
|
font-size: 28px;
|
|
}}
|
|
h2 {{
|
|
font-size: 20px;
|
|
}}
|
|
p {{
|
|
margin: 0;
|
|
color: #586966;
|
|
}}
|
|
.stats {{
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}}
|
|
.stat, .panel {{
|
|
background: white;
|
|
border: 1px solid #dde7e4;
|
|
border-radius: 8px;
|
|
}}
|
|
.stat {{
|
|
padding: 14px;
|
|
}}
|
|
.stat span {{
|
|
display: block;
|
|
color: #687976;
|
|
font-size: 13px;
|
|
margin-bottom: 6px;
|
|
}}
|
|
.stat strong {{
|
|
font-size: 24px;
|
|
}}
|
|
.panel {{
|
|
margin-bottom: 16px;
|
|
padding: 16px;
|
|
}}
|
|
.panel-head {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
}}
|
|
.form-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
|
gap: 12px;
|
|
}}
|
|
label span {{
|
|
display: block;
|
|
color: #586966;
|
|
font-size: 13px;
|
|
margin-bottom: 6px;
|
|
}}
|
|
input, select {{
|
|
width: 100%;
|
|
min-height: 40px;
|
|
border: 1px solid #cbd9d6;
|
|
border-radius: 8px;
|
|
padding: 9px 10px;
|
|
font: inherit;
|
|
background: #ffffff;
|
|
color: #17211f;
|
|
}}
|
|
.wide {{
|
|
grid-column: span 2;
|
|
}}
|
|
.form-actions {{
|
|
display: flex;
|
|
align-items: end;
|
|
}}
|
|
.button, .small-button {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 0;
|
|
border-radius: 8px;
|
|
background: #0f766e;
|
|
color: #ffffff;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}}
|
|
.button {{
|
|
min-height: 40px;
|
|
padding: 9px 14px;
|
|
}}
|
|
.button:hover, .small-button:hover {{
|
|
background: #115e59;
|
|
}}
|
|
.button.secondary, .small-button {{
|
|
background: #e7f4f1;
|
|
color: #0f766e;
|
|
}}
|
|
.button.secondary:hover, .small-button:hover {{
|
|
background: #d3ebe7;
|
|
}}
|
|
.small-button {{
|
|
min-height: 32px;
|
|
padding: 7px 10px;
|
|
font-size: 13px;
|
|
}}
|
|
.small-button.danger {{
|
|
background: #fee2e2;
|
|
color: #b91c1c;
|
|
}}
|
|
.small-button.danger:hover {{
|
|
background: #fecaca;
|
|
}}
|
|
.table-wrap {{
|
|
overflow-x: auto;
|
|
border: 1px solid #dbe7e4;
|
|
border-radius: 8px;
|
|
}}
|
|
table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
min-width: 1240px;
|
|
}}
|
|
th, td {{
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid #e8efed;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
}}
|
|
th {{
|
|
background: #eef6f4;
|
|
font-size: 13px;
|
|
text-transform: uppercase;
|
|
}}
|
|
tr:last-child td {{
|
|
border-bottom: 0;
|
|
}}
|
|
.badge {{
|
|
display: inline-block;
|
|
min-width: 40px;
|
|
padding: 4px 8px;
|
|
border-radius: 999px;
|
|
background: #e7f4f1;
|
|
color: #0f766e;
|
|
text-align: center;
|
|
font-weight: 700;
|
|
}}
|
|
.empty {{
|
|
text-align: center;
|
|
color: #687a76;
|
|
}}
|
|
.row-actions {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}}
|
|
.row-actions form {{
|
|
margin: 0;
|
|
}}
|
|
@media (max-width: 980px) {{
|
|
.form-grid {{
|
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
|
}}
|
|
.wide {{
|
|
grid-column: span 2;
|
|
}}
|
|
}}
|
|
@media (max-width: 760px) {{
|
|
body {{
|
|
padding: 14px;
|
|
}}
|
|
.stats {{
|
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
|
}}
|
|
.form-grid {{
|
|
grid-template-columns: 1fr;
|
|
}}
|
|
.wide {{
|
|
grid-column: span 1;
|
|
}}
|
|
.panel-head {{
|
|
display: block;
|
|
}}
|
|
.panel-head .button {{
|
|
margin-top: 12px;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{content}
|
|
</body>
|
|
</html>
|
|
"""
|