MIF_E31231708/.history/cv_web/app_20260615001828.py

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 Percaya 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>
"""