Add domain CRUD, energy density constraint, LLM status, reset results, score display fixes
Domain management: - Add domain list/detail/form templates and full CRUD routes (domains.py) - Add metric bound add/edit/delete via HTMX partials (_metrics_table.html) Energy density constraint (Rule 6 in ConstraintResolver): - Hard-block combos where power source provides <25% of platform's required Wh/kg - Warn (conditional) when under-density but within 4x - Solar Sail exempt (no stored energy); Airplane requires 400 Wh/kg, Spaceship 2000 Wh/kg - Add energy_density_wh_kg provides to all 8 stored-energy power sources in seed data - 3 new constraint resolver tests LLM-complete status: - Pipeline Pass 4 now sets combo status to llm_reviewed after successful LLM review - update_combination_status guards against downgrading: scored won't overwrite llm_reviewed or reviewed; llm_reviewed won't overwrite reviewed - Add badge-llm_reviewed CSS style (light blue) Reset results: - Repository.reset_domain_results() deletes combination_results, combination_scores, and pipeline_runs for a domain; pipeline re-evaluates on next run - POST /results/<domain>/reset route with flash confirmation - "Reset results" danger button with JS confirm dialog in results list Fix composite score 0 displaying as --- (Jinja2 falsy 0.0 bug): - Change `if r.composite_score` to `if r.composite_score is not none` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g
|
||||
@@ -12,6 +13,29 @@ from physcom.db.repository import Repository
|
||||
|
||||
|
||||
DEFAULT_DB = Path("data/physcom.db")
|
||||
_ENV_FILE = Path(".env")
|
||||
|
||||
|
||||
def _load_or_generate_secret_key() -> str:
|
||||
"""Return FLASK_SECRET_KEY from env, .env file, or auto-generate and persist it."""
|
||||
key = os.environ.get("FLASK_SECRET_KEY", "").strip()
|
||||
if key:
|
||||
return key
|
||||
|
||||
# Try to load from .env
|
||||
if _ENV_FILE.exists():
|
||||
for line in _ENV_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("FLASK_SECRET_KEY="):
|
||||
key = line[len("FLASK_SECRET_KEY="):].strip()
|
||||
if key:
|
||||
return key
|
||||
|
||||
# Generate, persist, and return
|
||||
key = secrets.token_hex(32)
|
||||
with _ENV_FILE.open("a") as f:
|
||||
f.write(f"FLASK_SECRET_KEY={key}\n")
|
||||
return key
|
||||
|
||||
|
||||
def get_repo() -> Repository:
|
||||
@@ -31,7 +55,7 @@ def close_db(exc: BaseException | None = None) -> None:
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "physcom-dev-key")
|
||||
app.secret_key = _load_or_generate_secret_key()
|
||||
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
@@ -57,4 +81,4 @@ def create_app() -> Flask:
|
||||
def run() -> None:
|
||||
"""Entry point for `physcom-web` script."""
|
||||
app = create_app()
|
||||
app.run(debug=True, port=int(os.environ.get("PORT", "5000")))
|
||||
app.run(host="0.0.0.0", debug=True, port=int(os.environ.get("PORT", "5000")))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Domain listing routes."""
|
||||
"""Domain CRUD routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from physcom.models.domain import Domain, MetricBound
|
||||
from physcom_web.app import get_repo
|
||||
|
||||
bp = Blueprint("domains", __name__, url_prefix="/domains")
|
||||
@@ -14,3 +15,100 @@ def domain_list():
|
||||
repo = get_repo()
|
||||
domains = repo.list_domains()
|
||||
return render_template("domains/list.html", domains=domains)
|
||||
|
||||
|
||||
@bp.route("/new", methods=["GET", "POST"])
|
||||
def domain_new():
|
||||
repo = get_repo()
|
||||
if request.method == "POST":
|
||||
name = request.form["name"].strip()
|
||||
description = request.form.get("description", "").strip()
|
||||
if not name:
|
||||
flash("Name is required.", "error")
|
||||
return render_template("domains/form.html")
|
||||
domain = repo.add_domain(Domain(name=name, description=description))
|
||||
flash(f"Domain '{name}' created.", "success")
|
||||
return redirect(url_for("domains.domain_detail", domain_id=domain.id))
|
||||
return render_template("domains/form.html")
|
||||
|
||||
|
||||
@bp.route("/<int:domain_id>", methods=["GET", "POST"])
|
||||
def domain_detail(domain_id: int):
|
||||
repo = get_repo()
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
if not domain:
|
||||
flash("Domain not found.", "error")
|
||||
return redirect(url_for("domains.domain_list"))
|
||||
if request.method == "POST":
|
||||
name = request.form["name"].strip()
|
||||
description = request.form.get("description", "").strip()
|
||||
if not name:
|
||||
flash("Name is required.", "error")
|
||||
else:
|
||||
repo.update_domain(domain_id, name, description)
|
||||
flash(f"Domain '{name}' updated.", "success")
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
return render_template("domains/detail.html", domain=domain)
|
||||
|
||||
|
||||
@bp.route("/<int:domain_id>/delete", methods=["POST"])
|
||||
def domain_delete(domain_id: int):
|
||||
repo = get_repo()
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
if domain:
|
||||
repo.delete_domain(domain_id)
|
||||
flash(f"Domain '{domain.name}' deleted.", "success")
|
||||
return redirect(url_for("domains.domain_list"))
|
||||
|
||||
|
||||
# ── Metric bound CRUD (HTMX partials) ────────────────────────
|
||||
|
||||
|
||||
@bp.route("/<int:domain_id>/metrics/add", methods=["POST"])
|
||||
def metric_add(domain_id: int):
|
||||
repo = get_repo()
|
||||
metric_name = request.form["metric_name"].strip()
|
||||
unit = request.form.get("unit", "").strip()
|
||||
try:
|
||||
weight = float(request.form.get("weight", "1.0"))
|
||||
norm_min = float(request.form.get("norm_min", "0.0"))
|
||||
norm_max = float(request.form.get("norm_max", "1.0"))
|
||||
except ValueError:
|
||||
flash("Weight, norm_min, and norm_max must be numbers.", "error")
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
return render_template("domains/_metrics_table.html", domain=domain)
|
||||
if not metric_name:
|
||||
flash("Metric name is required.", "error")
|
||||
else:
|
||||
mb = MetricBound(metric_name=metric_name, weight=weight, norm_min=norm_min, norm_max=norm_max, unit=unit)
|
||||
repo.add_metric_bound(domain_id, mb)
|
||||
flash("Metric added.", "success")
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
return render_template("domains/_metrics_table.html", domain=domain)
|
||||
|
||||
|
||||
@bp.route("/<int:domain_id>/metrics/<int:metric_id>/edit", methods=["POST"])
|
||||
def metric_edit(domain_id: int, metric_id: int):
|
||||
repo = get_repo()
|
||||
try:
|
||||
weight = float(request.form.get("weight", "1.0"))
|
||||
norm_min = float(request.form.get("norm_min", "0.0"))
|
||||
norm_max = float(request.form.get("norm_max", "1.0"))
|
||||
except ValueError:
|
||||
flash("Weight, norm_min, and norm_max must be numbers.", "error")
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
return render_template("domains/_metrics_table.html", domain=domain)
|
||||
unit = request.form.get("unit", "").strip()
|
||||
repo.update_metric_bound(domain_id, metric_id, weight, norm_min, norm_max, unit)
|
||||
flash("Metric updated.", "success")
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
return render_template("domains/_metrics_table.html", domain=domain)
|
||||
|
||||
|
||||
@bp.route("/<int:domain_id>/metrics/<int:metric_id>/delete", methods=["POST"])
|
||||
def metric_delete(domain_id: int, metric_id: int):
|
||||
repo = get_repo()
|
||||
repo.delete_metric_bound(domain_id, metric_id)
|
||||
flash("Metric removed.", "success")
|
||||
domain = repo.get_domain_by_id(domain_id)
|
||||
return render_template("domains/_metrics_table.html", domain=domain)
|
||||
|
||||
@@ -151,7 +151,7 @@ def run_cancel(run_id: int):
|
||||
"""Set a running pipeline to cancelled. The pipeline checks this flag."""
|
||||
repo = get_repo()
|
||||
run = repo.get_pipeline_run(run_id)
|
||||
if run and run["status"] == "running":
|
||||
if run and run["status"] in ("running", "rate_limited"):
|
||||
repo.update_pipeline_run(run_id, status="cancelled")
|
||||
flash(f"Run #{run_id} cancellation requested.", "info")
|
||||
return redirect(url_for("pipeline.pipeline_form"))
|
||||
|
||||
@@ -65,6 +65,18 @@ def result_detail(domain_name: str, combo_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<domain_name>/reset", methods=["POST"])
|
||||
def reset_results(domain_name: str):
|
||||
repo = get_repo()
|
||||
domain = repo.get_domain(domain_name)
|
||||
if not domain:
|
||||
flash(f"Domain '{domain_name}' not found.", "error")
|
||||
return redirect(url_for("results.results_index"))
|
||||
count = repo.reset_domain_results(domain_name)
|
||||
flash(f"Reset {count} result rows for '{domain_name}'. Ready to re-run.", "success")
|
||||
return redirect(url_for("results.results_domain", domain_name=domain_name))
|
||||
|
||||
|
||||
@bp.route("/<domain_name>/<int:combo_id>/review", methods=["POST"])
|
||||
def submit_review(domain_name: str, combo_id: int):
|
||||
repo = get_repo()
|
||||
@@ -84,6 +96,7 @@ def submit_review(domain_name: str, combo_id: int):
|
||||
combo_id, domain.id, composite_score,
|
||||
pass_reached=5,
|
||||
novelty_flag=novelty_flag,
|
||||
llm_review=existing.get("llm_review") if existing else None,
|
||||
human_notes=human_notes,
|
||||
)
|
||||
repo.update_combination_status(combo_id, "reviewed")
|
||||
|
||||
@@ -81,6 +81,7 @@ table.compact th, table.compact td { padding: 0.25rem 0.4rem; font-size: 0.85rem
|
||||
.badge-valid { background: #dcfce7; color: #166534; }
|
||||
.badge-blocked { background: #fee2e2; color: #991b1b; }
|
||||
.badge-scored { background: #dbeafe; color: #1e40af; }
|
||||
.badge-llm_reviewed { background: #e0f2fe; color: #0369a1; }
|
||||
.badge-reviewed { background: #f3e8ff; color: #6b21a8; }
|
||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||
|
||||
@@ -171,6 +172,7 @@ dd { font-size: 0.9rem; }
|
||||
.badge-completed { background: #dcfce7; color: #166534; }
|
||||
.badge-failed { background: #fee2e2; color: #991b1b; }
|
||||
.badge-cancelled { background: #fef3c7; color: #92400e; }
|
||||
.badge-rate_limited { background: #ffedd5; color: #9a3412; }
|
||||
|
||||
.run-status { padding: 0.25rem 0; }
|
||||
.run-status-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
|
||||
68
src/physcom_web/templates/domains/_metrics_table.html
Normal file
68
src/physcom_web/templates/domains/_metrics_table.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<table id="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Unit</th>
|
||||
<th>Weight</th>
|
||||
<th>Norm Min</th>
|
||||
<th>Norm Max</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mb in domain.metric_bounds %}
|
||||
<tr>
|
||||
<td>{{ mb.metric_name }}</td>
|
||||
<td>{{ mb.unit or '—' }}</td>
|
||||
<td>{{ mb.weight }}</td>
|
||||
<td>{{ mb.norm_min }}</td>
|
||||
<td>{{ mb.norm_max }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm"
|
||||
onclick="this.closest('tr').nextElementSibling.style.display='table-row'; this.closest('tr').style.display='none'">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('domains.metric_delete', domain_id=domain.id, metric_id=mb.metric_id) }}"
|
||||
hx-target="#metrics-section" hx-swap="innerHTML"
|
||||
class="inline-form">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Del</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="edit-row" style="display:none">
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('domains.metric_edit', domain_id=domain.id, metric_id=mb.metric_id) }}"
|
||||
hx-target="#metrics-section" hx-swap="innerHTML">
|
||||
<td>{{ mb.metric_name }}</td>
|
||||
<td><input name="unit" value="{{ mb.unit or '' }}"></td>
|
||||
<td><input name="weight" type="number" step="any" value="{{ mb.weight }}" required></td>
|
||||
<td><input name="norm_min" type="number" step="any" value="{{ mb.norm_min }}" required></td>
|
||||
<td><input name="norm_max" type="number" step="any" value="{{ mb.norm_max }}" required></td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
onclick="this.closest('tr').style.display='none'; this.closest('tr').previousElementSibling.style.display=''">
|
||||
Cancel
|
||||
</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Add Metric</h3>
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('domains.metric_add', domain_id=domain.id) }}"
|
||||
hx-target="#metrics-section" hx-swap="innerHTML"
|
||||
class="dep-add-form">
|
||||
<div class="form-row">
|
||||
<input name="metric_name" placeholder="metric name" required>
|
||||
<input name="unit" placeholder="unit">
|
||||
<input name="weight" type="number" step="any" placeholder="weight" value="1.0" required>
|
||||
<input name="norm_min" type="number" step="any" placeholder="norm min" value="0.0" required>
|
||||
<input name="norm_max" type="number" step="any" placeholder="norm max" value="1.0" required>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
36
src/physcom_web/templates/domains/detail.html
Normal file
36
src/physcom_web/templates/domains/detail.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ domain.name }} — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ domain.name }}</h1>
|
||||
<form method="post" action="{{ url_for('domains.domain_delete', domain_id=domain.id) }}" class="inline-form"
|
||||
onsubmit="return confirm('Delete this domain and all its pipeline runs and results?')">
|
||||
<button type="submit" class="btn btn-danger">Delete Domain</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{{ url_for('domains.domain_detail', domain_id=domain.id) }}">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ domain.name }}" required>
|
||||
</div>
|
||||
<div class="form-group" style="flex:2">
|
||||
<label for="description">Description</label>
|
||||
<input type="text" id="description" name="description" value="{{ domain.description }}">
|
||||
</div>
|
||||
<div class="form-group" style="align-self:end">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Metrics</h2>
|
||||
|
||||
<div id="metrics-section">
|
||||
{% include "domains/_metrics_table.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
src/physcom_web/templates/domains/form.html
Normal file
24
src/physcom_web/templates/domains/form.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New Domain — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>New Domain</h1>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name"
|
||||
value="{{ request.form.get('name', '') }}" required placeholder="e.g. Urban Transit">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3">{{ request.form.get('description', '') }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
<a href="{{ url_for('domains.domain_list') }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,14 +2,17 @@
|
||||
{% block title %}Domains — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Domains</h1>
|
||||
<div class="page-header">
|
||||
<h1>Domains</h1>
|
||||
<a href="{{ url_for('domains.domain_new') }}" class="btn btn-primary">New Domain</a>
|
||||
</div>
|
||||
|
||||
{% if not domains %}
|
||||
<p class="empty">No domains found. Seed data via CLI first.</p>
|
||||
<p class="empty">No domains found. <a href="{{ url_for('domains.domain_new') }}">Create one</a> or seed data via CLI.</p>
|
||||
{% else %}
|
||||
{% for d in domains %}
|
||||
<div class="card">
|
||||
<h2>{{ d.name }}</h2>
|
||||
<h2><a href="{{ url_for('domains.domain_detail', domain_id=d.id) }}">{{ d.name }}</a></h2>
|
||||
<p>{{ d.description }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# HTMX partial: live status for a single pipeline run #}
|
||||
<div class="run-status run-status-{{ run.status }}"
|
||||
{% if run.status == 'running' or run.status == 'pending' %}
|
||||
{% if run.status in ('running', 'pending', 'rate_limited') %}
|
||||
hx-get="{{ url_for('pipeline.run_status', run_id=run.id) }}"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="outerHTML"
|
||||
@@ -65,8 +65,14 @@
|
||||
<div class="flash flash-error" style="margin-top:0.5rem">{{ run.error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.status == 'rate_limited' %}
|
||||
<div class="flash flash-info" style="margin-top:0.5rem">
|
||||
Rate limited — waiting for quota to refresh, then resuming automatically.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="run-status-actions">
|
||||
{% if run.status == 'running' %}
|
||||
{% if run.status in ('running', 'rate_limited') %}
|
||||
<form method="post" action="{{ url_for('pipeline.run_cancel', run_id=run.id) }}" class="inline-form">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Cancel</button>
|
||||
</form>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% set active_runs = runs | selectattr('status', 'in', ['pending', 'running']) | list %}
|
||||
{% set active_runs = runs | selectattr('status', 'in', ['pending', 'running', 'rate_limited']) | list %}
|
||||
{% if active_runs %}
|
||||
<h2>Active Runs</h2>
|
||||
{% for run in active_runs %}
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
|
||||
{% if domain and results is not none %}
|
||||
<div class="card">
|
||||
<h2>{{ domain.name }} <span class="subtitle">{{ domain.description }}</span></h2>
|
||||
<div class="page-header">
|
||||
<h2>{{ domain.name }} <span class="subtitle">{{ domain.description }}</span></h2>
|
||||
<form method="post" action="{{ url_for('results.reset_results', domain_name=domain.name) }}"
|
||||
class="inline-form"
|
||||
onsubmit="return confirm('Delete all results for {{ domain.name }}? This cannot be undone.')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Reset results</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if statuses %}
|
||||
<div class="filter-row">
|
||||
@@ -53,7 +60,7 @@
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="score-cell">{{ "%.4f"|format(r.composite_score) if r.composite_score else '—' }}</td>
|
||||
<td class="score-cell">{{ "%.4f"|format(r.composite_score) if r.composite_score is not none else '—' }}</td>
|
||||
<td>{{ r.combination.entities|map(attribute='name')|join(' + ') }}</td>
|
||||
<td><span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span></td>
|
||||
<td class="block-reason-cell">
|
||||
|
||||
Reference in New Issue
Block a user