Add Flask web UI, Docker Compose, core engine + tests
- physcom core: CLI, 5-pass pipeline, SQLite repo, 37 tests - physcom_web: Flask app with HTMX for entity/domain/pipeline/results CRUD - Docker Compose: web + cli services sharing a named volume for the DB - Clean up local settings to use wildcard permissions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
src/physcom_web/__init__.py
Normal file
1
src/physcom_web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""PhysCom Web — Flask UI for Physical Combinatorics."""
|
||||
4
src/physcom_web/__main__.py
Normal file
4
src/physcom_web/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Allow `python -m physcom_web` to start the dev server."""
|
||||
from physcom_web.app import run
|
||||
|
||||
run()
|
||||
60
src/physcom_web/app.py
Normal file
60
src/physcom_web/app.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Flask application factory and DB setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g
|
||||
|
||||
from physcom.db.schema import init_db
|
||||
from physcom.db.repository import Repository
|
||||
|
||||
|
||||
DEFAULT_DB = Path("data/physcom.db")
|
||||
|
||||
|
||||
def get_repo() -> Repository:
|
||||
"""Return a Repository scoped to the current request."""
|
||||
if "repo" not in g:
|
||||
db_path = Path(os.environ.get("PHYSCOM_DB", str(DEFAULT_DB)))
|
||||
conn = init_db(db_path)
|
||||
g.repo = Repository(conn)
|
||||
return g.repo
|
||||
|
||||
|
||||
def close_db(exc: BaseException | None = None) -> None:
|
||||
repo: Repository | None = g.pop("repo", None)
|
||||
if repo is not None:
|
||||
repo.conn.close()
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "physcom-dev-key")
|
||||
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
# Register blueprints
|
||||
from physcom_web.routes.entities import bp as entities_bp
|
||||
from physcom_web.routes.domains import bp as domains_bp
|
||||
from physcom_web.routes.pipeline import bp as pipeline_bp
|
||||
from physcom_web.routes.results import bp as results_bp
|
||||
|
||||
app.register_blueprint(entities_bp)
|
||||
app.register_blueprint(domains_bp)
|
||||
app.register_blueprint(pipeline_bp)
|
||||
app.register_blueprint(results_bp)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
from flask import redirect, url_for
|
||||
return redirect(url_for("entities.entity_list"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Entry point for `physcom-web` script."""
|
||||
app = create_app()
|
||||
app.run(debug=True, port=int(os.environ.get("PORT", "5000")))
|
||||
0
src/physcom_web/routes/__init__.py
Normal file
0
src/physcom_web/routes/__init__.py
Normal file
16
src/physcom_web/routes/domains.py
Normal file
16
src/physcom_web/routes/domains.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Domain listing routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
from physcom_web.app import get_repo
|
||||
|
||||
bp = Blueprint("domains", __name__, url_prefix="/domains")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def domain_list():
|
||||
repo = get_repo()
|
||||
domains = repo.list_domains()
|
||||
return render_template("domains/list.html", domains=domains)
|
||||
128
src/physcom_web/routes/entities.py
Normal file
128
src/physcom_web/routes/entities.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Entity + dependency CRUD routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from physcom.models.entity import Dependency, Entity
|
||||
from physcom_web.app import get_repo
|
||||
|
||||
bp = Blueprint("entities", __name__, url_prefix="/entities")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def entity_list():
|
||||
repo = get_repo()
|
||||
entities = repo.list_entities()
|
||||
# Group by dimension
|
||||
grouped: dict[str, list[Entity]] = {}
|
||||
for e in entities:
|
||||
grouped.setdefault(e.dimension, []).append(e)
|
||||
return render_template("entities/list.html", grouped=grouped)
|
||||
|
||||
|
||||
@bp.route("/new", methods=["GET", "POST"])
|
||||
def entity_new():
|
||||
repo = get_repo()
|
||||
if request.method == "POST":
|
||||
dimension = request.form["dimension"].strip()
|
||||
name = request.form["name"].strip()
|
||||
description = request.form.get("description", "").strip()
|
||||
if not dimension or not name:
|
||||
flash("Dimension and name are required.", "error")
|
||||
return render_template("entities/form.html", entity=None,
|
||||
dimensions=repo.list_dimensions())
|
||||
entity = Entity(name=name, dimension=dimension, description=description)
|
||||
repo.add_entity(entity)
|
||||
flash(f"Entity '{name}' added to '{dimension}'.", "success")
|
||||
return redirect(url_for("entities.entity_detail", entity_id=entity.id))
|
||||
return render_template("entities/form.html", entity=None,
|
||||
dimensions=repo.list_dimensions())
|
||||
|
||||
|
||||
@bp.route("/<int:entity_id>")
|
||||
def entity_detail(entity_id: int):
|
||||
repo = get_repo()
|
||||
entity = repo.get_entity(entity_id)
|
||||
if not entity:
|
||||
flash("Entity not found.", "error")
|
||||
return redirect(url_for("entities.entity_list"))
|
||||
return render_template("entities/detail.html", entity=entity)
|
||||
|
||||
|
||||
@bp.route("/<int:entity_id>/edit", methods=["GET", "POST"])
|
||||
def entity_edit(entity_id: int):
|
||||
repo = get_repo()
|
||||
entity = repo.get_entity(entity_id)
|
||||
if not entity:
|
||||
flash("Entity not found.", "error")
|
||||
return redirect(url_for("entities.entity_list"))
|
||||
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("entities/form.html", entity=entity,
|
||||
dimensions=repo.list_dimensions())
|
||||
repo.update_entity(entity_id, name, description)
|
||||
flash(f"Entity '{name}' updated.", "success")
|
||||
return redirect(url_for("entities.entity_detail", entity_id=entity_id))
|
||||
return render_template("entities/form.html", entity=entity,
|
||||
dimensions=repo.list_dimensions())
|
||||
|
||||
|
||||
@bp.route("/<int:entity_id>/delete", methods=["POST"])
|
||||
def entity_delete(entity_id: int):
|
||||
repo = get_repo()
|
||||
entity = repo.get_entity(entity_id)
|
||||
if entity:
|
||||
repo.delete_entity(entity_id)
|
||||
flash(f"Entity '{entity.name}' deleted.", "success")
|
||||
return redirect(url_for("entities.entity_list"))
|
||||
|
||||
|
||||
# ── Dependency CRUD (HTMX partials) ─────────────────────────
|
||||
|
||||
|
||||
@bp.route("/<int:entity_id>/deps/add", methods=["POST"])
|
||||
def dep_add(entity_id: int):
|
||||
repo = get_repo()
|
||||
dep = Dependency(
|
||||
category=request.form["category"].strip(),
|
||||
key=request.form["key"].strip(),
|
||||
value=request.form["value"].strip(),
|
||||
unit=request.form.get("unit", "").strip() or None,
|
||||
constraint_type=request.form.get("constraint_type", "requires").strip(),
|
||||
)
|
||||
if not dep.category or not dep.key or not dep.value:
|
||||
flash("Category, key, and value are required.", "error")
|
||||
else:
|
||||
repo.add_dependency(entity_id, dep)
|
||||
flash("Dependency added.", "success")
|
||||
entity = repo.get_entity(entity_id)
|
||||
return render_template("entities/_dep_table.html", entity=entity)
|
||||
|
||||
|
||||
@bp.route("/<int:entity_id>/deps/<int:dep_id>/edit", methods=["POST"])
|
||||
def dep_edit(entity_id: int, dep_id: int):
|
||||
repo = get_repo()
|
||||
dep = Dependency(
|
||||
category=request.form["category"].strip(),
|
||||
key=request.form["key"].strip(),
|
||||
value=request.form["value"].strip(),
|
||||
unit=request.form.get("unit", "").strip() or None,
|
||||
constraint_type=request.form.get("constraint_type", "requires").strip(),
|
||||
)
|
||||
repo.update_dependency(dep_id, dep)
|
||||
flash("Dependency updated.", "success")
|
||||
entity = repo.get_entity(entity_id)
|
||||
return render_template("entities/_dep_table.html", entity=entity)
|
||||
|
||||
|
||||
@bp.route("/<int:entity_id>/deps/<int:dep_id>/delete", methods=["POST"])
|
||||
def dep_delete(entity_id: int, dep_id: int):
|
||||
repo = get_repo()
|
||||
repo.delete_dependency(dep_id)
|
||||
flash("Dependency deleted.", "success")
|
||||
entity = repo.get_entity(entity_id)
|
||||
return render_template("entities/_dep_table.html", entity=entity)
|
||||
56
src/physcom_web/routes/pipeline.py
Normal file
56
src/physcom_web/routes/pipeline.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Pipeline run routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from physcom_web.app import get_repo
|
||||
|
||||
bp = Blueprint("pipeline", __name__, url_prefix="/pipeline")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def pipeline_form():
|
||||
repo = get_repo()
|
||||
domains = repo.list_domains()
|
||||
dimensions = repo.list_dimensions()
|
||||
return render_template("pipeline/run.html", domains=domains, dimensions=dimensions)
|
||||
|
||||
|
||||
@bp.route("/run", methods=["POST"])
|
||||
def pipeline_run():
|
||||
repo = get_repo()
|
||||
|
||||
domain_name = request.form["domain"]
|
||||
domain = repo.get_domain(domain_name)
|
||||
if not domain:
|
||||
flash(f"Domain '{domain_name}' not found.", "error")
|
||||
return redirect(url_for("pipeline.pipeline_form"))
|
||||
|
||||
passes = [int(p) for p in request.form.getlist("passes")]
|
||||
if not passes:
|
||||
passes = [1, 2, 3]
|
||||
|
||||
threshold = float(request.form.get("threshold", 0.1))
|
||||
dim_list = request.form.getlist("dimensions")
|
||||
if not dim_list:
|
||||
flash("Select at least one dimension.", "error")
|
||||
return redirect(url_for("pipeline.pipeline_form"))
|
||||
|
||||
from physcom.engine.constraint_resolver import ConstraintResolver
|
||||
from physcom.engine.scorer import Scorer
|
||||
from physcom.engine.pipeline import Pipeline
|
||||
|
||||
resolver = ConstraintResolver()
|
||||
scorer = Scorer(domain)
|
||||
pipeline = Pipeline(repo, resolver, scorer, llm=None)
|
||||
|
||||
result = pipeline.run(domain, dim_list, score_threshold=threshold, passes=passes)
|
||||
|
||||
flash(
|
||||
f"Pipeline complete: {result.total_generated} combos generated, "
|
||||
f"{result.pass1_valid} valid, {result.pass1_blocked} blocked, "
|
||||
f"{result.pass3_above_threshold} above threshold.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("results.results_domain", domain_name=domain_name))
|
||||
95
src/physcom_web/routes/results.py
Normal file
95
src/physcom_web/routes/results.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Results browse + review routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from physcom_web.app import get_repo
|
||||
|
||||
bp = Blueprint("results", __name__, url_prefix="/results")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def results_index():
|
||||
repo = get_repo()
|
||||
domains = repo.list_domains()
|
||||
return render_template("results/list.html", domains=domains, results=None, domain=None)
|
||||
|
||||
|
||||
@bp.route("/<domain_name>")
|
||||
def results_domain(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"))
|
||||
|
||||
status_filter = request.args.get("status")
|
||||
results = repo.get_all_results(domain_name, status=status_filter)
|
||||
statuses = repo.count_combinations_by_status()
|
||||
|
||||
return render_template(
|
||||
"results/list.html",
|
||||
domains=repo.list_domains(),
|
||||
domain=domain,
|
||||
results=results,
|
||||
status_filter=status_filter,
|
||||
statuses=statuses,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<domain_name>/<int:combo_id>")
|
||||
def result_detail(domain_name: str, combo_id: int):
|
||||
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"))
|
||||
|
||||
combo = repo.get_combination(combo_id)
|
||||
if not combo:
|
||||
flash("Combination not found.", "error")
|
||||
return redirect(url_for("results.results_domain", domain_name=domain_name))
|
||||
|
||||
result = repo.get_result(combo_id, domain.id)
|
||||
scores = repo.get_combination_scores(combo_id, domain.id)
|
||||
|
||||
return render_template(
|
||||
"results/detail.html",
|
||||
domain=domain,
|
||||
combo=combo,
|
||||
result=result,
|
||||
scores=scores,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<domain_name>/<int:combo_id>/review", methods=["POST"])
|
||||
def submit_review(domain_name: str, combo_id: int):
|
||||
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"))
|
||||
|
||||
novelty_flag = request.form.get("novelty_flag", "").strip() or None
|
||||
human_notes = request.form.get("human_notes", "").strip() or None
|
||||
|
||||
# Get existing result to preserve composite_score
|
||||
existing = repo.get_result(combo_id, domain.id)
|
||||
composite_score = existing["composite_score"] if existing else 0.0
|
||||
|
||||
repo.save_result(
|
||||
combo_id, domain.id, composite_score,
|
||||
pass_reached=5,
|
||||
novelty_flag=novelty_flag,
|
||||
human_notes=human_notes,
|
||||
)
|
||||
repo.update_combination_status(combo_id, "reviewed")
|
||||
|
||||
# Return HTMX partial or redirect
|
||||
if request.headers.get("HX-Request"):
|
||||
return render_template("results/_review_done.html",
|
||||
novelty_flag=novelty_flag, human_notes=human_notes)
|
||||
flash("Review saved.", "success")
|
||||
return redirect(url_for("results.result_detail",
|
||||
domain_name=domain_name, combo_id=combo_id))
|
||||
156
src/physcom_web/static/style.css
Normal file
156
src/physcom_web/static/style.css
Normal file
@@ -0,0 +1,156 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #1a1a2e;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Nav ─────────────────────────────────────────────────── */
|
||||
nav {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
nav .nav-brand { color: #fff; font-weight: 700; font-size: 1.1rem; }
|
||||
nav ul { list-style: none; display: flex; gap: 1.25rem; }
|
||||
nav a { color: #c4c4d4; }
|
||||
nav a:hover { color: #fff; text-decoration: none; }
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────── */
|
||||
main { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
|
||||
/* ── Page header ─────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.75rem; }
|
||||
h2 { font-size: 1.2rem; margin: 1rem 0 0.5rem; }
|
||||
h3 { font-size: 1rem; margin-bottom: 0.25rem; }
|
||||
.subtitle { font-weight: 400; color: #666; font-size: 0.9rem; }
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e2e8;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────── */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th, td { padding: 0.4rem 0.6rem; text-align: left; border-bottom: 1px solid #eee; }
|
||||
th { font-weight: 600; color: #555; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
table.compact th, table.compact td { padding: 0.25rem 0.4rem; font-size: 0.85rem; }
|
||||
|
||||
/* ── Score cells ─────────────────────────────────────────── */
|
||||
.score-cell { font-family: monospace; font-weight: 600; }
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #e2e2e8;
|
||||
color: #333;
|
||||
}
|
||||
.badge-requires { background: #dbeafe; color: #1e40af; }
|
||||
.badge-provides { background: #dcfce7; color: #166534; }
|
||||
.badge-range_min, .badge-range_max { background: #fef3c7; color: #92400e; }
|
||||
.badge-excludes { background: #fee2e2; color: #991b1b; }
|
||||
.badge-valid { background: #dcfce7; color: #166534; }
|
||||
.badge-blocked { background: #fee2e2; color: #991b1b; }
|
||||
.badge-scored { background: #dbeafe; color: #1e40af; }
|
||||
.badge-reviewed { background: #f3e8ff; color: #6b21a8; }
|
||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: #f3f4f6; text-decoration: none; }
|
||||
.btn-primary { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
|
||||
|
||||
/* ── Forms ───────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 0.75rem; }
|
||||
.form-group label { display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 0.25rem; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-actions { margin-top: 1rem; display: flex; gap: 0.5rem; }
|
||||
.form-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.form-row input, .form-row select { width: auto; flex: 1; min-width: 80px; }
|
||||
|
||||
fieldset { border: 1px solid #e2e2e8; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
||||
legend { font-weight: 600; font-size: 0.85rem; padding: 0 0.3rem; }
|
||||
.checkbox-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.checkbox-row label { display: flex; align-items: center; gap: 0.3rem; font-size: 0.9rem; }
|
||||
|
||||
/* ── Inline form (for delete buttons) ────────────────────── */
|
||||
.inline-form { display: inline; }
|
||||
|
||||
/* ── Flash messages ──────────────────────────────────────── */
|
||||
.flash-container { margin-bottom: 1rem; }
|
||||
.flash {
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.flash-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.flash-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.flash-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
|
||||
|
||||
/* ── Filter row ──────────────────────────────────────────── */
|
||||
.filter-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.filter-row span { font-weight: 600; font-size: 0.85rem; color: #555; }
|
||||
|
||||
/* ── DL styling ──────────────────────────────────────────── */
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; }
|
||||
dt { font-weight: 600; font-size: 0.85rem; color: #555; }
|
||||
dd { font-size: 0.9rem; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────── */
|
||||
.empty { color: #666; padding: 2rem 0; text-align: center; }
|
||||
|
||||
/* ── Actions column ──────────────────────────────────────── */
|
||||
.actions { white-space: nowrap; display: flex; gap: 0.25rem; }
|
||||
|
||||
/* ── Dep add form ────────────────────────────────────────── */
|
||||
.dep-add-form { margin-top: 0.75rem; }
|
||||
35
src/physcom_web/templates/base.html
Normal file
35
src/physcom_web/templates/base.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}PhysCom{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="{{ url_for('entities.entity_list') }}" class="nav-brand">PhysCom</a>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('entities.entity_list') }}">Entities</a></li>
|
||||
<li><a href="{{ url_for('domains.domain_list') }}">Domains</a></li>
|
||||
<li><a href="{{ url_for('pipeline.pipeline_form') }}">Pipeline</a></li>
|
||||
<li><a href="{{ url_for('results.results_index') }}">Results</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-container">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
34
src/physcom_web/templates/domains/list.html
Normal file
34
src/physcom_web/templates/domains/list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Domains — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Domains</h1>
|
||||
|
||||
{% if not domains %}
|
||||
<p class="empty">No domains found. Seed data via CLI first.</p>
|
||||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% for d in domains %}
|
||||
<div class="card">
|
||||
<h2>{{ d.name }}</h2>
|
||||
<p>{{ d.description }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Weight</th><th>Min</th><th>Max</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mb in d.metric_bounds %}
|
||||
<tr>
|
||||
<td>{{ mb.metric_name }}</td>
|
||||
<td>{{ mb.weight }}</td>
|
||||
<td>{{ mb.norm_min }}</td>
|
||||
<td>{{ mb.norm_max }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
80
src/physcom_web/templates/entities/_dep_table.html
Normal file
80
src/physcom_web/templates/entities/_dep_table.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<table id="dep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Unit</th>
|
||||
<th>Type</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dep-table-body">
|
||||
{% for dep in entity.dependencies %}
|
||||
<tr>
|
||||
<td>{{ dep.category }}</td>
|
||||
<td>{{ dep.key }}</td>
|
||||
<td>{{ dep.value }}</td>
|
||||
<td>{{ dep.unit or '—' }}</td>
|
||||
<td><span class="badge badge-{{ dep.constraint_type }}">{{ dep.constraint_type }}</span></td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm"
|
||||
onclick="this.closest('tr').querySelector('.edit-row').style.display='table-row'; this.closest('tr').style.display='none'">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('entities.dep_delete', entity_id=entity.id, dep_id=dep.id) }}"
|
||||
hx-target="#dep-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('entities.dep_edit', entity_id=entity.id, dep_id=dep.id) }}"
|
||||
hx-target="#dep-section" hx-swap="innerHTML">
|
||||
<td><input name="category" value="{{ dep.category }}" required></td>
|
||||
<td><input name="key" value="{{ dep.key }}" required></td>
|
||||
<td><input name="value" value="{{ dep.value }}" required></td>
|
||||
<td><input name="unit" value="{{ dep.unit or '' }}"></td>
|
||||
<td>
|
||||
<select name="constraint_type">
|
||||
{% for ct in ['requires', 'provides', 'range_min', 'range_max', 'excludes'] %}
|
||||
<option value="{{ ct }}" {{ 'selected' if dep.constraint_type == ct }}>{{ ct }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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 Dependency</h3>
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('entities.dep_add', entity_id=entity.id) }}"
|
||||
hx-target="#dep-section" hx-swap="innerHTML"
|
||||
class="dep-add-form">
|
||||
<div class="form-row">
|
||||
<input name="category" placeholder="category" required>
|
||||
<input name="key" placeholder="key" required>
|
||||
<input name="value" placeholder="value" required>
|
||||
<input name="unit" placeholder="unit">
|
||||
<select name="constraint_type">
|
||||
<option value="requires">requires</option>
|
||||
<option value="provides">provides</option>
|
||||
<option value="range_min">range_min</option>
|
||||
<option value="range_max">range_max</option>
|
||||
<option value="excludes">excludes</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
28
src/physcom_web/templates/entities/detail.html
Normal file
28
src/physcom_web/templates/entities/detail.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ entity.name }} — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ entity.name }}</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('entities.entity_edit', entity_id=entity.id) }}" class="btn">Edit</a>
|
||||
<form method="post" action="{{ url_for('entities.entity_delete', entity_id=entity.id) }}" class="inline-form"
|
||||
onsubmit="return confirm('Delete this entity?')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl>
|
||||
<dt>Dimension</dt><dd>{{ entity.dimension }}</dd>
|
||||
<dt>Description</dt><dd>{{ entity.description or '—' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h2>Dependencies</h2>
|
||||
|
||||
<div id="dep-section">
|
||||
{% include "entities/_dep_table.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
38
src/physcom_web/templates/entities/form.html
Normal file
38
src/physcom_web/templates/entities/form.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit' if entity else 'Add' }} Entity — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ 'Edit' if entity else 'New' }} Entity</h1>
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="dimension">Dimension</label>
|
||||
{% if entity %}
|
||||
<input type="text" id="dimension" name="dimension" value="{{ entity.dimension }}" readonly>
|
||||
{% else %}
|
||||
<input type="text" id="dimension" name="dimension" list="dim-list"
|
||||
value="{{ request.form.get('dimension', '') }}" required placeholder="e.g. platform">
|
||||
<datalist id="dim-list">
|
||||
{% for d in dimensions %}
|
||||
<option value="{{ d.name }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name"
|
||||
value="{{ entity.name if entity else request.form.get('name', '') }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3">{{ entity.description if entity else request.form.get('description', '') }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save' if entity else 'Create' }}</button>
|
||||
<a href="{{ url_for('entities.entity_detail', entity_id=entity.id) if entity else url_for('entities.entity_list') }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
43
src/physcom_web/templates/entities/list.html
Normal file
43
src/physcom_web/templates/entities/list.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Entities — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Entities</h1>
|
||||
<a href="{{ url_for('entities.entity_new') }}" class="btn btn-primary">+ Add Entity</a>
|
||||
</div>
|
||||
|
||||
{% if not grouped %}
|
||||
<p class="empty">No entities found. <a href="{{ url_for('entities.entity_new') }}">Add one</a> or seed data via CLI.</p>
|
||||
{% else %}
|
||||
{% for dimension, entities in grouped.items() %}
|
||||
<div class="card">
|
||||
<h2>{{ dimension }}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Deps</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entities %}
|
||||
<tr>
|
||||
<td>{{ e.id }}</td>
|
||||
<td><a href="{{ url_for('entities.entity_detail', entity_id=e.id) }}">{{ e.name }}</a></td>
|
||||
<td>{{ e.description }}</td>
|
||||
<td>{{ e.dependencies|length }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('entities.entity_edit', entity_id=e.id) }}" class="btn btn-sm">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
60
src/physcom_web/templates/pipeline/run.html
Normal file
60
src/physcom_web/templates/pipeline/run.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Run Pipeline — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Run Pipeline</h1>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{{ url_for('pipeline.pipeline_run') }}">
|
||||
<div class="form-group">
|
||||
<label for="domain">Domain</label>
|
||||
<select name="domain" id="domain" required>
|
||||
<option value="">— select —</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.name }}">{{ d.name }} — {{ d.description }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Passes</legend>
|
||||
<div class="checkbox-row">
|
||||
{% for p in [1, 2, 3, 4, 5] %}
|
||||
<label>
|
||||
<input type="checkbox" name="passes" value="{{ p }}"
|
||||
{{ 'checked' if p <= 3 }}>
|
||||
Pass {{ p }}
|
||||
{% if p == 1 %}(Constraints)
|
||||
{% elif p == 2 %}(Estimation)
|
||||
{% elif p == 3 %}(Scoring)
|
||||
{% elif p == 4 %}(LLM Review)
|
||||
{% elif p == 5 %}(Human Review)
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="threshold">Score Threshold</label>
|
||||
<input type="number" name="threshold" id="threshold" value="0.1" step="0.01" min="0" max="1">
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>Dimensions</legend>
|
||||
<div class="checkbox-row">
|
||||
{% for d in dimensions %}
|
||||
<label>
|
||||
<input type="checkbox" name="dimensions" value="{{ d.name }}" checked>
|
||||
{{ d.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Run Pipeline</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
7
src/physcom_web/templates/results/_review_done.html
Normal file
7
src/physcom_web/templates/results/_review_done.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="card">
|
||||
<p class="flash flash-success">Review saved.</p>
|
||||
<dl>
|
||||
{% if novelty_flag %}<dt>Novelty</dt><dd>{{ novelty_flag }}</dd>{% endif %}
|
||||
{% if human_notes %}<dt>Notes</dt><dd>{{ human_notes }}</dd>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
22
src/physcom_web/templates/results/_review_form.html
Normal file
22
src/physcom_web/templates/results/_review_form.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="card">
|
||||
<form method="post"
|
||||
hx-post="{{ url_for('results.submit_review', domain_name=domain.name, combo_id=combo.id) }}"
|
||||
hx-target="#review-section" hx-swap="innerHTML">
|
||||
<div class="form-group">
|
||||
<label for="novelty_flag">Novelty Flag</label>
|
||||
<select name="novelty_flag" id="novelty_flag">
|
||||
<option value="">— none —</option>
|
||||
<option value="novel" {{ 'selected' if result and result.novelty_flag == 'novel' }}>novel</option>
|
||||
<option value="exists" {{ 'selected' if result and result.novelty_flag == 'exists' }}>exists</option>
|
||||
<option value="researched" {{ 'selected' if result and result.novelty_flag == 'researched' }}>researched</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="human_notes">Notes</label>
|
||||
<textarea name="human_notes" id="human_notes" rows="3">{{ result.human_notes if result and result.human_notes else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Review</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
82
src/physcom_web/templates/results/detail.html
Normal file
82
src/physcom_web/templates/results/detail.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Combination #{{ combo.id }} — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Combination #{{ combo.id }}</h1>
|
||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name) }}" class="btn">Back to list</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl>
|
||||
<dt>Domain</dt><dd>{{ domain.name }}</dd>
|
||||
<dt>Status</dt><dd><span class="badge badge-{{ combo.status }}">{{ combo.status }}</span></dd>
|
||||
{% if combo.block_reason %}
|
||||
<dt>Block Reason</dt><dd>{{ combo.block_reason }}</dd>
|
||||
{% endif %}
|
||||
{% if result %}
|
||||
<dt>Composite Score</dt><dd class="score-cell">{{ "%.4f"|format(result.composite_score) }}</dd>
|
||||
<dt>Pass Reached</dt><dd>{{ result.pass_reached }}</dd>
|
||||
{% if result.novelty_flag %}
|
||||
<dt>Novelty</dt><dd>{{ result.novelty_flag }}</dd>
|
||||
{% endif %}
|
||||
{% if result.llm_review %}
|
||||
<dt>LLM Review</dt><dd>{{ result.llm_review }}</dd>
|
||||
{% endif %}
|
||||
{% if result.human_notes %}
|
||||
<dt>Human Notes</dt><dd>{{ result.human_notes }}</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h2>Entities</h2>
|
||||
<div class="card-grid">
|
||||
{% for e in combo.entities %}
|
||||
<div class="card">
|
||||
<h3><a href="{{ url_for('entities.entity_detail', entity_id=e.id) }}">{{ e.name }}</a></h3>
|
||||
<p class="subtitle">{{ e.dimension }}</p>
|
||||
<p>{{ e.description }}</p>
|
||||
<table class="compact">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
{% for dep in e.dependencies %}
|
||||
<tr>
|
||||
<td>{{ dep.key }}</td>
|
||||
<td>{{ dep.value }}{{ ' ' + dep.unit if dep.unit else '' }}</td>
|
||||
<td><span class="badge badge-{{ dep.constraint_type }}">{{ dep.constraint_type }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if scores %}
|
||||
<h2>Per-Metric Scores</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Raw Value</th><th>Normalized</th><th>Method</th><th>Confidence</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in scores %}
|
||||
<tr>
|
||||
<td>{{ s.metric_name }}</td>
|
||||
<td>{{ "%.2f"|format(s.raw_value) if s.raw_value is not none else '—' }}</td>
|
||||
<td class="score-cell">{{ "%.4f"|format(s.normalized_score) if s.normalized_score is not none else '—' }}</td>
|
||||
<td>{{ s.estimation_method or '—' }}</td>
|
||||
<td>{{ "%.2f"|format(s.confidence) if s.confidence is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Human Review</h2>
|
||||
<div id="review-section">
|
||||
{% include "results/_review_form.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
src/physcom_web/templates/results/list.html
Normal file
69
src/physcom_web/templates/results/list.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Results — PhysCom{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Results</h1>
|
||||
|
||||
<div class="form-row" style="margin-bottom:1rem">
|
||||
{% for d in domains %}
|
||||
<a href="{{ url_for('results.results_domain', domain_name=d.name) }}"
|
||||
class="btn {{ 'btn-primary' if domain and domain.name == d.name else '' }}">
|
||||
{{ d.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if domain and results is not none %}
|
||||
<div class="card">
|
||||
<h2>{{ domain.name }} <span class="subtitle">{{ domain.description }}</span></h2>
|
||||
|
||||
{% if statuses %}
|
||||
<div class="filter-row">
|
||||
<span>Filter:</span>
|
||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name) }}"
|
||||
class="btn btn-sm {{ '' if status_filter else 'btn-primary' }}">All</a>
|
||||
{% for s, cnt in statuses.items() %}
|
||||
<a href="{{ url_for('results.results_domain', domain_name=domain.name, status=s) }}"
|
||||
class="btn btn-sm {{ 'btn-primary' if status_filter == s else '' }}">
|
||||
{{ s }} ({{ cnt }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not results %}
|
||||
<p class="empty">No results yet. <a href="{{ url_for('pipeline.pipeline_form') }}">Run the pipeline</a> first.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Score</th>
|
||||
<th>Entities</th>
|
||||
<th>Status</th>
|
||||
<th>Novelty</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="score-cell">{{ "%.4f"|format(r.composite_score) }}</td>
|
||||
<td>{{ r.combination.entities|map(attribute='name')|join(' + ') }}</td>
|
||||
<td><span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span></td>
|
||||
<td>{{ r.novelty_flag or '—' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('results.result_detail', domain_name=domain.name, combo_id=r.combination.id) }}"
|
||||
class="btn btn-sm">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif not domain %}
|
||||
<p class="empty">Select a domain above to view results.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user