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:
Simonson, Andrew
2026-02-18 13:59:53 -06:00
parent 6e0f82835a
commit 8118a62242
54 changed files with 3505 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""PhysCom Web — Flask UI for Physical Combinatorics."""

View 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
View 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")))

View File

View 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)

View 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)

View 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))

View 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))

View 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; }

View 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>

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View 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 %}