Compact AI playground admin layout
This commit is contained in:
481
app/admin_web.py
481
app/admin_web.py
@@ -206,6 +206,34 @@ def _render_admin_page(request: Request, title: str, page_title: str, body: str)
|
|||||||
.error {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }}
|
.error {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }}
|
||||||
.success {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #ecfdf5; color: #166534; border: 1px solid #86efac; }}
|
.success {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #ecfdf5; color: #166534; border: 1px solid #86efac; }}
|
||||||
.muted {{ color: #64748b; font-size: 14px; }}
|
.muted {{ color: #64748b; font-size: 14px; }}
|
||||||
|
.tabs {{ display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 18px; border-bottom: 1px solid #e2e8f0; }}
|
||||||
|
.tabs a {{ display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; color: #475569; text-decoration: none; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; font-weight: 700; font-size: 14px; }}
|
||||||
|
.tabs a.active {{ background: #fff; border-color: #e2e8f0; color: #0f172a; box-shadow: 0 -1px 0 #fff inset; }}
|
||||||
|
.compact-strip {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 14px 0; }}
|
||||||
|
.compact-stat {{ border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; padding: 12px 14px; }}
|
||||||
|
.compact-stat span {{ display: block; color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; }}
|
||||||
|
.compact-stat strong {{ display: block; margin-top: 4px; color: #0f172a; font-size: 20px; line-height: 1.1; }}
|
||||||
|
.field-grid {{ display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 12px 16px; align-items: end; }}
|
||||||
|
.field-grid .wide {{ grid-column: 1 / -1; }}
|
||||||
|
.tab-panel {{ margin-top: 8px; }}
|
||||||
|
.toolbar {{ display: flex; align-items: end; gap: 12px; flex-wrap: wrap; margin: 12px 0 16px; }}
|
||||||
|
.toolbar label {{ min-width: 150px; margin-top: 0; }}
|
||||||
|
.toolbar input, .toolbar select {{ min-width: 150px; }}
|
||||||
|
.table-wrap {{ width: 100%; overflow-x: auto; }}
|
||||||
|
.table-wrap table {{ min-width: 860px; }}
|
||||||
|
.status-pill {{ display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 999px; background: #e2e8f0; color: #334155; font-size: 12px; font-weight: 700; }}
|
||||||
|
.status-approved, .status-active {{ background: #dcfce7; color: #166534; }}
|
||||||
|
.status-rejected, .status-archived {{ background: #fee2e2; color: #991b1b; }}
|
||||||
|
.status-draft {{ background: #e0f2fe; color: #075985; }}
|
||||||
|
.status-stale {{ background: #fef3c7; color: #92400e; }}
|
||||||
|
.button-link {{ display: inline-block; padding: 9px 12px; border-radius: 8px; background: #0f172a; color: #fff; text-decoration: none; font-size: 13px; font-weight: 700; }}
|
||||||
|
.secondary-link {{ display: inline-block; padding: 10px 12px; border-radius: 8px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 14px; font-weight: 700; }}
|
||||||
|
@media (max-width: 860px) {{
|
||||||
|
.layout {{ grid-template-columns: 1fr; }}
|
||||||
|
.sidebar {{ position: static; }}
|
||||||
|
.content {{ padding: 18px; }}
|
||||||
|
.field-grid {{ grid-template-columns: 1fr; }}
|
||||||
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -586,10 +614,19 @@ async def _recent_generated_variants(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
basis_item_id: int | None = None,
|
basis_item_id: int | None = None,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
level_filter: str | None = None,
|
||||||
|
run_id_filter: int | None = None,
|
||||||
) -> list[Item]:
|
) -> list[Item]:
|
||||||
stmt = select(Item).where(Item.generated_by == "ai")
|
stmt = select(Item).where(Item.generated_by == "ai")
|
||||||
if basis_item_id is not None:
|
if basis_item_id is not None:
|
||||||
stmt = stmt.where(Item.basis_item_id == basis_item_id)
|
stmt = stmt.where(Item.basis_item_id == basis_item_id)
|
||||||
|
if status_filter:
|
||||||
|
stmt = stmt.where(Item.variant_status == status_filter)
|
||||||
|
if level_filter:
|
||||||
|
stmt = stmt.where(Item.level == level_filter)
|
||||||
|
if run_id_filter is not None:
|
||||||
|
stmt = stmt.where(Item.generation_run_id == run_id_filter)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
stmt.order_by(Item.created_at.desc(), Item.id.desc()).limit(limit)
|
stmt.order_by(Item.created_at.desc(), Item.id.desc()).limit(limit)
|
||||||
)
|
)
|
||||||
@@ -2136,6 +2173,280 @@ async def basis_item_review_bulk(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AI_PLAYGROUND_TABS = (
|
||||||
|
("generate", "Generate"),
|
||||||
|
("review", "Review Queue"),
|
||||||
|
("runs", "Runs"),
|
||||||
|
("basis", "Basis Items"),
|
||||||
|
)
|
||||||
|
AI_VARIANT_STATUSES = ("draft", "approved", "active", "rejected", "archived", "stale")
|
||||||
|
AI_VARIANT_LEVELS = ("mudah", "sulit")
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_option(value: str, selected_value: str) -> str:
|
||||||
|
return "selected" if value == selected_value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_tab_nav(active_tab: str) -> str:
|
||||||
|
links = []
|
||||||
|
for tab, label in AI_PLAYGROUND_TABS:
|
||||||
|
active_class = "active" if tab == active_tab else ""
|
||||||
|
aria = ' aria-current="page"' if tab == active_tab else ""
|
||||||
|
links.append(
|
||||||
|
f'<a class="{active_class}" href="/admin/ai-playground?tab={tab}"{aria}>{escape(label)}</a>'
|
||||||
|
)
|
||||||
|
return f'<nav class="tabs" aria-label="AI Playground sections">{"".join(links)}</nav>'
|
||||||
|
|
||||||
|
|
||||||
|
def _status_pill(status: str | None) -> str:
|
||||||
|
value = status or "unknown"
|
||||||
|
css_value = re.sub(r"[^a-z0-9_-]+", "-", value.lower())
|
||||||
|
return f'<span class="status-pill status-{escape(css_value)}">{escape(value)}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_status_strip(
|
||||||
|
key_configured: bool,
|
||||||
|
stats: dict[str, Any],
|
||||||
|
generation_runs: list[AIGenerationRun],
|
||||||
|
generation_summary: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
latest_run = "-"
|
||||||
|
latest_saved = "-"
|
||||||
|
if generation_summary:
|
||||||
|
latest_run = str(generation_summary.get("run_id", "-"))
|
||||||
|
latest_saved = str(len(generation_summary.get("saved_item_ids") or []))
|
||||||
|
elif generation_runs:
|
||||||
|
latest_run = str(generation_runs[0].id)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="compact-strip">
|
||||||
|
<div class="compact-stat"><span>OpenRouter</span><strong>{"Yes" if key_configured else "No"}</strong></div>
|
||||||
|
<div class="compact-stat"><span>AI Items</span><strong>{stats.get("total_ai_items", 0)}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Latest Run</span><strong>{escape(latest_run)}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Saved</span><strong>{escape(latest_saved)}</strong></div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_generation_summary(generation_summary: dict[str, Any] | None) -> str:
|
||||||
|
if not generation_summary:
|
||||||
|
return ""
|
||||||
|
saved_item_ids = generation_summary.get("saved_item_ids") or []
|
||||||
|
return f"""
|
||||||
|
<div class="compact-strip">
|
||||||
|
<div class="compact-stat"><span>Run ID</span><strong>{generation_summary.get("run_id", "-")}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Requested</span><strong>{generation_summary.get("requested_count", 0)}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Generated</span><strong>{generation_summary.get("generated_count", 0)}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Saved</span><strong>{len(saved_item_ids)}</strong></div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_generate_tab(
|
||||||
|
basis_items: list[Item],
|
||||||
|
generation_summary: dict[str, Any] | None,
|
||||||
|
basis_item_id: str,
|
||||||
|
target_level: str,
|
||||||
|
ai_model: str,
|
||||||
|
generation_count: str,
|
||||||
|
operator_notes: str,
|
||||||
|
include_note_for_admin: bool,
|
||||||
|
include_note_in_prompt: bool,
|
||||||
|
) -> str:
|
||||||
|
seed_callout = ""
|
||||||
|
if not basis_items:
|
||||||
|
seed_callout = """
|
||||||
|
<div class="success">No <code>sedang</code> basis items found yet.</div>
|
||||||
|
<form method="post" action="/admin/ai-playground/seed-demo">
|
||||||
|
<button type="submit">Seed Demo Basis Item</button>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<section class="tab-panel">
|
||||||
|
{seed_callout}
|
||||||
|
{_ai_generation_summary(generation_summary)}
|
||||||
|
<form method="post" action="/admin/ai-playground?tab=generate" autocomplete="off">
|
||||||
|
<div class="field-grid">
|
||||||
|
<div>
|
||||||
|
<label for="basis_item_id">Basis Item ID</label>
|
||||||
|
<input id="basis_item_id" name="basis_item_id" type="number" value="{escape(basis_item_id)}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="target_level">Target Level</label>
|
||||||
|
<select id="target_level" name="target_level">
|
||||||
|
<option value="mudah" {_selected_option("mudah", target_level)}>mudah</option>
|
||||||
|
<option value="sulit" {_selected_option("sulit", target_level)}>sulit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="generation_count">Generate Count</label>
|
||||||
|
<input id="generation_count" name="generation_count" type="number" min="1" max="50" value="{escape(generation_count)}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ai_model">Model</label>
|
||||||
|
<input id="ai_model" name="ai_model" type="text" value="{escape(ai_model or settings.OPENROUTER_MODEL_LLAMA)}" readonly style="background:#f8fafc;">
|
||||||
|
</div>
|
||||||
|
<div class="wide">
|
||||||
|
<label for="operator_notes">Optional Notes</label>
|
||||||
|
<textarea id="operator_notes" name="operator_notes" rows="3" placeholder="Optional generation note for this run">{escape(operator_notes)}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="row"><input type="checkbox" name="include_note_for_admin" {"checked" if include_note_for_admin else ""}> Save note for admin team</label>
|
||||||
|
<label class="row"><input type="checkbox" name="include_note_in_prompt" {"checked" if include_note_in_prompt else ""}> Include note in AI prompt payload</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Generate Run</button>
|
||||||
|
<a class="secondary-link" href="/admin/ai-playground?tab=basis">Find Basis Item</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_basis_tab(basis_items: list[Item]) -> str:
|
||||||
|
rows = []
|
||||||
|
for item in basis_items:
|
||||||
|
stem_preview = _truncate(_html_to_text(item.stem), 140)
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{item.id}</td>"
|
||||||
|
f"<td>{escape(str(item.tryout_id))}</td>"
|
||||||
|
f"<td>{item.slot}</td>"
|
||||||
|
f"<td>{item.website_id}</td>"
|
||||||
|
f"<td>{escape(stem_preview)}</td>"
|
||||||
|
f"<td><a class=\"button-link\" href=\"/admin/ai-playground?tab=generate&basis_item_id={item.id}\">Use</a></td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
table = (
|
||||||
|
"<div class=\"table-wrap\"><table><thead><tr><th>Item ID</th><th>Tryout</th><th>Slot</th><th>Website</th><th>Stem</th><th>Action</th></tr></thead><tbody>"
|
||||||
|
+ ("".join(rows) if rows else "<tr><td colspan=\"6\">No sedang basis items found.</td></tr>")
|
||||||
|
+ "</tbody></table></div>"
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<section class="tab-panel">
|
||||||
|
<div class="actions" style="margin-top:0">
|
||||||
|
<form method="post" action="/admin/ai-playground/seed-demo">
|
||||||
|
<button type="submit">Seed Demo Basis Item</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{table}
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_runs_tab(
|
||||||
|
generation_runs: list[AIGenerationRun],
|
||||||
|
generation_summary: dict[str, Any] | None,
|
||||||
|
) -> str:
|
||||||
|
rows = []
|
||||||
|
for run in generation_runs:
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{run.id}</td>"
|
||||||
|
f"<td>{run.basis_item_id}</td>"
|
||||||
|
f"<td>{escape(run.target_level)}</td>"
|
||||||
|
f"<td>{run.requested_count}</td>"
|
||||||
|
f"<td>{escape(_truncate(run.model, 54))}</td>"
|
||||||
|
f"<td>{escape(run.created_by)}</td>"
|
||||||
|
f"<td>{escape(str(run.created_at))}</td>"
|
||||||
|
f"<td><a class=\"secondary-link\" href=\"/admin/ai-playground?tab=review&run_id={run.id}\">Review</a></td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
table = (
|
||||||
|
"<div class=\"table-wrap\"><table><thead><tr><th>Run ID</th><th>Basis Item</th><th>Target</th><th>Requested</th><th>Model</th><th>Created By</th><th>Created At</th><th>Action</th></tr></thead><tbody>"
|
||||||
|
+ ("".join(rows) if rows else "<tr><td colspan=\"8\">No generation runs yet.</td></tr>")
|
||||||
|
+ "</tbody></table></div>"
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<section class="tab-panel">
|
||||||
|
{_ai_generation_summary(generation_summary)}
|
||||||
|
{table}
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_review_tab(
|
||||||
|
generated_variants: list[Item],
|
||||||
|
status_filter: str,
|
||||||
|
level_filter: str,
|
||||||
|
run_id_filter: str,
|
||||||
|
) -> str:
|
||||||
|
status_options = ['<option value="">All statuses</option>']
|
||||||
|
for status in AI_VARIANT_STATUSES:
|
||||||
|
status_options.append(
|
||||||
|
f'<option value="{status}" {_selected_option(status, status_filter)}>{status}</option>'
|
||||||
|
)
|
||||||
|
level_options = ['<option value="">All levels</option>']
|
||||||
|
for level in AI_VARIANT_LEVELS:
|
||||||
|
level_options.append(
|
||||||
|
f'<option value="{level}" {_selected_option(level, level_filter)}>{level}</option>'
|
||||||
|
)
|
||||||
|
|
||||||
|
variant_rows = []
|
||||||
|
for item in generated_variants:
|
||||||
|
stem_preview = _truncate(_html_to_text(item.stem), 120)
|
||||||
|
variant_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><input type=\"checkbox\" name=\"item_ids\" value=\"{item.id}\"></td>"
|
||||||
|
f"<td>{item.id}</td>"
|
||||||
|
f"<td>{item.generation_run_id or '-'}</td>"
|
||||||
|
f"<td>{item.basis_item_id or '-'}</td>"
|
||||||
|
f"<td>{escape(item.level)}</td>"
|
||||||
|
f"<td>{_status_pill(item.variant_status)}</td>"
|
||||||
|
f"<td>{escape(_truncate(item.ai_model or '-', 42))}</td>"
|
||||||
|
f"<td>{escape(stem_preview)}</td>"
|
||||||
|
f"<td>{escape(str(item.created_at))}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
variant_table_rows = (
|
||||||
|
"".join(variant_rows)
|
||||||
|
if variant_rows
|
||||||
|
else '<tr><td colspan="9">No AI-generated variants match this view.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<section class="tab-panel">
|
||||||
|
<form class="toolbar" method="get" action="/admin/ai-playground">
|
||||||
|
<input type="hidden" name="tab" value="review">
|
||||||
|
<label>Status
|
||||||
|
<select name="status">{"".join(status_options)}</select>
|
||||||
|
</label>
|
||||||
|
<label>Level
|
||||||
|
<select name="level">{"".join(level_options)}</select>
|
||||||
|
</label>
|
||||||
|
<label>Run ID
|
||||||
|
<input name="run_id" type="number" min="1" value="{escape(run_id_filter)}">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
<a class="secondary-link" href="/admin/ai-playground?tab=review">Clear</a>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/ai-playground/review-bulk?tab=review">
|
||||||
|
<div class="actions" style="margin:16px 0">
|
||||||
|
<select name="action" style="max-width:260px">
|
||||||
|
<option value="approved">Approve selected</option>
|
||||||
|
<option value="rejected">Reject selected</option>
|
||||||
|
<option value="archived">Archive selected</option>
|
||||||
|
<option value="stale">Mark stale</option>
|
||||||
|
<option value="active">Activate selected</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th><input type="checkbox" onclick="document.querySelectorAll('input[name="item_ids"]').forEach(el => el.checked = this.checked)"></th><th>Item ID</th><th>Run ID</th><th>Basis</th><th>Level</th><th>Status</th><th>Model</th><th>Stem</th><th>Created At</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{variant_table_rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _ai_form_body(
|
def _ai_form_body(
|
||||||
key_configured: bool,
|
key_configured: bool,
|
||||||
stats: dict[str, Any],
|
stats: dict[str, Any],
|
||||||
@@ -2152,135 +2463,47 @@ def _ai_form_body(
|
|||||||
operator_notes: str = "",
|
operator_notes: str = "",
|
||||||
include_note_for_admin: bool = True,
|
include_note_for_admin: bool = True,
|
||||||
include_note_in_prompt: bool = False,
|
include_note_in_prompt: bool = False,
|
||||||
|
active_tab: str = "generate",
|
||||||
|
variant_status_filter: str = "",
|
||||||
|
variant_level_filter: str = "",
|
||||||
|
variant_run_id_filter: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
|
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
|
||||||
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
|
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
|
||||||
basis_items = basis_items or []
|
basis_items = basis_items or []
|
||||||
generation_runs = generation_runs or []
|
generation_runs = generation_runs or []
|
||||||
generated_variants = generated_variants or []
|
generated_variants = generated_variants or []
|
||||||
basis_rows = [
|
if active_tab not in {tab for tab, _ in AI_PLAYGROUND_TABS}:
|
||||||
[
|
active_tab = "generate"
|
||||||
item.id,
|
|
||||||
item.tryout_id,
|
|
||||||
item.slot,
|
|
||||||
item.website_id,
|
|
||||||
_truncate(item.stem, 90),
|
|
||||||
]
|
|
||||||
for item in basis_items
|
|
||||||
]
|
|
||||||
basis_table = _table(
|
|
||||||
["Item ID", "Tryout", "Slot", "Website", "Stem"],
|
|
||||||
basis_rows,
|
|
||||||
)
|
|
||||||
seed_callout = ""
|
|
||||||
if not basis_items:
|
|
||||||
seed_callout = """
|
|
||||||
<div class="success">
|
|
||||||
No <code>sedang</code> basis items found yet. Seed one demo website, tryout, and basis item to test AI generation immediately.
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/admin/ai-playground/seed-demo">
|
|
||||||
<button type="submit">Seed Demo Basis Item</button>
|
|
||||||
</form>
|
|
||||||
"""
|
|
||||||
summary_html = ""
|
|
||||||
if generation_summary:
|
|
||||||
saved_item_ids = generation_summary.get("saved_item_ids") or []
|
|
||||||
summary_html = f"""
|
|
||||||
<h3 style="margin-top:24px">Latest Generation Run</h3>
|
|
||||||
<div class="grid">
|
|
||||||
<div class="stat">Run ID<strong>{generation_summary.get("run_id", "-")}</strong></div>
|
|
||||||
<div class="stat">Requested<strong>{generation_summary.get("requested_count", 0)}</strong></div>
|
|
||||||
<div class="stat">Generated<strong>{generation_summary.get("generated_count", 0)}</strong></div>
|
|
||||||
<div class="stat">Saved<strong>{len(saved_item_ids)}</strong></div>
|
|
||||||
</div>
|
|
||||||
<p class="muted">Each saved output starts as <code>draft</code>. Review per item below to approve, reject, archive, stale, or activate.</p>
|
|
||||||
"""
|
|
||||||
|
|
||||||
run_rows = [
|
tab_html = {
|
||||||
[
|
"generate": _ai_generate_tab(
|
||||||
run.id,
|
basis_items,
|
||||||
run.basis_item_id,
|
generation_summary,
|
||||||
run.target_level,
|
basis_item_id,
|
||||||
run.requested_count,
|
target_level,
|
||||||
run.model,
|
ai_model,
|
||||||
run.created_by,
|
generation_count,
|
||||||
str(run.created_at),
|
operator_notes,
|
||||||
]
|
include_note_for_admin,
|
||||||
for run in generation_runs
|
include_note_in_prompt,
|
||||||
]
|
),
|
||||||
runs_table = _table(
|
"review": _ai_review_tab(
|
||||||
["Run ID", "Basis Item", "Target", "Requested", "Model", "Created By", "Created At"],
|
generated_variants,
|
||||||
run_rows,
|
variant_status_filter,
|
||||||
)
|
variant_level_filter,
|
||||||
|
variant_run_id_filter,
|
||||||
variant_rows = []
|
),
|
||||||
for item in generated_variants:
|
"runs": _ai_runs_tab(generation_runs, generation_summary),
|
||||||
variant_rows.append(
|
"basis": _ai_basis_tab(basis_items),
|
||||||
"<tr>"
|
}[active_tab]
|
||||||
f"<td><input type=\"checkbox\" name=\"item_ids\" value=\"{item.id}\"></td>"
|
|
||||||
f"<td>{item.id}</td>"
|
|
||||||
f"<td>{item.generation_run_id or '-'}</td>"
|
|
||||||
f"<td>{item.basis_item_id or '-'}</td>"
|
|
||||||
f"<td>{escape(item.level)}</td>"
|
|
||||||
f"<td>{escape(item.variant_status)}</td>"
|
|
||||||
f"<td>{escape(item.ai_model or '-')}</td>"
|
|
||||||
f"<td>{escape(_truncate(item.stem, 100))}</td>"
|
|
||||||
f"<td>{escape(str(item.created_at))}</td>"
|
|
||||||
"</tr>"
|
|
||||||
)
|
|
||||||
variants_table = (
|
|
||||||
"<form method=\"post\" action=\"/admin/ai-playground/review-bulk\">"
|
|
||||||
"<div class=\"actions\" style=\"margin:16px 0\">"
|
|
||||||
"<select name=\"action\" style=\"max-width:260px\">"
|
|
||||||
"<option value=\"approved\">Approve selected</option>"
|
|
||||||
"<option value=\"rejected\">Reject selected</option>"
|
|
||||||
"<option value=\"archived\">Archive selected</option>"
|
|
||||||
"<option value=\"stale\">Mark stale</option>"
|
|
||||||
"<option value=\"active\">Activate selected</option>"
|
|
||||||
"</select>"
|
|
||||||
"<button type=\"submit\">Apply</button>"
|
|
||||||
"</div>"
|
|
||||||
"<table><thead><tr><th><input type=\"checkbox\" onclick=\"document.querySelectorAll('input[name="item_ids"]').forEach(el => el.checked = this.checked)\"></th><th>Item ID</th><th>Run ID</th><th>Basis</th><th>Level</th><th>Status</th><th>Model</th><th>Stem</th><th>Created At</th></tr></thead><tbody>"
|
|
||||||
+ ("".join(variant_rows) if variant_rows else "<tr><td colspan=\"9\">No AI-generated variants yet.</td></tr>")
|
|
||||||
+ "</tbody></table></form>"
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<p class="muted">OpenRouter key configured: <strong>{"Yes" if key_configured else "No"}</strong></p>
|
{_ai_status_strip(key_configured, stats, generation_runs, generation_summary)}
|
||||||
<p class="muted">Total AI-generated items: <strong>{stats.get("total_ai_items", 0)}</strong></p>
|
|
||||||
<p class="muted">Hybrid workflow: one run can generate one or many variants; each item remains independently reviewable.</p>
|
|
||||||
{success_html}
|
{success_html}
|
||||||
{error_html}
|
{error_html}
|
||||||
{seed_callout}
|
{_ai_tab_nav(active_tab)}
|
||||||
<form method="post" action="/admin/ai-playground" autocomplete="off">
|
{tab_html}
|
||||||
<label for="basis_item_id">Basis Item ID</label>
|
|
||||||
<input id="basis_item_id" name="basis_item_id" type="number" value="{escape(basis_item_id)}">
|
|
||||||
<label for="target_level">Target Level</label>
|
|
||||||
<select id="target_level" name="target_level" style="width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;padding:12px 14px;font-size:15px;">
|
|
||||||
<option value="mudah" {"selected" if target_level == "mudah" else ""}>mudah</option>
|
|
||||||
<option value="sulit" {"selected" if target_level == "sulit" else ""}>sulit</option>
|
|
||||||
</select>
|
|
||||||
<label for="ai_model">Model</label>
|
|
||||||
<input id="ai_model" name="ai_model" type="text" value="{escape(settings.OPENROUTER_MODEL_LLAMA)}" readonly style="width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;padding:12px 14px;font-size:15px;background:#f8fafc;">
|
|
||||||
<label for="generation_count">Generate Count</label>
|
|
||||||
<input id="generation_count" name="generation_count" type="number" min="1" max="50" value="{escape(generation_count)}">
|
|
||||||
<p class="muted">Recommended: 1-3 variants per run. Larger runs can increase overlap and review burden. Backend safety cap: 50.</p>
|
|
||||||
<label for="operator_notes">Optional Notes (style hints)</label>
|
|
||||||
<textarea id="operator_notes" name="operator_notes" rows="3" placeholder="Optional generation note for this run">{escape(operator_notes)}</textarea>
|
|
||||||
<label class="row"><input type="checkbox" name="include_note_for_admin" {"checked" if include_note_for_admin else ""}> Save note for admin team (visible in run history)</label>
|
|
||||||
<label class="row"><input type="checkbox" name="include_note_in_prompt" {"checked" if include_note_in_prompt else ""}> Include note in AI prompt payload</label>
|
|
||||||
<p class="muted" style="margin-top:6px;">Example note: <code>Use simple wording, one-step arithmetic, avoid trick options.</code></p>
|
|
||||||
<button type="submit">Generate Run</button>
|
|
||||||
</form>
|
|
||||||
<h3 style="margin-top:24px">Available Sedang Basis Items</h3>
|
|
||||||
<p class="muted">The generator needs a <code>sedang</code> item. Use one of these IDs, or seed demo data if the table is empty.</p>
|
|
||||||
{basis_table}
|
|
||||||
{summary_html}
|
|
||||||
<h3 style="margin-top:24px">Recent Generation Runs</h3>
|
|
||||||
{runs_table}
|
|
||||||
<h3 style="margin-top:24px">Generated Variants (Review Queue)</h3>
|
|
||||||
<p class="muted">Use bulk review actions to move items from draft to approved/active, or reject/archive/stale.</p>
|
|
||||||
{variants_table}
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -2293,6 +2516,17 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
|
|||||||
stats = await get_ai_stats(db)
|
stats = await get_ai_stats(db)
|
||||||
basis_items = await _basis_items_for_playground(db)
|
basis_items = await _basis_items_for_playground(db)
|
||||||
basis_item_id = request.query_params.get("basis_item_id", "")
|
basis_item_id = request.query_params.get("basis_item_id", "")
|
||||||
|
active_tab = request.query_params.get("tab", "generate")
|
||||||
|
if active_tab not in {tab for tab, _ in AI_PLAYGROUND_TABS}:
|
||||||
|
active_tab = "generate"
|
||||||
|
status_filter = request.query_params.get("status", "")
|
||||||
|
if status_filter not in AI_VARIANT_STATUSES:
|
||||||
|
status_filter = ""
|
||||||
|
level_filter = request.query_params.get("level", "")
|
||||||
|
if level_filter not in AI_VARIANT_LEVELS:
|
||||||
|
level_filter = ""
|
||||||
|
run_id_filter = request.query_params.get("run_id", "").strip()
|
||||||
|
run_id_filter_int = int(run_id_filter) if run_id_filter.isdigit() else None
|
||||||
generation_runs = await _recent_generation_runs(db)
|
generation_runs = await _recent_generation_runs(db)
|
||||||
selected_basis_item_id: int | None = None
|
selected_basis_item_id: int | None = None
|
||||||
if basis_item_id and str(basis_item_id).isdigit():
|
if basis_item_id and str(basis_item_id).isdigit():
|
||||||
@@ -2300,6 +2534,9 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
|
|||||||
generated_variants = await _recent_generated_variants(
|
generated_variants = await _recent_generated_variants(
|
||||||
db,
|
db,
|
||||||
basis_item_id=selected_basis_item_id,
|
basis_item_id=selected_basis_item_id,
|
||||||
|
status_filter=status_filter or None,
|
||||||
|
level_filter=level_filter or None,
|
||||||
|
run_id_filter=run_id_filter_int,
|
||||||
)
|
)
|
||||||
body = _ai_form_body(
|
body = _ai_form_body(
|
||||||
bool(settings.OPENROUTER_API_KEY),
|
bool(settings.OPENROUTER_API_KEY),
|
||||||
@@ -2308,6 +2545,10 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
|
|||||||
generation_runs=generation_runs,
|
generation_runs=generation_runs,
|
||||||
generated_variants=generated_variants,
|
generated_variants=generated_variants,
|
||||||
basis_item_id=str(basis_item_id or ""),
|
basis_item_id=str(basis_item_id or ""),
|
||||||
|
active_tab=active_tab,
|
||||||
|
variant_status_filter=status_filter,
|
||||||
|
variant_level_filter=level_filter,
|
||||||
|
variant_run_id_filter=run_id_filter if run_id_filter_int is not None else "",
|
||||||
)
|
)
|
||||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||||
|
|
||||||
@@ -2654,6 +2895,7 @@ async def ai_playground_review_bulk(
|
|||||||
basis_items=basis_items,
|
basis_items=basis_items,
|
||||||
generation_runs=generation_runs,
|
generation_runs=generation_runs,
|
||||||
generated_variants=generated_variants,
|
generated_variants=generated_variants,
|
||||||
|
active_tab="review",
|
||||||
)
|
)
|
||||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||||
|
|
||||||
@@ -2665,6 +2907,7 @@ async def ai_playground_review_bulk(
|
|||||||
basis_items=basis_items,
|
basis_items=basis_items,
|
||||||
generation_runs=generation_runs,
|
generation_runs=generation_runs,
|
||||||
generated_variants=generated_variants,
|
generated_variants=generated_variants,
|
||||||
|
active_tab="review",
|
||||||
)
|
)
|
||||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||||
|
|
||||||
@@ -2680,6 +2923,7 @@ async def ai_playground_review_bulk(
|
|||||||
basis_items=basis_items,
|
basis_items=basis_items,
|
||||||
generation_runs=generation_runs,
|
generation_runs=generation_runs,
|
||||||
generated_variants=generated_variants,
|
generated_variants=generated_variants,
|
||||||
|
active_tab="review",
|
||||||
)
|
)
|
||||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||||
|
|
||||||
@@ -2702,6 +2946,7 @@ async def ai_playground_review_bulk(
|
|||||||
basis_items=updated_basis_items,
|
basis_items=updated_basis_items,
|
||||||
generation_runs=updated_runs,
|
generation_runs=updated_runs,
|
||||||
generated_variants=updated_variants,
|
generated_variants=updated_variants,
|
||||||
|
active_tab="review",
|
||||||
)
|
)
|
||||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user