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