diff --git a/app/admin_web.py b/app/admin_web.py index dd8149f..59ffae6 100644 --- a/app/admin_web.py +++ b/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; }} .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; }} + .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; }} + }} @@ -586,10 +614,19 @@ async def _recent_generated_variants( db: AsyncSession, limit: int = 100, basis_item_id: int | None = None, + status_filter: str | None = None, + level_filter: str | None = None, + run_id_filter: int | None = None, ) -> list[Item]: stmt = select(Item).where(Item.generated_by == "ai") if basis_item_id is not None: 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( 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'{escape(label)}' + ) + return f'' + + +def _status_pill(status: str | None) -> str: + value = status or "unknown" + css_value = re.sub(r"[^a-z0-9_-]+", "-", value.lower()) + return f'{escape(value)}' + + +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""" +
+
OpenRouter{"Yes" if key_configured else "No"}
+
AI Items{stats.get("total_ai_items", 0)}
+
Latest Run{escape(latest_run)}
+
Saved{escape(latest_saved)}
+
+ """ + + +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""" +
+
Run ID{generation_summary.get("run_id", "-")}
+
Requested{generation_summary.get("requested_count", 0)}
+
Generated{generation_summary.get("generated_count", 0)}
+
Saved{len(saved_item_ids)}
+
+ """ + + +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 = """ +
No sedang basis items found yet.
+
+ +
+ """ + + return f""" +
+ {seed_callout} + {_ai_generation_summary(generation_summary)} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Find Basis Item +
+
+
+ """ + + +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( + "" + f"{item.id}" + f"{escape(str(item.tryout_id))}" + f"{item.slot}" + f"{item.website_id}" + f"{escape(stem_preview)}" + f"Use" + "" + ) + + table = ( + "
" + + ("".join(rows) if rows else "") + + "
Item IDTryoutSlotWebsiteStemAction
No sedang basis items found.
" + ) + return f""" +
+
+
+ +
+
+ {table} +
+ """ + + +def _ai_runs_tab( + generation_runs: list[AIGenerationRun], + generation_summary: dict[str, Any] | None, +) -> str: + rows = [] + for run in generation_runs: + rows.append( + "" + f"{run.id}" + f"{run.basis_item_id}" + f"{escape(run.target_level)}" + f"{run.requested_count}" + f"{escape(_truncate(run.model, 54))}" + f"{escape(run.created_by)}" + f"{escape(str(run.created_at))}" + f"Review" + "" + ) + table = ( + "
" + + ("".join(rows) if rows else "") + + "
Run IDBasis ItemTargetRequestedModelCreated ByCreated AtAction
No generation runs yet.
" + ) + return f""" +
+ {_ai_generation_summary(generation_summary)} + {table} +
+ """ + + +def _ai_review_tab( + generated_variants: list[Item], + status_filter: str, + level_filter: str, + run_id_filter: str, +) -> str: + status_options = [''] + for status in AI_VARIANT_STATUSES: + status_options.append( + f'' + ) + level_options = [''] + for level in AI_VARIANT_LEVELS: + level_options.append( + f'' + ) + + variant_rows = [] + for item in generated_variants: + stem_preview = _truncate(_html_to_text(item.stem), 120) + variant_rows.append( + "" + f"" + f"{item.id}" + f"{item.generation_run_id or '-'}" + f"{item.basis_item_id or '-'}" + f"{escape(item.level)}" + f"{_status_pill(item.variant_status)}" + f"{escape(_truncate(item.ai_model or '-', 42))}" + f"{escape(stem_preview)}" + f"{escape(str(item.created_at))}" + "" + ) + variant_table_rows = ( + "".join(variant_rows) + if variant_rows + else 'No AI-generated variants match this view.' + ) + + return f""" +
+
+ + + + + + Clear +
+
+
+ + +
+
+ + + + + + {variant_table_rows} + +
Item IDRun IDBasisLevelStatusModelStemCreated At
+
+
+
+ """ + + def _ai_form_body( key_configured: bool, stats: dict[str, Any], @@ -2152,135 +2463,47 @@ def _ai_form_body( operator_notes: str = "", include_note_for_admin: bool = True, include_note_in_prompt: bool = False, + active_tab: str = "generate", + variant_status_filter: str = "", + variant_level_filter: str = "", + variant_run_id_filter: str = "", ) -> str: error_html = f'
{escape(error)}
' if error else "" success_html = f'
{escape(success)}
' if success else "" basis_items = basis_items or [] generation_runs = generation_runs or [] generated_variants = generated_variants or [] - basis_rows = [ - [ - 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 = """ -
- No sedang basis items found yet. Seed one demo website, tryout, and basis item to test AI generation immediately. -
-
- -
- """ - summary_html = "" - if generation_summary: - saved_item_ids = generation_summary.get("saved_item_ids") or [] - summary_html = f""" -

Latest Generation Run

-
-
Run ID{generation_summary.get("run_id", "-")}
-
Requested{generation_summary.get("requested_count", 0)}
-
Generated{generation_summary.get("generated_count", 0)}
-
Saved{len(saved_item_ids)}
-
-

Each saved output starts as draft. Review per item below to approve, reject, archive, stale, or activate.

- """ + if active_tab not in {tab for tab, _ in AI_PLAYGROUND_TABS}: + active_tab = "generate" - run_rows = [ - [ - run.id, - run.basis_item_id, - run.target_level, - run.requested_count, - run.model, - run.created_by, - str(run.created_at), - ] - for run in generation_runs - ] - runs_table = _table( - ["Run ID", "Basis Item", "Target", "Requested", "Model", "Created By", "Created At"], - run_rows, - ) - - variant_rows = [] - for item in generated_variants: - variant_rows.append( - "" - f"" - f"{item.id}" - f"{item.generation_run_id or '-'}" - f"{item.basis_item_id or '-'}" - f"{escape(item.level)}" - f"{escape(item.variant_status)}" - f"{escape(item.ai_model or '-')}" - f"{escape(_truncate(item.stem, 100))}" - f"{escape(str(item.created_at))}" - "" - ) - variants_table = ( - "
" - "
" - "" - "" - "
" - "" - + ("".join(variant_rows) if variant_rows else "") - + "
el.checked = this.checked)\">Item IDRun IDBasisLevelStatusModelStemCreated At
No AI-generated variants yet.
" - ) + tab_html = { + "generate": _ai_generate_tab( + basis_items, + generation_summary, + basis_item_id, + target_level, + ai_model, + generation_count, + operator_notes, + include_note_for_admin, + include_note_in_prompt, + ), + "review": _ai_review_tab( + generated_variants, + variant_status_filter, + variant_level_filter, + variant_run_id_filter, + ), + "runs": _ai_runs_tab(generation_runs, generation_summary), + "basis": _ai_basis_tab(basis_items), + }[active_tab] return f""" -

OpenRouter key configured: {"Yes" if key_configured else "No"}

-

Total AI-generated items: {stats.get("total_ai_items", 0)}

-

Hybrid workflow: one run can generate one or many variants; each item remains independently reviewable.

+ {_ai_status_strip(key_configured, stats, generation_runs, generation_summary)} {success_html} {error_html} - {seed_callout} -
- - - - - - - - -

Recommended: 1-3 variants per run. Larger runs can increase overlap and review burden. Backend safety cap: 50.

- - - - -

Example note: Use simple wording, one-step arithmetic, avoid trick options.

- -
-

Available Sedang Basis Items

-

The generator needs a sedang item. Use one of these IDs, or seed demo data if the table is empty.

- {basis_table} - {summary_html} -

Recent Generation Runs

- {runs_table} -

Generated Variants (Review Queue)

-

Use bulk review actions to move items from draft to approved/active, or reject/archive/stale.

- {variants_table} + {_ai_tab_nav(active_tab)} + {tab_html} """ @@ -2293,6 +2516,17 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db stats = await get_ai_stats(db) basis_items = await _basis_items_for_playground(db) 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) selected_basis_item_id: int | None = None 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( db, 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( 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, generated_variants=generated_variants, 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) @@ -2654,6 +2895,7 @@ async def ai_playground_review_bulk( basis_items=basis_items, generation_runs=generation_runs, generated_variants=generated_variants, + active_tab="review", ) return _render_admin_page(request, "AI Playground", "AI Playground", body) @@ -2665,6 +2907,7 @@ async def ai_playground_review_bulk( basis_items=basis_items, generation_runs=generation_runs, generated_variants=generated_variants, + active_tab="review", ) return _render_admin_page(request, "AI Playground", "AI Playground", body) @@ -2680,6 +2923,7 @@ async def ai_playground_review_bulk( basis_items=basis_items, generation_runs=generation_runs, generated_variants=generated_variants, + active_tab="review", ) 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, generation_runs=updated_runs, generated_variants=updated_variants, + active_tab="review", ) return _render_admin_page(request, "AI Playground", "AI Playground", body)